@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
package/tests/modules.mjs CHANGED
@@ -1,6 +1,7 @@
1
1
  // tests/modules.mjs — the feature-module selection resolver (task 1193 / V4.R54, ADR 0062 §4).
2
2
  import assert from 'node:assert/strict';
3
3
  import { test } from 'node:test';
4
+ import { existsSync } from 'node:fs';
4
5
  import modulesMod from '../src/modules.js';
5
6
 
6
7
  const {
@@ -17,6 +18,14 @@ const OTB = { modules: { game: true, 'art-pipeline': true, discord: true, 'dev-b
17
18
  // loadModules() does in production. Membership across the move is preserved here.
18
19
  const EFFECTIVE = modulesMod.effectiveRegistry();
19
20
 
21
+ // Sample feature-module keys for the PURE resolver tests below. `game` and
22
+ // `art-pipeline` left the core at the CB→OTB split, so the effective registry no
23
+ // longer knows them and passing them made resolveModules throw `cannot enable
24
+ // unknown module` — the tests were asserting a topology, not the resolver (task
25
+ // 1002470). Synthesized here so the precedence/merge/freeze semantics stay under
26
+ // test on any instance, whatever it happens to have installed.
27
+ const SAMPLE_REG = { ...EFFECTIVE, game: { default: false }, 'art-pipeline': { default: false } };
28
+
20
29
  test('vanilla (no config) resolves every module OFF — fail-to-vanilla, never fail-to-OTB', () => {
21
30
  const m = resolveModules({});
22
31
  for (const k of KNOWN_MODULES) assert.equal(m[k], false, `${k} should default off`);
@@ -28,7 +37,7 @@ test('every registry module has an explicit default of false', () => {
28
37
  });
29
38
 
30
39
  test('instance config turns modules on', () => {
31
- const m = resolveModules({ neutral: VANILLA, instance: OTB, registry: EFFECTIVE });
40
+ const m = resolveModules({ neutral: VANILLA, instance: OTB, registry: SAMPLE_REG });
32
41
  assert.equal(m.game, true);
33
42
  assert.equal(m['dev-box'], true);
34
43
  assert.equal(isModuleEnabled('discord', m), true);
@@ -41,19 +50,31 @@ test('instance config turns modules on', () => {
41
50
  // gate + builder economy + idea inbox + red-team intake + prefs + onboarding +
42
51
  // session corpus + the MAS autonomy machinery + the status dashboard + the
43
52
  // builders' hall ship to every instance — see each module.json).
44
- assert.deepEqual(enabledModules(m).sort(), ['art-pipeline', 'autonomy', 'builder-settings', 'dev-box', 'discord', 'economy', 'game', 'grading', 'hall-ui', 'ideas', 'lifecycle', 'memory', 'onboarding', 'security', 'sessions', 'status-ui']);
53
+ // Asserted as a PROPERTY rather than a frozen 16-key list (which named modules the
54
+ // core no longer carries — task 1002470).
55
+ // Everything the instance switched on resolves on...
56
+ for (const k of Object.keys(OTB.modules)) assert.equal(m[k], true, `instance-enabled '${k}' must resolve on`);
57
+ // ...every `default: true` core-domain module resolves on WITHOUT being listed...
58
+ const coreDefaults = Object.keys(SAMPLE_REG).filter((k) => SAMPLE_REG[k].default);
59
+ assert.ok(coreDefaults.length >= 5, `expected a real core-domain roster, found ${coreDefaults.length}`);
60
+ for (const k of coreDefaults) assert.equal(m[k], true, `default-on core module '${k}' must resolve on unlisted`);
61
+ // ...and a registry key that is neither listed nor default-on stays off.
62
+ for (const k of Object.keys(SAMPLE_REG)) {
63
+ if (SAMPLE_REG[k].default || k in OTB.modules) continue;
64
+ assert.equal(m[k], false, `unlisted opt-in module '${k}' must stay off`);
65
+ }
45
66
  });
46
67
 
47
68
  test('instance config overrides the neutral starter per-flag (deep merge of the modules map)', () => {
48
69
  const neutral = { modules: { game: false, discord: false, 'dev-box': false, 'art-pipeline': false } };
49
70
  const instance = { modules: { discord: true } }; // only flip one
50
- const m = resolveModules({ neutral, instance, registry: EFFECTIVE });
71
+ const m = resolveModules({ neutral, instance, registry: SAMPLE_REG });
51
72
  assert.equal(m.discord, true);
52
73
  assert.equal(m.game, false); // neutral value survives
53
74
  });
54
75
 
55
76
  test('env override beats instance config (precedence), with prefix + snake-casing', () => {
56
- const m = resolveModules({ instance: OTB, env: { OTB_MODULE_DEV_BOX: '0' }, envPrefix: 'OTB', registry: EFFECTIVE });
77
+ const m = resolveModules({ instance: OTB, env: { OTB_MODULE_DEV_BOX: '0' }, envPrefix: 'OTB', registry: SAMPLE_REG });
57
78
  assert.equal(m['dev-box'], false); // env off beat instance on
58
79
  assert.equal(m.game, true); // others untouched
59
80
  });
@@ -111,19 +132,23 @@ test('disciplines: vanilla offers engineer + ideator (core); art-pipeline adds a
111
132
  // instance), so it is offered with no modules on; only 'artist' is module-gated.
112
133
  // art-pipeline is now a DISCOVERED module (BV1.R48), so pass EFFECTIVE registry.
113
134
  assert.deepEqual(enabledDisciplines(resolveModules({})), ['engineer', 'ideator']);
114
- const m = resolveModules({ instance: { modules: { 'art-pipeline': true } }, registry: EFFECTIVE });
135
+ // The 'artist' leg reads art-pipeline's OWN module.json contributes, so it only
136
+ // means anything where that module is installed (task 1002470). The core half
137
+ // above — ideation ships to every instance — is asserted unconditionally.
138
+ if (!EFFECTIVE['art-pipeline']) return;
139
+ const m = resolveModules({ instance: { modules: { 'art-pipeline': true } }, registry: SAMPLE_REG });
115
140
  assert.deepEqual(enabledDisciplines(m), ['engineer', 'ideator', 'artist']);
116
141
  });
117
142
 
118
143
  test('clientModules is a safe boolean projection covering every known module + disciplines', () => {
119
- const c = clientModules(resolveModules({ instance: OTB, registry: EFFECTIVE }));
144
+ const c = clientModules(resolveModules({ instance: OTB, registry: SAMPLE_REG }));
120
145
  for (const k of KNOWN_MODULES) assert.equal(typeof c.enabled[k], 'boolean');
121
146
  assert.ok(Array.isArray(c.disciplines));
122
147
  assert.ok(c.disciplines.includes('engineer'));
123
148
  });
124
149
 
125
150
  test('the resolved set is frozen (callers cannot mutate the singleton)', () => {
126
- const m = resolveModules({ instance: OTB, registry: EFFECTIVE });
151
+ const m = resolveModules({ instance: OTB, registry: SAMPLE_REG });
127
152
  assert.throws(() => { m.game = false; }, TypeError);
128
153
  });
129
154
 
@@ -131,6 +156,9 @@ test('the committed config files resolve: OTB enables all four, on disk', () =>
131
156
  // loadModules reads config/modules.json over config/modules.neutral.json.
132
157
  // No GDS_BRANDING_FILE → still the default OTB instance pack (regression guard
133
158
  // for the GDS_MODULES_FILE selector below).
159
+ // config/modules.json is INSTANCE content — the neutral core ships only
160
+ // config/modules.neutral.json, so there is no instance pack to assert (task 1002470).
161
+ if (!existsSync(new URL('../config/modules.json', import.meta.url))) return;
134
162
  const m = loadModules({ env: {} });
135
163
  assert.equal(m.game, true);
136
164
  assert.equal(m['art-pipeline'], true);
@@ -7,14 +7,21 @@ import { test } from 'node:test';
7
7
  import { createRequire } from 'node:module';
8
8
  import path from 'node:path';
9
9
  import { fileURLToPath } from 'node:url';
10
+ import { onDiskModuleKeys } from './helpers.mjs';
10
11
 
11
12
  const require = createRequire(import.meta.url);
12
13
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
13
14
  const { listModules } = require(path.join(ROOT, 'src', 'bongos', 'routes', 'modules.js'));
14
15
 
16
+ // The roster is compared against a direct disk read (helpers.mjs onDiskModuleKeys) rather
17
+ // than a hardcoded floor — that floor (>=18) went stale when modules left the core at the
18
+ // CB→OTB split, turning a real exhaustiveness check into a census (task 1002470).
19
+
15
20
  test('listModules: every on-disk module is present, sorted, and well-formed', () => {
16
21
  const rows = listModules();
17
- assert.ok(rows.length >= 18, `expected every on-disk module (got ${rows.length})`);
22
+ const expected = onDiskModuleKeys();
23
+ assert.ok(expected.length >= 5, `expected a real on-disk module roster, found ${expected.length}`);
24
+ assert.deepEqual(rows.map((m) => m.key).sort(), expected, 'listModules must return exactly the on-disk modules');
18
25
  for (const m of rows) {
19
26
  assert.equal(typeof m.key, 'string');
20
27
  assert.ok(['own', 'core'].includes(m.provenance), `${m.key}: provenance is own|core`);
@@ -24,15 +31,32 @@ test('listModules: every on-disk module is present, sorted, and well-formed', ()
24
31
  assert.deepEqual(keys, [...keys].sort(), 'listModules is sorted by key');
25
32
  });
26
33
 
27
- test('listModules: today, every module is own-built (ADR 0107 audit — no module names a foreign origin yet)', () => {
34
+ // This used to assert a CENSUS ("today, every module is own-built") that was true only
35
+ // while the instance was OTB. On a neutral core, provisioning + platform-identity carry
36
+ // an origin naming another project and correctly classify 'core' — the census was stale,
37
+ // not the code (task 1002470). Assert the ADR 0107 §9 RULE instead, which holds on every
38
+ // instance: no origin => own, and an origin naming this project => own.
39
+ test('listModules: provenance follows the ADR 0107 §9 rule (no origin, or a self-naming origin, is own-built)', () => {
28
40
  const rows = listModules();
29
- for (const m of rows) assert.equal(m.provenance, 'own', `${m.key} unexpectedly classifies core-loaded`);
41
+ let withOrigin = 0;
42
+ for (const m of rows) {
43
+ if (!m.origin) {
44
+ assert.equal(m.provenance, 'own', `${m.key} declares no origin, so it must classify own-built`);
45
+ continue;
46
+ }
47
+ withOrigin += 1;
48
+ assert.ok(['own', 'core'].includes(m.provenance), `${m.key}: provenance must be own|core`);
49
+ }
50
+ // Non-vacuity: the no-origin branch must actually have been exercised.
51
+ assert.ok(rows.length > withOrigin, 'expected at least one module with no declared origin');
30
52
  });
31
53
 
32
- test('listModules: game module is on-disk, enabled in the OTB instance config, and carries no origin', () => {
54
+ test('listModules: an installed game module is enabled and carries no origin', () => {
33
55
  const rows = listModules();
34
56
  const game = rows.find((m) => m.key === 'game');
35
- assert.ok(game, 'game module discovered');
57
+ // modules/game/ left the core at the CB→OTB split; where it IS installed the
58
+ // original contract still holds (task 1002470).
59
+ if (!game) return;
36
60
  assert.equal(game.enabled, true);
37
61
  assert.equal(game.origin, null);
38
62
  });
@@ -43,6 +43,12 @@ async function t(name, fn) {
43
43
  // src/bongos/routes, so we patch the resolved module before requiring the
44
44
  // routes.
45
45
  const authPath = require.resolve('../src/bongos/auth.js');
46
+ const catalog = require('../modules/governance/catalog.js');
47
+ // Mirrors the canonical ladder in modules/governance/catalog.js — omitting a rung
48
+ // would resolve its floor to Infinity and deny everyone, including archon. Kept at
49
+ // module scope (BV1.R104) so requireRank and requirePermission below share ONE
50
+ // ladder; R105's branch still had the stale 3-rung inline copy that dropped thetes.
51
+ const RANK_ORDER = { xenos: 1, thetes: 2, metic: 3, archon: 4 };
46
52
  const stubAuth = {
47
53
  requireBuilder: (req, _res, next) => {
48
54
  // The test harness sets req._stub_builder beforehand.
@@ -52,8 +58,7 @@ const stubAuth = {
52
58
  },
53
59
  requireRank: (rank) => (req, res, next) => {
54
60
  if (!req.builder) return res.status(401).json({ error: 'unauth' });
55
- const order = { xenos: 1, metic: 2, archon: 3 };
56
- if ((order[req.builder.rank] || 0) < (order[rank] || 0)) {
61
+ if ((RANK_ORDER[req.builder.rank] || 0) < (RANK_ORDER[rank] || 0)) {
57
62
  return res.status(403).json({
58
63
  error: 'rank_forbidden',
59
64
  required: rank,
@@ -62,6 +67,28 @@ const stubAuth = {
62
67
  }
63
68
  next();
64
69
  },
70
+ // BV1.R104/R105 migrated these gates to requirePermission — core routes in R104,
71
+ // module routes (here: POST /claims/:id/release-on-behalf) in R105. Resolve each
72
+ // atom's floor from the REAL catalog and apply the same threshold as the rank stub
73
+ // above, so the stub admits exactly who the requireRank spelling did — the seeded
74
+ // rank-roles make the two gates equivalent (ADR 0151). An unknown atom denies
75
+ // (Infinity) — fail-closed, matching the live gate; a stub that admitted on an
76
+ // unknown key would hide precisely the typo this suite exists to catch.
77
+ requirePermission: (...perms) => (req, res, next) => {
78
+ if (!req.builder) return res.status(401).json({ error: 'unauth' });
79
+ const floor = perms.reduce(
80
+ (max, key) => Math.max(max, RANK_ORDER[catalog.byKey(key)?.floor] || Infinity),
81
+ 0,
82
+ );
83
+ if ((RANK_ORDER[req.builder.rank] || 0) < floor) {
84
+ return res.status(403).json({
85
+ error: 'permission_forbidden',
86
+ required: perms,
87
+ held: [],
88
+ });
89
+ }
90
+ next();
91
+ },
65
92
  };
66
93
  require.cache[authPath] = {
67
94
  id: authPath,
@@ -190,23 +217,28 @@ function send({ port, method, path, body, headers = {} }) {
190
217
 
191
218
  const { server, port } = await listen(buildApp());
192
219
 
193
- await t('GET /builders/roster as xenos → 403 rank_forbidden', async () => {
220
+ // BV1.R104 migrated this gate to requirePermission('builder.roster.read') — an
221
+ // archon-floored atom, so the SAME callers are refused; only the 403 code and the
222
+ // `required` shape changed (permission_forbidden + the atom list, not a rank name).
223
+ await t('GET /builders/roster as xenos → 403 permission_forbidden', async () => {
194
224
  const r = await send({
195
225
  port, method: 'GET', path: '/api/gds/builders/roster',
196
226
  headers: { 'x-stub-builder-id': '7', 'x-stub-builder-rank': 'xenos' },
197
227
  });
198
228
  assert.equal(r.status, 403);
199
- assert.equal(r.json?.error, 'rank_forbidden');
200
- assert.equal(r.json?.required, 'archon');
229
+ assert.equal(r.json?.error, 'permission_forbidden');
230
+ assert.deepEqual(r.json?.required, ['builder.roster.read']);
201
231
  });
202
232
 
203
- await t('GET /builders/roster as metic → 403 rank_forbidden', async () => {
233
+ // ADR 0157 lowered `builder.roster.read` archon→metic: reading who is on the roster is
234
+ // administration, not authority over who holds authority. The refusal assertion that
235
+ // still guards the surface is the sub-Metic one above (thetes/xenos → 403).
236
+ await t('GET /builders/roster as metic → admitted (ADR 0157)', async () => {
204
237
  const r = await send({
205
238
  port, method: 'GET', path: '/api/gds/builders/roster',
206
239
  headers: { 'x-stub-builder-id': '7', 'x-stub-builder-rank': 'metic' },
207
240
  });
208
- assert.equal(r.status, 403);
209
- assert.equal(r.json?.required, 'archon');
241
+ assert.notEqual(r.status, 403, 'a metic now holds builder.roster.read');
210
242
  });
211
243
 
212
244
  await t('GET /builders/roster as archon → 200', async () => {
@@ -279,16 +311,22 @@ await t('POST /claims/:id/release-on-behalf as xenos → 403', async () => {
279
311
  headers: { 'x-stub-builder-id': '7', 'x-stub-builder-rank': 'xenos' },
280
312
  });
281
313
  assert.equal(r.status, 403);
282
- assert.equal(r.json?.error, 'rank_forbidden');
314
+ // BV1.R105: the route now gates on `claim.release.any`, so the denial code is
315
+ // requirePermission's `permission_forbidden`. The AUDIENCE is unchanged (the atom
316
+ // floors at archon) — only the spelling of the refusal moved. This is the R97
317
+ // 403-contract change reaching its first module route.
318
+ assert.equal(r.json?.error, 'permission_forbidden');
283
319
  });
284
320
 
285
- await t('POST /claims/:id/release-on-behalf as metic → 403', async () => {
321
+ // ADR 0157 lowered `claim.release.any` archon→metic: freeing another builder's stale
322
+ // claim is queue housekeeping, not an authority grant.
323
+ await t('POST /claims/:id/release-on-behalf as metic → admitted (ADR 0157)', async () => {
286
324
  const r = await send({
287
325
  port, method: 'POST', path: '/api/gds/claims/123/release-on-behalf',
288
326
  body: { reason: 'stale' },
289
327
  headers: { 'x-stub-builder-id': '7', 'x-stub-builder-rank': 'metic' },
290
328
  });
291
- assert.equal(r.status, 403);
329
+ assert.notEqual(r.status, 403, 'a metic now holds claim.release.any');
292
330
  });
293
331
 
294
332
  await t('POST /claims/:id/release-on-behalf w/o reason → 400 validation_failed', async () => {
@@ -41,6 +41,7 @@ require.cache[authPath] = {
41
41
  exports: {
42
42
  requireBuilder: (_req, _res, next) => next(),
43
43
  requireRank: () => (_req, _res, next) => next(),
44
+ requirePermission: () => (_req, _res, next) => next(),
44
45
  },
45
46
  };
46
47
 
@@ -183,5 +183,85 @@ t('names the in-flight task in the MATCHING worktree, not the first row', () =>
183
183
  assert.equal(d.inFlightTitle, 'Two');
184
184
  });
185
185
 
186
+ // =========================================================================
187
+ // task 1002589 (idea 1000638) — the DB backstop's ERROR SURFACE. A claims
188
+ // INSERT can trip TWO partial unique indexes; claimTask used to map every pg
189
+ // 23505 to ALREADY_CLAIMED, so a trip of migration 180's per-session index
190
+ // read as "task taken by another session" while the task sat 'ready'.
191
+ // claimUniqueViolationError branches on err.constraint: the per-task index
192
+ // keeps ALREADY_CLAIMED, the per-session index gets its own honest code, and
193
+ // an UNKNOWN constraint fails CLOSED on ALREADY_CLAIMED (never widen).
194
+ // =========================================================================
195
+ const { claimUniqueViolationError } = require('../modules/lifecycle/db.js');
196
+
197
+ t('23505 on uniq_active_claim_per_task → ALREADY_CLAIMED (race lost, unchanged)', () => {
198
+ const e = claimUniqueViolationError({ code: '23505', constraint: 'uniq_active_claim_per_task' });
199
+ assert.equal(e.code, 'ALREADY_CLAIMED');
200
+ });
201
+
202
+ t('23505 on uniq_active_claim_per_session → session_already_has_bound_claim', () => {
203
+ const e = claimUniqueViolationError({ code: '23505', constraint: 'uniq_active_claim_per_session' });
204
+ assert.equal(e.code, 'session_already_has_bound_claim');
205
+ assert.match(e.what_to_do, /builder-ship|builder-release|[Ss]hip or release/,
206
+ 'what_to_do should name the ship-or-release way out');
207
+ assert.match(e.what_to_do, /bind_session/,
208
+ 'what_to_do should name the bind_session:false escape hatch');
209
+ });
210
+
211
+ // task 1002570: core_204 re-keys the index per (session, worktree) under a
212
+ // NEW name. Both generations must map to the honest code — a deploy applies
213
+ // code and migration at different instants, and an instance may still carry the
214
+ // migration-180 session-only index.
215
+ t('23505 on uniq_active_claim_per_session_worktree (core_204) → same honest code', () => {
216
+ const e = claimUniqueViolationError({ code: '23505', constraint: 'uniq_active_claim_per_session_worktree' });
217
+ assert.equal(e.code, 'session_already_has_bound_claim');
218
+ assert.match(e.what_to_do, /worktree/, 'what_to_do should name the separate-worktree way out');
219
+ assert.match(e.what_to_do, /bind_session/);
220
+ });
221
+
222
+ t('23505 with an unknown constraint fails CLOSED → ALREADY_CLAIMED', () => {
223
+ assert.equal(claimUniqueViolationError({ code: '23505', constraint: 'uniq_something_future' }).code, 'ALREADY_CLAIMED');
224
+ assert.equal(claimUniqueViolationError({ code: '23505' }).code, 'ALREADY_CLAIMED');
225
+ assert.equal(claimUniqueViolationError(null).code, 'ALREADY_CLAIMED');
226
+ });
227
+
228
+ // =========================================================================
229
+ // task 1002570 — the core_204 re-key itself. Text tripwires on the
230
+ // migration file so the index can't silently drift from the guard's boundary
231
+ // (the exact mismatch this whole family of bugs came from). DB-free: pins the
232
+ // SQL's shape, not its execution.
233
+ // =========================================================================
234
+ import { readFileSync } from 'node:fs';
235
+ import { fileURLToPath } from 'node:url';
236
+ import { dirname, join } from 'node:path';
237
+
238
+ // Read lazily inside each pin so a missing/renamed file fails THESE tests, not
239
+ // the whole suite file at import time.
240
+ const migSql = () => readFileSync(
241
+ join(dirname(fileURLToPath(import.meta.url)),
242
+ '../migrations/core_204_rekey_active_claim_per_session_worktree.sql'),
243
+ 'utf8');
244
+
245
+ t('core_204 drops the session-only index and creates the per-worktree one', () => {
246
+ assert.match(migSql(), /DROP INDEX IF EXISTS uniq_active_claim_per_session\b/,
247
+ 'must remove the worktree-blind index');
248
+ assert.match(migSql(), /CREATE UNIQUE INDEX IF NOT EXISTS uniq_active_claim_per_session_worktree/,
249
+ 'must create the re-keyed index idempotently');
250
+ });
251
+
252
+ t('core_204 folds NULL worktrees so a shared clone still self-collides', () => {
253
+ // In a plain multi-column unique index NULLs are DISTINCT — without the fold,
254
+ // two worktree-less claims would both insert and re-open the task-1642
255
+ // commingling hole. COALESCE is the version-safe fold (no PG15 requirement).
256
+ assert.match(migSql(), /COALESCE\(worktree_name,\s*''\)/i);
257
+ });
258
+
259
+ t('core_204 keeps the 180 predicate (active + bound rows only) and heals first', () => {
260
+ assert.match(migSql(), /WHERE released_at IS NULL AND creator_session_id IS NOT NULL/,
261
+ 'partial predicate must match migration 180 (web claims stay excluded)');
262
+ assert.match(migSql(), /PARTITION BY c\.creator_session_id,\s*COALESCE\(c\.worktree_name,\s*''\)/i,
263
+ 'the pre-CREATE heal must group by the NEW key');
264
+ });
265
+
186
266
  console.log(`\n${passed} passed, ${failed} failed`);
187
267
  process.exit(failed ? 1 : 0);
@@ -0,0 +1,102 @@
1
+ // tests/patch_gate_ordering.mjs — PATCH /tasks/:id settles gates before writes
2
+ // (task 1002609, choke-point finding G-22).
3
+ //
4
+ // The bug this pins: the handler used to validate-and-write per field in
5
+ // sequence, so a body mixing a valid early field with a refused later one
6
+ // committed the early write and THEN 403/400'd — a Metic sending
7
+ // { parallel_safe: false, security_sensitive: true } had parallel_safe
8
+ // committed before rank_forbidden. Authorization must be settled before any
9
+ // field is written, and the same holds for every validation refusal.
10
+ //
11
+ // Pinned as source-shape assertions on the PATCH handler slice (the
12
+ // goal_advisory.mjs pattern): every refusal code must appear BEFORE the first
13
+ // db.updateTask* write, and the checker itself is proven non-vacuous against a
14
+ // synthetic counter-example shaped like the old bug.
15
+ //
16
+ // Run: node --test tests/patch_gate_ordering.mjs
17
+
18
+ import assert from 'node:assert/strict';
19
+ import { test } from 'node:test';
20
+ import { readFileSync } from 'node:fs';
21
+
22
+ const src = readFileSync(new URL('../modules/lifecycle/routes/tasks.js', import.meta.url), 'utf8');
23
+
24
+ // The PATCH handler slice: router.patch('/tasks/:id' … up to the next route.
25
+ function patchSlice(source) {
26
+ const start = source.indexOf("router.patch('/tasks/:id'");
27
+ assert.ok(start !== -1, 'PATCH /tasks/:id handler not found');
28
+ const end = source.indexOf('router.post(', start);
29
+ assert.ok(end !== -1, 'no route follows the PATCH handler');
30
+ return source.slice(start, end);
31
+ }
32
+
33
+ // All refusal codes the validation phase can emit, and the first write.
34
+ const REFUSALS = [
35
+ "'rank_forbidden'",
36
+ "'bad_parallel_safe'",
37
+ "'bad_security_sensitive'",
38
+ "'bad_automation_tag'",
39
+ "'bad_module_key'",
40
+ "'bad_goal_id'",
41
+ "'goal_not_found'",
42
+ "'goal_version_mismatch'",
43
+ "'bad_requires_rank'",
44
+ 'badKind(res)',
45
+ "'bad_discipline'",
46
+ ];
47
+
48
+ function firstWriteIndex(slice) {
49
+ const m = slice.match(/await db\.updateTask\w+\(/);
50
+ return m ? m.index : -1;
51
+ }
52
+
53
+ function refusalsBeforeFirstWrite(slice) {
54
+ const writeAt = firstWriteIndex(slice);
55
+ if (writeAt === -1) return { ok: false, offenders: ['no db.updateTask* write found'] };
56
+ const offenders = REFUSALS.filter((code) => {
57
+ const at = slice.indexOf(code);
58
+ return at === -1 || at > writeAt;
59
+ });
60
+ return { ok: offenders.length === 0, offenders };
61
+ }
62
+
63
+ test('every gate and validation refusal precedes the first db write (G-22)', () => {
64
+ const slice = patchSlice(src);
65
+ const r = refusalsBeforeFirstWrite(slice);
66
+ assert.ok(r.ok, `refusal codes missing or after the first write: ${r.offenders.join(', ')}`);
67
+ });
68
+
69
+ test('the Archon gate on security_sensitive specifically precedes the parallel_safe write', () => {
70
+ const slice = patchSlice(src);
71
+ const gate = slice.indexOf("'rank_forbidden'");
72
+ const write = slice.indexOf('await db.updateTaskParallelSafe(');
73
+ assert.ok(gate !== -1 && write !== -1, 'gate or write not found');
74
+ assert.ok(gate < write, 'rank_forbidden must fire before parallel_safe commits — the exact G-22 case');
75
+ });
76
+
77
+ test('write order keeps requires_rank after security_sensitive (derived-floor dependency)', () => {
78
+ const slice = patchSlice(src);
79
+ const ss = slice.indexOf('await db.updateTaskSecuritySensitive(');
80
+ const rr = slice.indexOf('await db.updateTaskRequiresRank(');
81
+ assert.ok(ss !== -1 && rr !== -1, 'expected both writes in the handler');
82
+ assert.ok(ss < rr, 'requires_rank clamps against the derived floor, so its write must follow the security_sensitive write');
83
+ });
84
+
85
+ test('the ordering checker actually CATCHES the old write-then-refuse shape (not a no-op)', () => {
86
+ const oldShape = `router.patch('/tasks/:id', async (req, res) => {
87
+ let updated = null;
88
+ if (hasParallelSafe) {
89
+ if (bad) return res.fail('bad_parallel_safe', 400);
90
+ updated = await db.updateTaskParallelSafe(id, body.parallel_safe);
91
+ }
92
+ if (hasSecuritySensitive) {
93
+ if (!archon) return res.fail('rank_forbidden', 403);
94
+ if (bad) return res.fail('bad_security_sensitive', 400);
95
+ updated = await db.updateTaskSecuritySensitive(id, v);
96
+ }
97
+ });
98
+ router.post('/tasks/:id/promote', () => {});`;
99
+ const r = refusalsBeforeFirstWrite(patchSlice(oldShape));
100
+ assert.equal(r.ok, false, 'the checker must flag a rank gate that follows a write');
101
+ assert.ok(r.offenders.includes("'rank_forbidden'"), 'rank_forbidden after the first write must be the offender');
102
+ });
@@ -4,8 +4,9 @@
4
4
 
5
5
  import { strict as assert } from 'node:assert';
6
6
  import { createRequire } from 'node:module';
7
+ import { requireHostModule } from './helpers.mjs';
7
8
  const require = createRequire(import.meta.url);
8
- const { gateDecision, buildDirective, FLAG } = require('../.claude/hooks/pre-compact.js');
9
+ const { gateDecision, buildDirective, FLAG } = requireHostModule(require, '../.claude/hooks/pre-compact.js');
9
10
 
10
11
  let passed = 0, failed = 0;
11
12
  function test(name, fn) {
@@ -10,12 +10,13 @@
10
10
 
11
11
  import { strict as assert } from 'node:assert';
12
12
  import { createRequire } from 'node:module';
13
+ import { requireHostModule } from './helpers.mjs';
13
14
  import fs from 'node:fs';
14
15
  import os from 'node:os';
15
16
  import path from 'node:path';
16
17
 
17
18
  const require = createRequire(import.meta.url);
18
- const { previewCacheControl, injectLivereload, renderArtGallery } = require('../src/preview-server.js');
19
+ const { previewCacheControl, injectLivereload, renderArtGallery } = requireHostModule(require, '../src/preview-server.js');
19
20
 
20
21
  // A minimal valid 1×1 PNG (transparent), base64-decoded to bytes.
21
22
  const TINY_PNG = Buffer.from(
@@ -83,6 +83,7 @@ require.cache[authPath] = {
83
83
  exports: {
84
84
  requireBuilder: (_req, _res, next) => next(),
85
85
  requireRank: () => (_req, _res, next) => next(),
86
+ requirePermission: () => (_req, _res, next) => next(),
86
87
  },
87
88
  };
88
89