@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
@@ -0,0 +1,180 @@
1
+ // tests/provisioning_onboard_mode.mjs
2
+ //
3
+ // The onboard_mode leg of brownfield onboarding (ADR 0121, task 1002562, migration
4
+ // provisioning_004). Before this, greenfield-vs-adopt existed ONLY as a query param on
5
+ // GET /provisioning/onboard-plan that chose which checklist TEXT to render — it was never
6
+ // persisted, so the control-plane runner ran the greenfield scaffold unconditionally and
7
+ // adopting an existing repo always failed as an unrelated-history non-fast-forward.
8
+ //
9
+ // Covers the two layers that carry the mode from the browser to the runner:
10
+ // • createInstance — normalization (unknown/absent → greenfield) + what reaches SQL
11
+ // • POST /instances — validation + threading, driven over a real mounted router
12
+ // • publicInstance — the mode is readable back out (coalesced for pre-004 rows)
13
+ //
14
+ // DB-free: the pool is a recording fake and the data-access fns the route needs are
15
+ // stubbed on the cached module object. No network.
16
+ //
17
+ // Run: node tests/provisioning_onboard_mode.mjs
18
+
19
+ import { strict as assert } from 'node:assert';
20
+ import { createRequire } from 'node:module';
21
+ import http from 'node:http';
22
+
23
+ const require = createRequire(import.meta.url);
24
+ const ROOT = new URL('..', import.meta.url).pathname;
25
+
26
+ const provisioning = require(`${ROOT}/modules/provisioning/provisioning.js`);
27
+
28
+ let passed = 0, failed = 0;
29
+ function t(name, fn) {
30
+ try { fn(); passed++; console.log(` PASS ${name}`); }
31
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
32
+ }
33
+ async function ta(name, fn) {
34
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
35
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.message}`); }
36
+ }
37
+
38
+ // ── createInstance: normalization + what actually reaches SQL ─────────────────────
39
+ // A recording pool: capture the INSERT's params so we assert the STORED value, not just
40
+ // the returned row (a normalization bug that only shows up in the column is the one that
41
+ // would silently mislabel a row forever).
42
+ function recordingDb(returnRow = {}) {
43
+ const calls = [];
44
+ return {
45
+ calls,
46
+ query: async (text, params) => {
47
+ calls.push({ text, params });
48
+ return { rows: [{ id: 1, owner_builder_id: 7, slug: 'demo', ...returnRow }] };
49
+ },
50
+ };
51
+ }
52
+
53
+ await ta('createInstance: onboardMode "adopt" is persisted', async () => {
54
+ const db = recordingDb();
55
+ await provisioning.createInstance(db, {
56
+ ownerBuilderId: 7, slug: 'demo', targetRef: 'lars589/demo',
57
+ hostingShape: 'standalone', onboardMode: 'adopt',
58
+ });
59
+ const insert = db.calls.find((c) => /INSERT INTO provisioning_instances/i.test(c.text));
60
+ assert.ok(insert, 'issued an INSERT');
61
+ assert.match(insert.text, /onboard_mode/, 'the column is in the INSERT');
62
+ assert.ok(insert.params.includes('adopt'), `'adopt' reached the INSERT params (got ${JSON.stringify(insert.params)})`);
63
+ });
64
+
65
+ await ta('createInstance: absent / unknown onboardMode → greenfield, the SAFE default', async () => {
66
+ // Greenfield refuses to push over existing history (no --force anywhere), so an
67
+ // unrecognized value must land there. Defaulting to 'adopt' would put the layering path
68
+ // on a repo nobody declared as pre-existing.
69
+ for (const mode of [undefined, null, '', 'ADOPT', 'brownfield', 'nonsense']) {
70
+ const db = recordingDb();
71
+ await provisioning.createInstance(db, {
72
+ ownerBuilderId: 7, slug: 'demo', hostingShape: 'standalone', onboardMode: mode,
73
+ });
74
+ const insert = db.calls.find((c) => /INSERT INTO provisioning_instances/i.test(c.text));
75
+ assert.ok(insert.params.includes('greenfield'), `onboardMode=${JSON.stringify(mode)} → greenfield`);
76
+ assert.ok(!insert.params.includes('adopt'), `onboardMode=${JSON.stringify(mode)} must NOT become adopt`);
77
+ }
78
+ });
79
+
80
+ t('ONBOARD_MODES matches the provisioning_004 CHECK constraint', () => {
81
+ assert.deepEqual(provisioning.ONBOARD_MODES, ['greenfield', 'adopt']);
82
+ });
83
+
84
+ t('publicInstance: surfaces onboard_mode, coalescing a pre-004 row to greenfield', () => {
85
+ assert.equal(provisioning.publicInstance({ id: 1, onboard_mode: 'adopt' }).onboard_mode, 'adopt');
86
+ // A row read before the column existed (or a NULL) reads as greenfield — which is what
87
+ // it was, by construction: there was no other scaffold path.
88
+ assert.equal(provisioning.publicInstance({ id: 1 }).onboard_mode, 'greenfield');
89
+ assert.equal(provisioning.publicInstance({ id: 1, onboard_mode: null }).onboard_mode, 'greenfield');
90
+ });
91
+
92
+ // ── POST /provisioning/instances over a real mounted router ───────────────────────
93
+ let lastCreateArgs = null;
94
+ provisioning.createInstance = async (_db, args) => {
95
+ lastCreateArgs = args;
96
+ return { instance: { id: 42, owner_builder_id: 7, slug: args.slug, onboard_mode: args.onboardMode }, created: true };
97
+ };
98
+ provisioning.enqueueIntent = async () => ({ intent: { id: 1, state: 'pending' }, created: true });
99
+ provisioning.recordEvent = async () => {};
100
+ provisioning.getInstanceBySlug = async () => null;
101
+
102
+ const api = require(`${ROOT}/src/module-api.js`);
103
+ api.requireBuilder = (req, _res, next) => { req.builder = { id: 7, github_login: 'owner' }; next(); };
104
+
105
+ const express = require('express');
106
+ const provisioningRoutes = require(`${ROOT}/modules/provisioning/routes/provisioning.js`);
107
+ const app = express();
108
+ app.use(express.json());
109
+ app.use((_req, res, next) => {
110
+ res.fail = (code, opts, details) => {
111
+ const status = typeof opts === 'number' ? opts : (opts && opts.status) || 400;
112
+ res.status(status).json({ error: { code, ...(details || (typeof opts === 'object' ? opts : {})) } });
113
+ };
114
+ next();
115
+ });
116
+ app.use('/api/bongos', provisioningRoutes());
117
+
118
+ const server = app.listen(0);
119
+ await new Promise((r) => server.once('listening', r));
120
+ const base = `http://127.0.0.1:${server.address().port}/api/bongos`;
121
+
122
+ function post(path, body) {
123
+ return new Promise((resolve, reject) => {
124
+ const payload = JSON.stringify(body);
125
+ const req = http.request(`${base}${path}`, {
126
+ method: 'POST',
127
+ headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
128
+ }, (res) => {
129
+ let d = '';
130
+ res.on('data', (c) => { d += c; });
131
+ res.on('end', () => { let j = null; try { j = JSON.parse(d); } catch { /* non-JSON */ } resolve({ status: res.statusCode, body: j, raw: d }); });
132
+ });
133
+ req.on('error', reject);
134
+ req.end(payload);
135
+ });
136
+ }
137
+
138
+ await ta('POST /instances: onboard_mode=adopt is threaded through to createInstance', async () => {
139
+ lastCreateArgs = null;
140
+ const res = await post('/provisioning/instances', {
141
+ slug: 'demo', hosting_shape: 'standalone', target_ref: 'lars589/demo', onboard_mode: 'adopt',
142
+ });
143
+ assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
144
+ assert.equal(lastCreateArgs && lastCreateArgs.onboardMode, 'adopt');
145
+ });
146
+
147
+ await ta('POST /instances: onboard_mode absent → greenfield (unchanged behavior for every existing caller)', async () => {
148
+ lastCreateArgs = null;
149
+ const res = await post('/provisioning/instances', { slug: 'demo', hosting_shape: 'standalone' });
150
+ assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
151
+ assert.equal(lastCreateArgs && lastCreateArgs.onboardMode, 'greenfield');
152
+ });
153
+
154
+ await ta('POST /instances: an unknown onboard_mode is REFUSED (never silently coerced)', async () => {
155
+ // Coercing here would be worse than refusing: the caller believes it asked for one thing
156
+ // and the runner does another. The route names the valid set so the client can fix itself.
157
+ const res = await post('/provisioning/instances', {
158
+ slug: 'demo', hosting_shape: 'standalone', target_ref: 'a/b', onboard_mode: 'brownfield',
159
+ });
160
+ assert.equal(res.status, 400);
161
+ assert.equal(res.body.error.code, 'bad_onboard_mode');
162
+ assert.deepEqual(res.body.error.valid, ['greenfield', 'adopt']);
163
+ });
164
+
165
+ await ta('POST /instances: adopt WITHOUT target_ref is refused — there is no repo to layer onto', async () => {
166
+ // Without target_ref the runner would fall back to greenfield behavior on a row labelled
167
+ // adopt. Refuse at the door rather than store a lie.
168
+ for (const body of [
169
+ { slug: 'demo', hosting_shape: 'standalone', onboard_mode: 'adopt' },
170
+ { slug: 'demo', hosting_shape: 'standalone', onboard_mode: 'adopt', target_ref: ' ' },
171
+ ]) {
172
+ const res = await post('/provisioning/instances', body);
173
+ assert.equal(res.status, 400, `expected 400 for ${JSON.stringify(body)}`);
174
+ assert.equal(res.body.error.code, 'target_ref_required_for_adopt');
175
+ }
176
+ });
177
+
178
+ server.close();
179
+ console.log(`\nprovisioning_onboard_mode: ${passed} passed, ${failed} failed`);
180
+ process.exit(failed ? 1 : 0);
@@ -211,6 +211,9 @@ t('no publishable test statically imports an excluded module (R89 boundary)', ()
211
211
  const offenders = []; // …and are still publishable (the actual failure)
212
212
  for (const name of fs.readdirSync(testsDir)) {
213
213
  if (!/\.(mjs|js|cjs)$/.test(name)) continue;
214
+ // Skip self: the regex specimens below are deliberate full import forms;
215
+ // this file's real imports pull scripts/gds code only, never modules/.
216
+ if (name === 'publish_manifest.mjs') continue;
214
217
  const rel = `tests/${name}`;
215
218
  const bad = new Set();
216
219
  let m; IMPORT_RE.lastIndex = 0;
@@ -225,9 +228,19 @@ t('no publishable test statically imports an excluded module (R89 boundary)', ()
225
228
  offenders.push(`${rel} imports excluded module(s) [${[...bad].sort().join(', ')}] but is still PUBLISHABLE — add it to CONTENT_EXCLUDE (task 2147)`);
226
229
  }
227
230
  }
228
- // non-vacuous: the scan must actually SEE the known host tests (a broken regex that
229
- // matched nothing would otherwise pass silently). The 7 game/art tests are the floor.
230
- assert.ok(detected.length >= 7, `expected ≥7 host-module tests detected, saw ${detected.length} — the import scan may be broken`);
231
+ // non-vacuous: prove the regex itself extracts a module key from every import
232
+ // form it claims to cover. A repo-state floor ("must see ≥7 host tests") went
233
+ // stale the moment the host-module tests moved to modules/<key>/tests/ under
234
+ // ADR 0107 — tests/ can legitimately contain zero live specimens (task 1002611).
235
+ for (const [specimen, key] of [
236
+ [`const x = require('../modules/game/db.js')`, 'game'],
237
+ [`import x from '../../modules/art-pipeline/pipeline/palette.js'`, 'art-pipeline'],
238
+ [`const y = await import('./modules/some-key/loader.js')`, 'some-key'],
239
+ ]) {
240
+ IMPORT_RE.lastIndex = 0;
241
+ const m = IMPORT_RE.exec(specimen);
242
+ assert.ok(m && (m[2] || m[4]) === key, `import scan no longer matches: ${specimen}`);
243
+ }
231
244
  assert.equal(offenders.length, 0, '\n ' + offenders.join('\n '));
232
245
  });
233
246
 
@@ -58,7 +58,7 @@ function mkDeps({
58
58
  refreshStaleImpl,
59
59
  healAttempts = new Map(),
60
60
  } = {}) {
61
- const calls = { ship: [], broadcast: [], status: [], merge: [], ancestry: [], resolve: [], refresh: [], flag: [], logs: [], errors: [] };
61
+ const calls = { ship: [], broadcast: [], status: [], merge: [], ancestry: [], resolve: [], refresh: [], flag: [], events: [], logs: [], errors: [] };
62
62
  // isSafeBranch mirrors github-push's pattern enough for the test (rejects
63
63
  // option-like / traversal refs); we use the REAL module so the contract can't drift.
64
64
  const githubPush = require('../modules/lifecycle/github-push.js');
@@ -108,23 +108,33 @@ function mkDeps({
108
108
  return !!a;
109
109
  },
110
110
  // task 1547 per-task land proof. Mirrors the real github-push.landProvenForTask
111
- // shape but routes its ONE network hop (the ancestry compare) through the mock
111
+ // shape but routes its network hops (the ancestry compares) through the mock
112
112
  // above so the sweep stays DB/network-free, and reuses the REAL prReferencesTask
113
113
  // so that pure check can't drift. publishedSha (the server-pushed tip) is the
114
- // robust proof; the matched-PR-references-this-task fallback covers a legacy /
115
- // in-flight row with no recorded tip.
114
+ // robust proof; task 1002615 adds the merged-PR merge-commit proof (out-of-band /
115
+ // squash land) and gates the no-tip reference fallback on merged:true, because
116
+ // the sweep now consults this proof even while a PR is still in flight.
116
117
  landProvenForTask: async ({ taskId, publishedSha, status }) => {
117
- if (publishedSha && /^[0-9a-f]{7,64}$/.test(String(publishedSha))) {
118
+ const hasTip = !!(publishedSha && /^[0-9a-f]{7,64}$/.test(String(publishedSha)));
119
+ if (hasTip) {
118
120
  calls.ancestry.push(publishedSha);
119
121
  const a = ancestryBySha[publishedSha];
120
122
  if (a instanceof Error) throw a;
121
- return a
122
- ? { proven: true, reason: `published tip ${publishedSha.slice(0, 8)} in main` }
123
- : { proven: false, reason: `published tip ${publishedSha.slice(0, 8)} not in main` };
123
+ if (a) return { proven: true, reason: `published tip ${publishedSha.slice(0, 8)} in main` };
124
124
  }
125
- return githubPush.prReferencesTask(status, taskId)
125
+ if (status && status.merged === true && githubPush.prReferencesTask(status, taskId)
126
+ && status.merge_sha && /^[0-9a-f]{7,64}$/.test(String(status.merge_sha))) {
127
+ calls.ancestry.push(status.merge_sha);
128
+ const m = ancestryBySha[status.merge_sha];
129
+ if (m instanceof Error) throw m;
130
+ if (m) return { proven: true, reason: `merged PR for task ${taskId} — merge in main (out-of-band/squash land)` };
131
+ }
132
+ if (hasTip) {
133
+ return { proven: false, reason: `published tip ${publishedSha.slice(0, 8)} not in main` };
134
+ }
135
+ return (status && status.merged === true && githubPush.prReferencesTask(status, taskId))
126
136
  ? { proven: true, reason: `matched PR references task ${taskId}` }
127
- : { proven: false, reason: `no published tip and matched PR does not reference task ${taskId}` };
137
+ : { proven: false, reason: `no published tip and no merged PR references task ${taskId}` };
128
138
  },
129
139
  },
130
140
  // task.shipped is emitted on the kernel seam now; the discord module's listener
@@ -133,6 +143,9 @@ function mkDeps({
133
143
  // broadcastImpl REPLACES the default push (the throwing-broadcast test relies
134
144
  // on that to prove the reconcile stands even when the listener throws).
135
145
  emit: async (event, task) => {
146
+ // task 1002606: record EVERY seam event so the strand push channel
147
+ // (task.strand-flagged) is observable alongside task.shipped.
148
+ calls.events.push({ event, payload: task });
136
149
  if (event !== 'task.shipped') return;
137
150
  if (broadcastImpl) return broadcastImpl(task);
138
151
  calls.broadcast.push(task.id);
@@ -307,6 +320,50 @@ test('1002472 merged + deploy unconfigured but tip NOT in main → NO ship (1547
307
320
  assert.equal(deps._calls.ship.length, 0);
308
321
  });
309
322
 
323
+ // ---------------------------------------------------------------------------
324
+ // task 1002615 (idea 1000613) — a land made OUT-OF-BAND must still ship.
325
+ // The 2026-07-29 reproduction: ship → confirmed → PR merged by the owner through
326
+ // the web UI → the work verified on main → the task sat at 'confirmed' for 9+ min
327
+ // (forever), builder unpaid, until a manual POST /ship.
328
+ // ---------------------------------------------------------------------------
329
+ const MERGE_SHA = 'c'.repeat(40);
330
+
331
+ test('1002615 web-UI squash merge (tip rewritten, PR merged) → ships via the merge-commit proof', async () => {
332
+ const deps = mkDeps({
333
+ rows: [{ task_id: 80, builder_id: 3, worktree_name: 'wt-squash', published_head_sha: NOT_ON_MAIN }],
334
+ statusByBranch: { 'claude/wt-squash': { ...landedPrFor(80), deploy_status: 'unconfigured', merge_sha: MERGE_SHA } },
335
+ ancestryBySha: { [NOT_ON_MAIN]: false, [MERGE_SHA]: true },
336
+ });
337
+ const r = await reconciler.runSweep(deps);
338
+ assert.equal(r.reconciled, 1, 'a squash-rewritten land must not strand unpaid');
339
+ assert.deepEqual(deps._calls.ship, [{ taskId: 80, builderId: 3 }]);
340
+ assert.deepEqual(deps._calls.broadcast, [80]);
341
+ });
342
+
343
+ test('1002615 manual git merge to main, PR closed un-merged → tip ancestry ships it (prNotLanded no longer suppresses the proof)', async () => {
344
+ const deps = mkDeps({
345
+ rows: [{ task_id: 81, builder_id: 3, worktree_name: 'wt-oob', published_head_sha: ON_MAIN,
346
+ confirmed_at: new Date().toISOString() }],
347
+ statusByBranch: { 'claude/wt-oob': { pr_state: 'closed', merged: false, merge_sha: null, deploy_status: 'pending' } },
348
+ ancestryBySha: { [ON_MAIN]: true },
349
+ });
350
+ const r = await reconciler.runSweep(deps);
351
+ assert.equal(r.reconciled, 1, 'work on main behind a dead PR must still land');
352
+ assert.deepEqual(deps._calls.ship, [{ taskId: 81, builderId: 3 }]);
353
+ });
354
+
355
+ test('1002615 the reuse guard holds: merged PR for a DIFFERENT task + tip not in main → NO ship', async () => {
356
+ const deps = mkDeps({
357
+ rows: [{ task_id: 82, builder_id: 3, worktree_name: 'wt-reused', published_head_sha: NOT_ON_MAIN,
358
+ confirmed_at: new Date().toISOString() }],
359
+ statusByBranch: { 'claude/wt-reused': { ...landedPrFor(999), deploy_status: 'unconfigured', merge_sha: MERGE_SHA } },
360
+ ancestryBySha: { [NOT_ON_MAIN]: false, [MERGE_SHA]: true },
361
+ });
362
+ const r = await reconciler.runSweep(deps);
363
+ assert.equal(r.reconciled, 0, 'the merge-commit proof must bind to THIS task');
364
+ assert.equal(deps._calls.ship.length, 0);
365
+ });
366
+
310
367
  test('getPublishStatus throws (transient) → swallowed, sweep continues to the next task', async () => {
311
368
  const deps = mkDeps({
312
369
  rows: [
@@ -906,6 +963,120 @@ test('1002461 the WARN re-arms once a credential appears and then disappears aga
906
963
  assert.equal(badAgain._calls.errors.length, 1, 'a credential LOSS after a healthy period must warn again');
907
964
  });
908
965
 
966
+ // ---------------------------------------------------------------------------
967
+ // task 1002606 (finding P-3, the DETECTION half) — a strand reaches its builder
968
+ // when it is DETECTED, not at the 30-minute stale WARN. Definitive shapes flag
969
+ // needs_rebase on the sweep that sees them AND emit task.strand-flagged (the
970
+ // push channel Discord listens on); ambiguous shapes keep the 30-min grace.
971
+ // ---------------------------------------------------------------------------
972
+
973
+ test('1002606 a hand-written conflict flags needs_rebase IMMEDIATELY (not at the 30-min WARN), with the file list', async () => {
974
+ const deps = mkDeps({
975
+ rows: [{ task_id: 500, builder_id: 6, worktree_name: 'wt-hand', title: 'x', security_sensitive: false,
976
+ confirmed_at: new Date().toISOString() }], // FRESH — no stale grace involved
977
+ mergeByBranch: { 'claude/wt-hand': { merged: false, merged_now: false, number: 500, reason: 'merge_conflict' } },
978
+ conflictResolveImpl: async () => ({ resolved: false, skipped: 'unresolvable_conflict', conflicted: ['src/bongos/db.js'] }),
979
+ });
980
+ const r = await reconciler.runSweep(deps);
981
+ assert.equal(deps._calls.flag.length, 1, 'flagged on THIS sweep, minutes after the conflict — not 30 later');
982
+ assert.equal(deps._calls.flag[0].taskId, 500);
983
+ assert.match(deps._calls.flag[0].reason, /^strand:unresolvable_conflict:src\/bongos\/db\.js/, 'the reason names the conflicted file so /builder-start says WHERE');
984
+ assert.ok(deps._calls.errors.some((m) => /STRAND task 500/.test(m) && /hand-written conflict/.test(m)), 'WARN\'d with the concrete cause');
985
+ assert.equal(r.strandsFlagged, 1);
986
+ assert.equal(r.reconciled, 0, 'a conflicted PR still never ships');
987
+ });
988
+
989
+ test('1002606 every NEW needs_rebase flag emits task.strand-flagged (the push channel)', async () => {
990
+ const deps = mkDeps({
991
+ rows: [{ task_id: 501, builder_id: 6, worktree_name: 'wt-hand2', title: 'the title', security_sensitive: true,
992
+ confirmed_at: new Date().toISOString() }],
993
+ mergeByBranch: { 'claude/wt-hand2': { merged: false, merged_now: false, number: 501, reason: 'merge_conflict' } },
994
+ conflictResolveImpl: async () => ({ resolved: false, skipped: 'unresolvable_conflict', conflicted: ['a.js'] }),
995
+ });
996
+ await reconciler.runSweep(deps);
997
+ const strandEvents = deps._calls.events.filter((e) => e.event === 'task.strand-flagged');
998
+ assert.equal(strandEvents.length, 1, 'exactly one push event per new flag');
999
+ assert.equal(strandEvents[0].payload.id, 501);
1000
+ assert.equal(strandEvents[0].payload.builder_id, 6);
1001
+ assert.equal(strandEvents[0].payload.security_sensitive, true, 'the security flag rides along so the listener can route safely');
1002
+ assert.match(strandEvents[0].payload.reason, /^strand:unresolvable_conflict/);
1003
+ });
1004
+
1005
+ test('1002606 an ALREADY-flagged strand does not re-emit (flagTaskNeedsRebase idempotence gates the event)', async () => {
1006
+ const old = new Date(Date.now() - 50 * 3_600_000).toISOString();
1007
+ const deps = mkDeps({
1008
+ rows: [{ task_id: 502, builder_id: 6, worktree_name: null, published_head_sha: null, confirmed_at: old }],
1009
+ });
1010
+ deps.db.flagTaskNeedsRebase = async () => false; // already flagged on a prior sweep
1011
+ await reconciler.runSweep(deps);
1012
+ assert.equal(deps._calls.events.filter((e) => e.event === 'task.strand-flagged').length, 0,
1013
+ 'a re-sweep of a flagged strand must not spam the channel');
1014
+ assert.ok(deps._calls.errors.some((m) => /STRAND task 502/.test(m) && /needs_rebase already set/.test(m)),
1015
+ 'the WARN line still fires for journal visibility');
1016
+ });
1017
+
1018
+ test('1002606 a PR CLOSED without merging is DEFINITIVE — flagged fresh, no 30-min grace', async () => {
1019
+ const deps = mkDeps({
1020
+ rows: [{ task_id: 503, builder_id: 8, worktree_name: 'wt-deadpr', published_head_sha: NOT_ON_MAIN,
1021
+ confirmed_at: new Date().toISOString() }], // FRESH
1022
+ statusByBranch: { 'claude/wt-deadpr': { pr_state: 'closed', merged: false, merge_sha: null, deploy_status: 'none' } },
1023
+ ancestryBySha: { [NOT_ON_MAIN]: false }, // and the work is NOT on main out-of-band
1024
+ });
1025
+ const r = await reconciler.runSweep(deps);
1026
+ assert.deepEqual(deps._calls.flag, [{ taskId: 503, reason: 'strand:pr_closed_unmerged' }],
1027
+ 'a dead PR will never land on its own — the builder is told now');
1028
+ assert.ok(deps._calls.errors.some((m) => /STRAND task 503/.test(m) && /closed without merging/.test(m)));
1029
+ assert.equal(r.strandsFlagged, 1);
1030
+ assert.equal(r.staleStrands, 0, 'not an AGED strand — this is the immediate path');
1031
+ });
1032
+
1033
+ test('1002606 the 1002615 out-of-band land still wins over the dead-PR flag (proof runs first)', async () => {
1034
+ // Same closed-unmerged PR shape, but the work IS on main — it must ship, not flag.
1035
+ const deps = mkDeps({
1036
+ rows: [{ task_id: 504, builder_id: 8, worktree_name: 'wt-oob2', published_head_sha: ON_MAIN,
1037
+ confirmed_at: new Date().toISOString() }],
1038
+ statusByBranch: { 'claude/wt-oob2': { pr_state: 'closed', merged: false, merge_sha: null, deploy_status: 'none' } },
1039
+ ancestryBySha: { [ON_MAIN]: true },
1040
+ });
1041
+ const r = await reconciler.runSweep(deps);
1042
+ assert.equal(r.reconciled, 1, 'landed work behind a dead PR ships (1002615)');
1043
+ assert.equal(deps._calls.flag.length, 0, 'a landed task is never flagged');
1044
+ });
1045
+
1046
+ test('1002606 an OPEN PR still gets the grace — fresh, not flagged (CI may be legitimately running)', async () => {
1047
+ const deps = mkDeps({
1048
+ rows: [{ task_id: 505, builder_id: 8, worktree_name: 'wt-open2', confirmed_at: new Date().toISOString() }],
1049
+ statusByBranch: { 'claude/wt-open2': PENDING },
1050
+ });
1051
+ const r = await reconciler.runSweep(deps);
1052
+ assert.equal(deps._calls.flag.length, 0, 'an in-flight open PR is not a definitive strand');
1053
+ assert.equal(r.strandsFlagged, 0);
1054
+ });
1055
+
1056
+ test('1002606 a MERGED PR mid-deploy keeps the grace (merged=true never hits the dead-PR path)', async () => {
1057
+ const deps = mkDeps({
1058
+ rows: [{ task_id: 506, builder_id: 8, worktree_name: 'wt-middeploy', published_head_sha: ON_MAIN,
1059
+ confirmed_at: new Date().toISOString() }],
1060
+ statusByBranch: { 'claude/wt-middeploy': { pr_state: 'closed', merged: true, merge_sha: 'cafe1234', deploy_status: 'pending' } },
1061
+ ancestryBySha: { [ON_MAIN]: true },
1062
+ });
1063
+ const r = await reconciler.runSweep(deps);
1064
+ assert.equal(deps._calls.flag.length, 0, 'a merge whose deploy is finishing is the NORMAL landing window');
1065
+ assert.equal(r.reconciled, 0, 'the deploy gate still governs (1002615 exception intact)');
1066
+ });
1067
+
1068
+ test('1002606 the aged stale strand now ALSO emits task.strand-flagged', async () => {
1069
+ const old = new Date(Date.now() - 50 * 3_600_000).toISOString();
1070
+ const deps = mkDeps({
1071
+ rows: [{ task_id: 507, builder_id: 9, worktree_name: null, published_head_sha: null, confirmed_at: old }],
1072
+ });
1073
+ await reconciler.runSweep(deps);
1074
+ const strandEvents = deps._calls.events.filter((e) => e.event === 'task.strand-flagged');
1075
+ assert.equal(strandEvents.length, 1);
1076
+ assert.equal(strandEvents[0].payload.id, 507);
1077
+ assert.match(strandEvents[0].payload.reason, /^strand:no_branch_no_tip/);
1078
+ });
1079
+
909
1080
  // Drain the microtask queue, then report + exit with the right code.
910
1081
  setTimeout(() => {
911
1082
  console.log(`\npublish reconciler (task 1134 + 1915) tests: ${passed} passed, ${failed} failed`);
@@ -0,0 +1,205 @@
1
+ // tests/rank_tier_single_source.mjs — the rank-tier single-source guard (BV1.R108).
2
+ //
3
+ // The canonical rank → tier map is db-kernel.RANK_TIER, DERIVED from
4
+ // LIVE_RANK_LADDER and exposed on the doorway (api.RANK_TIER). This guard makes
5
+ // the collapse hold three ways:
6
+ //
7
+ // 1. IMPORTABLE code holds the SAME OBJECT, not a copy — modules/lifecycle/db.js
8
+ // (the claim-floor brain) and the doorway getter are Object.is-identical to
9
+ // the kernel map.
10
+ // 2. Every DELIBERATELY-LOCAL copy is pinned byte-equivalent. These exist for
11
+ // documented reasons and may not be force-merged (the D1 decision,
12
+ // modules/discord/discord-channels.js): dependency-free substrate
13
+ // (permission-path-check runs in a bare git hook), source-parsed literals
14
+ // (src/bongos/db.js — gate-review.js + diagram-facts.js regex its text),
15
+ // standalone CLIs (sequence, box-connect-lib), the require-free dev-box
16
+ // module template (boxes.js), zero-dep catalog + its text-parsing auditor
17
+ // (route-rank-check), and the hall browser statics.
18
+ // 3. NO NEW parallel map can land: a repo scan flags any rank→number map or
19
+ // rank-ladder array literal outside the allowlist below, both directions —
20
+ // an unlisted literal fails, and a stale allowlist entry fails too.
21
+ //
22
+ // Run: node tests/rank_tier_single_source.mjs
23
+
24
+ import { strict as assert } from 'node:assert';
25
+ import { test } from 'node:test';
26
+ import fs from 'node:fs';
27
+ import path from 'node:path';
28
+ import { execFileSync } from 'node:child_process';
29
+ import { createRequire } from 'node:module';
30
+ import { fileURLToPath } from 'node:url';
31
+
32
+ const require = createRequire(import.meta.url);
33
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
34
+ const kernel = require(path.join(ROOT, 'src', 'bongos', 'db-kernel.js'));
35
+ const api = require(path.join(ROOT, 'src', 'module-api.js'));
36
+ const coreDb = require(path.join(ROOT, 'src', 'bongos', 'db.js'));
37
+ const lifecycleDb = require(path.join(ROOT, 'modules', 'lifecycle', 'db.js'));
38
+ const ppc = require(path.join(ROOT, 'src', 'bongos', 'permission-path-check.js'));
39
+ const rrc = require(path.join(ROOT, 'src', 'bongos', 'route-rank-check.js'));
40
+ const sequence = require(path.join(ROOT, 'scripts', 'gds', 'sequence.js'));
41
+ const boxConnect = require(path.join(ROOT, 'scripts', 'gds', 'box-connect-lib.js'));
42
+ const boxes = require(path.join(ROOT, 'modules', 'dev-box', 'boxes.js'));
43
+ const catalog = require(path.join(ROOT, 'modules', 'governance', 'catalog.js'));
44
+
45
+ const LADDER = kernel.LIVE_RANK_LADDER;
46
+ const TIER = kernel.RANK_TIER;
47
+ const RANK_KEYS = ['xenos', 'thetes', 'metic', 'archon'];
48
+
49
+ // ===========================================================================
50
+ // 1 — the canonical derivation
51
+ // ===========================================================================
52
+
53
+ test('RANK_TIER is derived from LIVE_RANK_LADDER (1-based) and frozen', () => {
54
+ assert.deepEqual(LADDER, RANK_KEYS, 'the live ladder itself changed — update this guard deliberately');
55
+ assert.ok(Object.isFrozen(TIER));
56
+ assert.deepEqual(TIER, Object.fromEntries(LADDER.map((r, i) => [r, i + 1])));
57
+ });
58
+
59
+ test('the doorway and the lifecycle brain hold the SAME object, not copies', () => {
60
+ assert.ok(Object.is(api.RANK_TIER, TIER), 'api.RANK_TIER must be the kernel map itself');
61
+ assert.ok(Object.is(api.LIVE_RANK_LADDER, LADDER));
62
+ assert.ok(Object.is(lifecycleDb.REQUIRES_RANK_TIER, TIER),
63
+ 'modules/lifecycle/db.js must derive from the doorway, never hand-copy');
64
+ });
65
+
66
+ // ===========================================================================
67
+ // 2 — every deliberately-local copy is pinned
68
+ // ===========================================================================
69
+
70
+ test('src/bongos/db.js literal (the source-parsed brain) matches the kernel map', () => {
71
+ assert.deepEqual(coreDb.REQUIRES_RANK_TIER, TIER);
72
+ // The literal must stay PARSEABLE TEXT — gate-review.js's root-of-trust region
73
+ // watch and diagram-facts.js both regex it out of the source.
74
+ const src = fs.readFileSync(path.join(ROOT, 'src', 'bongos', 'db.js'), 'utf8');
75
+ const m = /REQUIRES_RANK_TIER\s*=\s*\{([^}]*)\}/.exec(src);
76
+ assert.ok(m, 'db.js must keep an inline REQUIRES_RANK_TIER object literal');
77
+ assert.deepEqual(parsePairs(m[1]), TIER, 'the db.js literal text drifted from the kernel map');
78
+ });
79
+
80
+ test('1-based local copies match: permission-path-check, sequence CLI', () => {
81
+ assert.deepEqual(ppc.RANK_TIER, TIER, 'permission-path-check drifted — the #863 failure class');
82
+ assert.deepEqual(sequence.RANK_TIER, TIER, 'sequence.js CLI copy drifted');
83
+ });
84
+
85
+ test('0-based local copies match: box-connect-lib CLI, dev-box boxes.js', () => {
86
+ for (const [name, map] of [['box-connect-lib', boxConnect.RANK_ORDER], ['boxes', boxes.RANK_ORDER]]) {
87
+ assert.deepEqual(Object.keys(map).sort(), [...RANK_KEYS].sort(), `${name} keys drifted`);
88
+ for (const r of RANK_KEYS) {
89
+ assert.equal(map[r], TIER[r] - 1, `${name}.RANK_ORDER.${r} must be tier-1 (0-based)`);
90
+ }
91
+ }
92
+ });
93
+
94
+ test('ladder mirrors match: governance catalog, route-rank-check', () => {
95
+ assert.deepEqual([...catalog.RANK_ORDER], [...LADDER]);
96
+ assert.deepEqual([...rrc.PERMISSION_RANK_ORDER], [...LADDER]);
97
+ });
98
+
99
+ // Files that cannot require server code (browser statics, dependency-free
100
+ // module CLIs) — their literal must be EXACTLY the canonical form. The loose
101
+ // scanner below flags any rank-ish literal in these files; this exact check is
102
+ // what turns "flagged but allowlisted" into "provably not drifted".
103
+ const EXACT_LADDER_RE = /\[\s*'xenos',\s*'thetes',\s*'metic',\s*'archon'\s*\]/;
104
+ const STATIC_LADDER_FILES = [
105
+ 'modules/discord/discord-channels.js',
106
+ 'modules/memory/search.js',
107
+ 'modules/hall-ui/public/ranks.js',
108
+ 'modules/hall-ui/public/profile.js',
109
+ 'modules/hall-ui/public/watch.js',
110
+ ];
111
+ test('static ladder copies carry the exact canonical ladder', () => {
112
+ for (const rel of STATIC_LADDER_FILES) {
113
+ const src = fs.readFileSync(path.join(ROOT, rel), 'utf8');
114
+ assert.ok(EXACT_LADDER_RE.test(src), `${rel}: ladder literal missing or drifted from canonical order`);
115
+ }
116
+ });
117
+
118
+ const STATIC_MAP_FILES = ['modules/hall-ui/public/builders.js', 'modules/hall-ui/public/goals-page.js'];
119
+ test('hall static tier maps match the kernel map', () => {
120
+ for (const rel of STATIC_MAP_FILES) {
121
+ const src = fs.readFileSync(path.join(ROOT, rel), 'utf8');
122
+ const maps = extractRankMaps(src);
123
+ assert.ok(maps.length > 0, `${rel}: expected a rank tier map`);
124
+ for (const map of maps) assert.deepEqual(map, TIER, `${rel}: tier map drifted`);
125
+ }
126
+ });
127
+
128
+ // ===========================================================================
129
+ // 3 — the reintroduction scanner (both directions vs the allowlist)
130
+ // ===========================================================================
131
+
132
+ // Where a rank→number map literal is allowed to exist (each is pinned above).
133
+ const MAP_ALLOWLIST = new Set([
134
+ 'src/bongos/db.js',
135
+ 'src/bongos/permission-path-check.js',
136
+ 'scripts/gds/sequence.js',
137
+ 'scripts/gds/box-connect-lib.js',
138
+ 'modules/dev-box/boxes.js',
139
+ 'modules/hall-ui/public/builders.js',
140
+ 'modules/hall-ui/public/goals-page.js',
141
+ ]);
142
+ // Where a rank-ladder array literal is allowed to exist.
143
+ const LADDER_ALLOWLIST = new Set([
144
+ 'src/bongos/db-kernel.js', // the canonical source itself
145
+ 'modules/governance/catalog.js',
146
+ 'src/bongos/route-rank-check.js',
147
+ ...STATIC_LADDER_FILES,
148
+ ]);
149
+
150
+ // A rank→digit pair inside an object block. Hex values (RANK_COLORS) never
151
+ // match: \d+ must run straight into , or }.
152
+ const pairRe = (rank) => new RegExp(`(?:^|[{,\\s'"])${rank}['"]?\\s*:\\s*\\d+\\s*[,}]`);
153
+ // ≥3 rank keys with digit values in one object literal = a tier map — including
154
+ // the drifted 3-key shape that caused #863 (a rank silently missing).
155
+ function hasRankMap(src) {
156
+ for (const m of src.matchAll(/\{[^{}]*\}/g)) {
157
+ if (RANK_KEYS.filter((r) => pairRe(r).test(m[0])).length >= 3) return true;
158
+ }
159
+ return false;
160
+ }
161
+ function parsePairs(inner) {
162
+ const out = {};
163
+ for (const m of inner.matchAll(/([a-z]+)['"]?\s*:\s*(\d+)/g)) out[m[1]] = Number(m[2]);
164
+ return out;
165
+ }
166
+ function extractRankMaps(src) {
167
+ const out = [];
168
+ for (const m of src.matchAll(/\{[^{}]*\}/g)) {
169
+ if (RANK_KEYS.filter((r) => pairRe(r).test(m[0])).length >= 3) {
170
+ out.push(parsePairs(m[0].slice(1, -1)));
171
+ }
172
+ }
173
+ return out;
174
+ }
175
+ // ≥3 CONSECUTIVE live-rank strings in one array literal = a ladder copy.
176
+ const LOOSE_LADDER_RE = /\[\s*(?:['"](?:xenos|thetes|metic|archon)['"]\s*,?\s*){3,}\]/;
177
+
178
+ test('no rank map or ladder literal exists outside the allowlist (and none is stale)', () => {
179
+ const tracked = execFileSync('git', ['-C', ROOT, 'ls-files', 'src', 'modules', 'scripts'], { encoding: 'utf8' })
180
+ .split('\n').map((l) => l.trim())
181
+ .filter((f) => f.endsWith('.js') && !f.includes('/tests/') && !f.endsWith('.test.js'));
182
+ assert.ok(tracked.length > 50, `suspiciously few tracked files (${tracked.length}) — scanner broken?`);
183
+
184
+ const mapHits = new Set();
185
+ const ladderHits = new Set();
186
+ for (const rel of tracked) {
187
+ const src = fs.readFileSync(path.join(ROOT, rel), 'utf8');
188
+ if (hasRankMap(src)) mapHits.add(rel);
189
+ if (LOOSE_LADDER_RE.test(src)) ladderHits.add(rel);
190
+ }
191
+ for (const f of mapHits) {
192
+ assert.ok(MAP_ALLOWLIST.has(f),
193
+ `${f} declares a rank→tier map literal. Derive it from api.RANK_TIER / db-kernel.RANK_TIER instead — or, if it genuinely cannot require server code, add it here WITH a pinning assertion above.`);
194
+ }
195
+ for (const f of ladderHits) {
196
+ assert.ok(LADDER_ALLOWLIST.has(f),
197
+ `${f} declares a rank-ladder array literal. Derive it from api.LIVE_RANK_LADDER instead — or, if it genuinely cannot require server code, add it here WITH a pinning assertion above.`);
198
+ }
199
+ for (const f of MAP_ALLOWLIST) {
200
+ assert.ok(mapHits.has(f), `allowlist entry ${f} no longer contains a rank map — prune it`);
201
+ }
202
+ for (const f of LADDER_ALLOWLIST) {
203
+ assert.ok(ladderHits.has(f), `allowlist entry ${f} no longer contains a ladder literal — prune it`);
204
+ }
205
+ });