@bongos/core 1.19.1033 → 1.19.1035

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 (114) hide show
  1. package/.bongos-core.json +204 -99
  2. package/.claude/skills/builder-start/SKILL.md +1 -1
  3. package/.claude/skills/grade-recover/SKILL.md +2 -1
  4. package/.claude/skills/grader-health/SKILL.md +4 -0
  5. package/.claude/skills/merge-mode/SKILL.md +1 -1
  6. package/clients/bongos-client/README.md +1 -1
  7. package/clients/bongos-client/bongos-client.global.js +6 -0
  8. package/clients/bongos-client/index.cjs +6 -0
  9. package/clients/bongos-client/index.d.ts +8 -0
  10. package/clients/bongos-client/index.mjs +6 -0
  11. package/docs/adr/0089-modular-multi-model-adversarial-grader.md +15 -0
  12. package/docs/adr/README.md +1 -1
  13. package/docs/api/openapi.json +120 -4
  14. package/docs/api-reference.md +9 -2
  15. package/docs/architecture.md +3 -2
  16. package/docs/canonical-permissions.md +2 -2
  17. package/docs/copy-inventory.md +34 -30
  18. package/docs/copy-registry.json +69 -33
  19. package/docs/module-api-changelog.md +5 -1
  20. package/docs/recipes/local-dev.md +45 -3
  21. package/docs/recipes/packaging-the-core.md +1 -1
  22. package/modules/autonomy/cadence.js +52 -0
  23. package/modules/autonomy/db.js +19 -0
  24. package/modules/autonomy/routes/autonomy.js +28 -0
  25. package/modules/government/catalog.js +2 -2
  26. package/modules/government/protected-surfaces.json +1 -0
  27. package/modules/grading/grade-engine.js +267 -0
  28. package/modules/grading/grader-subagent.js +4 -3
  29. package/modules/grading/grader-workers/index.js +67 -11
  30. package/modules/grading/grader-workers/worker-verdict.js +32 -2
  31. package/modules/grading/grader.js +109 -88
  32. package/modules/hall-ui/public/deploy.js +35 -0
  33. package/modules/hall-ui/public/project-settings.css +7 -0
  34. package/modules/lifecycle/routes/tasks.js +19 -4
  35. package/modules/lifecycle/ship-assurance.js +17 -1
  36. package/modules/provisioning/control-plane-guard.js +14 -13
  37. package/modules/provisioning/core-upgrade.js +30 -17
  38. package/modules/provisioning/public-refusal.js +44 -0
  39. package/modules/provisioning/routes/disconnect.js +2 -1
  40. package/modules/provisioning/routes/env-manifest.js +5 -4
  41. package/modules/provisioning/routes/provisioning.js +16 -15
  42. package/modules/provisioning/routes/repo-private.js +2 -1
  43. package/package-lock.json +2 -2
  44. package/package.json +1 -1
  45. package/release-notes.json +80 -0
  46. package/scripts/gds/autobongos-run.js +10 -2
  47. package/scripts/gds/backfill-cost.js +20 -9
  48. package/scripts/gds/claim.js +2 -2
  49. package/scripts/gds/credential-presence.js +281 -0
  50. package/scripts/gds/deploy-main-audit-gate.sh +11 -4
  51. package/scripts/gds/dev-lib.js +212 -5
  52. package/scripts/gds/dev.js +102 -50
  53. package/scripts/gds/doctor.js +3 -2
  54. package/scripts/gds/grade-correlation-audit.js +280 -0
  55. package/scripts/gds/grade-replay.js +237 -0
  56. package/scripts/gds/hooks/pre-push.js +7 -3
  57. package/scripts/gds/main-audit.js +19 -10
  58. package/scripts/gds/memory-map.js +3 -1
  59. package/scripts/gds/onboarding-config.js +3 -2
  60. package/scripts/gds/plain-cards.js +4 -0
  61. package/scripts/gds/preflight.js +12 -4
  62. package/scripts/gds/provision-core-upgrade.js +155 -4
  63. package/scripts/gds/provision-repo-auth.js +15 -0
  64. package/scripts/gds/provision.js +1 -1
  65. package/scripts/gds/publish-credential-check.js +3 -0
  66. package/scripts/gds/push-main.js +14 -3
  67. package/scripts/gds/push-path-brief.js +3 -2
  68. package/scripts/gds/run-routine.js +18 -5
  69. package/scripts/gds/run-unit-tests.js +14 -3
  70. package/scripts/gds/session-cost.js +12 -3
  71. package/scripts/gds/ship-deploy-target.js +11 -6
  72. package/scripts/gds/ship-grade-steps.js +33 -0
  73. package/scripts/gds/ship-land.js +157 -11
  74. package/scripts/gds/ship-merge.js +3 -2
  75. package/scripts/gds/ship-preflight-steps.js +30 -8
  76. package/scripts/gds/ship.js +1 -1
  77. package/scripts/gds/upgrade.js +3 -3
  78. package/scripts/hall-preview/README.md +20 -3
  79. package/scripts/hall-preview/live-identity.js +1 -1
  80. package/scripts/hall-preview/refresh-fixtures.js +71 -11
  81. package/scripts/hall-preview/scrub-identity.js +296 -0
  82. package/src/bongos/core-update.js +23 -1
  83. package/src/bongos/routes/core-update.js +27 -2
  84. package/src/bongos/routes/security.js +3 -3
  85. package/src/build-info.js +4 -2
  86. package/src/module-api.js +1 -1
  87. package/tests/adr_0089_scope.mjs +70 -0
  88. package/tests/autobongos_cadence.mjs +74 -0
  89. package/tests/consumer_layout_boot.mjs +363 -31
  90. package/tests/core_upgrade_door.mjs +40 -6
  91. package/tests/core_upgrade_runner.mjs +240 -4
  92. package/tests/credential_presence.mjs +153 -0
  93. package/tests/deploy_audit_gate.mjs +5 -2
  94. package/tests/deploy_page_projects.mjs +63 -2
  95. package/tests/dev_lib.mjs +2 -1
  96. package/tests/dev_windows.mjs +324 -0
  97. package/tests/env_knobs_neutral_prefix.mjs +203 -0
  98. package/tests/fixtures/grade-replay/recorded-panel-grades.json +27 -0
  99. package/tests/government_protected_surfaces.mjs +10 -0
  100. package/tests/grade_correlation_audit.mjs +159 -0
  101. package/tests/grade_engine.mjs +249 -0
  102. package/tests/grade_engine_replay.mjs +99 -0
  103. package/tests/grader_prompt_window_bound.mjs +132 -0
  104. package/tests/grader_unbacked_score.mjs +194 -0
  105. package/tests/hall_preview_scrub.mjs +214 -0
  106. package/tests/integration_parked_ratchet.mjs +24 -1
  107. package/tests/provisioning_public_refusal.mjs +195 -0
  108. package/tests/publish_status_branch.mjs +128 -0
  109. package/tests/routine_observability_api.mjs +195 -0
  110. package/tests/ship_assurance.mjs +86 -3
  111. package/tests/ship_land_red_pr.mjs +236 -0
  112. package/tests/ship_preflight.mjs +67 -1
  113. package/tests/start_env_guard.mjs +16 -0
  114. package/tests/upgrade.mjs +20 -1
@@ -5,6 +5,11 @@
5
5
  // (config / modules / .claude) sitting at a DIFFERENT root — and prove the two-root
6
6
  // module loader (task 2133) discovers modules from BOTH roots.
7
7
  //
8
+ // PLUS THE DATABASE HALF (task 1002863, audit H2 — "PROVE PUBLISH"): the same installed
9
+ // package must also install from its OWN shipped lockfile, migrate a brand-new EMPTY
10
+ // database, and serve a route that reads it. Before this, a core that installed and
11
+ // answered /healthz but could not migrate would have passed every gate there was.
12
+ //
8
13
  // WHY THIS EXISTS. Every existing gate runs the SOURCE tree with all deps present:
9
14
  // - tests/platform_boot.mjs imports src/platform-server.js straight from the repo;
10
15
  // - tests/fitness_two_root.mjs diverges resolveInstanceRoot() to a temp dir but
@@ -29,40 +34,205 @@
29
34
  // 6. fitness — the packaged fitness.js discoveredModuleKeys() spans both roots.
30
35
  // 7. gen-file-map — reads the INSTANCE root (lists the consumer's .claude/skills).
31
36
  // 8. gen-repo-map — maps CORE code (scripts/gds/fitness.js) from node_modules.
37
+ // 9. npm ci — the lockfile SHIPPED inside the package (package-core prunes it to the
38
+ // core's own dependency closure) installs with `npm ci` on its own: in step
39
+ // with the package.json it ships beside, and every integrity hash verifies.
40
+ // It is how the self-host image installs an unpacked core (its Dockerfile
41
+ // runs `npm ci --omit=dev`), and the R88 break — integrity hashes blanked by
42
+ // redaction — is exactly what `npm ci` refuses.
43
+ // 10. migrate — `npm run migrate` from the consumer root (the script `bongos init`
44
+ // scaffolds and `bongos upgrade` runs) applies, to a brand-new EMPTY
45
+ // database, every core migration and every migration of the modules a
46
+ // default install enables — and none of a module it leaves off.
47
+ // 11. db route — the installed server, booted against that database, serves a route that
48
+ // reads it: a sentinel row written into the fresh database comes back out of
49
+ // GET /api/bongos/public/versions, so the answer provably came from THIS
50
+ // database and not from a cache, a fallback or some other database.
51
+ // 12. modules — a SECOND empty database, migrated with every optional module the package
52
+ // ships switched on, applies every one of their migrations too. A default
53
+ // install never runs those files, so a defect in one is invisible to check 10
54
+ // — the gap the task-1004030 foreign-key break hid in.
55
+ //
56
+ // WHERE IT RUNS. This is an INTEGRATION harness (a package build, two installs, live boots
57
+ // and a Postgres), so it sits in the INTEGRATION set of scripts/gds/run-unit-tests.js — the
58
+ // per-PR unit gate never pays for it — and the `integration` job of
59
+ // .github/workflows/integration.yml runs it on every push to main via
60
+ // `run-unit-tests.js --integration`. That job's Postgres service supplies checks 10-12, but
61
+ // NOT the job's own database: the lane has already migrated it and the suite mutates it, so
62
+ // "migrates cleanly" asked there would be answered against a schema that already exists.
63
+ // Each database leg CREATEs its own uniquely named empty database on the same server and
64
+ // DROPs it afterwards, which also keeps the proof honest when it runs anywhere else.
32
65
  //
33
- // This is an INTEGRATION harness (a package build + `npm install` + a live boot), so
34
- // it is EXCLUDED from the DB-free unit gate (scripts/gds/run-unit-tests.js INTEGRATION
35
- // set) and driven by .github/workflows/consumer-layout-boot.yml. It needs NO Postgres
36
- // (/healthz, the loader, fitness, and the generators are all DB-free). Re-runnable:
66
+ // WITHOUT A POSTGRES. Under CI (CI=true, which GitHub Actions sets) the database leg is
67
+ // MANDATORY and an unreachable server FAILS the run: a leg that may skip in CI is a leg
68
+ // that can rot there unseen. Anywhere else — a laptop, `package-core.js --self-verify-boot`
69
+ // — checks 10-12 print SKIP lines (the runner surfaces them in its roll-up) and checks 1-9
70
+ // still run. Re-runnable:
37
71
  // node tests/consumer_layout_boot.mjs # builds a fresh core .tgz
38
72
  // CORE_TGZ=/abs/path/bongos-core-x.y.z.tgz node tests/consumer_layout_boot.mjs # reuse a prebuilt one
73
+ // The database leg uses the caller's own libpq settings (PGHOST / PGPORT / PGUSER /
74
+ // PGPASSWORD, else the local socket); the role needs CREATEDB.
75
+ //
76
+ // THE CHILD PROCESSES GET A DELIBERATE ENVIRONMENT, never simply the caller's:
77
+ // - The PACKAGER gets no database settings. package-core's fail-closed leak gate treats
78
+ // every secret-named env value as a credential that must not appear in the artifact,
79
+ // and the integration lane's throwaway password is the literal word `postgres` — which
80
+ // is in hundreds of core files. So in that lane the packager refused to build and this
81
+ // harness died at check 1 with "cannot continue without a core tarball" (the failure
82
+ // task 1004034 parked it for). The packager needs no database; this artifact is thrown
83
+ // away, never published; and the publishing lane carries no database env at all, so
84
+ // its gate is exactly as strict as before.
85
+ // - The INSTANCE processes (migrate, and the server booted against the fresh database)
86
+ // get an ALLOWLIST — PATH, HOME, locale, and the connection to their own database —
87
+ // the way a provisioned instance's service unit does. Inheriting the lane's environment
88
+ // would point the server at the lane's own database (pool.js prefers DATABASE_URL to
89
+ // PGDATABASE), opt the migration into the source repo's instance seeds, and let any
90
+ // module flag set on the runner change which schema a "default install" produces.
39
91
 
40
92
  import assert from 'node:assert/strict';
41
93
  import http from 'node:http';
42
94
  import net from 'node:net';
43
95
  import { spawn, spawnSync } from 'node:child_process';
44
- import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, realpathSync } from 'node:fs';
96
+ import { randomBytes } from 'node:crypto';
97
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, existsSync, realpathSync, readdirSync, statSync } from 'node:fs';
98
+ import { createRequire } from 'node:module';
45
99
  import { tmpdir } from 'node:os';
46
- import { join, dirname } from 'node:path';
100
+ import { join, dirname, sep } from 'node:path';
47
101
  import { fileURLToPath } from 'node:url';
48
102
 
49
103
  const REPO_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..');
50
104
  const NPM_BIN = process.platform === 'win32' ? 'npm.cmd' : 'npm';
51
105
  const CORE_VERSION_RANGE = '^1.0.0'; // consumer-probe's coreVersion — satisfied by any 1.x core.
106
+ // The consumer's package.json is scaffolded by the SAME function `bongos init` uses for a
107
+ // greenfield instance, so its `migrate` script is the real one rather than a copy that could drift.
108
+ const { buildInstancePackageJson } = createRequire(import.meta.url)(join(REPO_ROOT, 'scripts', 'gds', 'init.js'));
52
109
 
53
110
  let passed = 0;
54
111
  let failed = 0;
112
+ let skipped = 0;
55
113
  function check(name, fn) {
56
114
  try { fn(); passed++; console.log(` PASS ${name}`); }
57
115
  catch (e) { failed++; console.log(` FAIL ${name}\n ${e && e.message ? e.message : e}`); }
58
116
  }
117
+ // A check that could not run HERE. Printed as a SKIP line, which run-unit-tests.js counts and
118
+ // names in its roll-up — a skipped check must never read as a passing one.
119
+ function skip(name, reason) {
120
+ skipped++;
121
+ console.log(` SKIP ${name}\n ${reason}`);
122
+ }
123
+
124
+ // ---------------------------------------------------------------------------
125
+ // Child-process environments (see "THE CHILD PROCESSES GET A DELIBERATE ENVIRONMENT" above).
126
+ // ---------------------------------------------------------------------------
127
+
128
+ // Every variable that points a process at a database or authenticates it to one: libpq's PG*
129
+ // family, plus the DSN pool.js prefers over all of them.
130
+ const isDbEnvKey = (k) => k === 'DATABASE_URL' || /^PG[A-Z_]*$/.test(k);
131
+
132
+ // The caller's environment minus every database setting — for the packager, and for the boot
133
+ // that is meant to prove the package comes up with NO database at all.
134
+ function withoutDbEnv(env = process.env) {
135
+ const out = {};
136
+ for (const [k, v] of Object.entries(env)) if (!isDbEnvKey(k)) out[k] = v;
137
+ return out;
138
+ }
139
+
140
+ // What an INSTANCE process inherits: the basics a shell, npm and node need, and nothing that
141
+ // could choose its database, its seeds or its modules for it. The Windows names are there so a
142
+ // builder on Windows can run this too; they are simply absent elsewhere.
143
+ const INSTANCE_ENV_PASSTHROUGH = [
144
+ 'PATH', 'HOME', 'USER', 'LOGNAME', 'SHELL', 'TMPDIR', 'TMP', 'TEMP', 'LANG', 'LC_ALL', 'NODE_ENV',
145
+ 'SystemRoot', 'ComSpec', 'PATHEXT', 'USERPROFILE', 'APPDATA', 'LOCALAPPDATA',
146
+ 'PGSSLMODE', 'PGPASSFILE',
147
+ ];
148
+ function instanceEnv(conn, database, extra = {}) {
149
+ const env = {};
150
+ for (const k of INSTANCE_ENV_PASSTHROUGH) if (process.env[k] !== undefined) env[k] = process.env[k];
151
+ return { ...env, ...conn, PGDATABASE: database, ...extra };
152
+ }
153
+
154
+ // ---------------------------------------------------------------------------
155
+ // The database leg's plumbing: psql with the CALLER's connection settings. -X skips ~/.psqlrc,
156
+ // so a developer's own display settings cannot change the output parsed here.
157
+ // ---------------------------------------------------------------------------
158
+ function psql(sql, { database = 'postgres' } = {}) {
159
+ const r = spawnSync('psql', ['-X', '-q', '-v', 'ON_ERROR_STOP=1', '-tA', '-F', '|', '-d', database, '-c', sql], {
160
+ encoding: 'utf8',
161
+ env: { ...process.env, PGCONNECT_TIMEOUT: process.env.PGCONNECT_TIMEOUT || '5' },
162
+ });
163
+ if (r.error) return { ok: false, out: '', err: r.error.message };
164
+ return { ok: r.status === 0, out: (r.stdout || '').trim(), err: (r.stderr || '').trim() };
165
+ }
166
+
167
+ // Can this process reach a Postgres, and how exactly did it connect? The answer is handed to
168
+ // the instance processes verbatim, so migrate.sh (psql) and the booted server (node-postgres)
169
+ // reach the same server as the same role. That has to be spelled out: with no PGHOST, psql
170
+ // uses its own compiled-in socket directory while pool.js assumes /var/run/postgresql, and
171
+ // on a Homebrew install those differ.
172
+ function probePostgres() {
173
+ const r = psql("SELECT current_user, current_setting('port'), coalesce(host(inet_server_addr()), ''), current_setting('unix_socket_directories')");
174
+ if (!r.ok) return { ok: false, reason: r.err || 'psql exited non-zero' };
175
+ const [user, port, serverAddr, socketDirs] = r.out.split('|');
176
+ const host = process.env.PGHOST || serverAddr || String(socketDirs || '').split(',')[0].trim();
177
+ const conn = { PGUSER: user, PGPORT: process.env.PGPORT || port };
178
+ if (host) conn.PGHOST = host;
179
+ if (process.env.PGPASSWORD) conn.PGPASSWORD = process.env.PGPASSWORD;
180
+ return { ok: true, conn };
181
+ }
182
+
183
+ // Every database this run creates, so the finally block can drop them whatever failed.
184
+ const createdDatabases = [];
185
+ function createEmptyDatabase(tag) {
186
+ const name = `consumer_boot_${tag}_${process.pid}_${randomBytes(3).toString('hex')}`;
187
+ const r = psql(`CREATE DATABASE ${name}`);
188
+ if (!r.ok) throw new Error(`CREATE DATABASE ${name} failed: ${r.err}`);
189
+ createdDatabases.push(name);
190
+ return name;
191
+ }
192
+ function dropCreatedDatabases() {
193
+ for (const name of createdDatabases) {
194
+ // FORCE (Postgres 13+) ends any connection a crashed server left behind; the plain form
195
+ // is the fallback for an older server.
196
+ if (!psql(`DROP DATABASE IF EXISTS ${name} WITH (FORCE)`).ok) psql(`DROP DATABASE IF EXISTS ${name}`);
197
+ }
198
+ }
199
+
200
+ // The migration files the INSTALLED package carries, as schema_migrations records them (by
201
+ // filename stem). Walked the way migrate.sh's globs walk them — `*.sql` and `modules/*/` never
202
+ // match a dot-file — the core root first, then the instance root, each module key once.
203
+ const globVisible = (name) => !name.startsWith('.');
204
+ function sqlStems(dir) {
205
+ if (!existsSync(dir)) return [];
206
+ return readdirSync(dir).filter((f) => globVisible(f) && f.endsWith('.sql')).map((f) => f.slice(0, -'.sql'.length)).sort();
207
+ }
208
+ function migrationInventory(coreRoot, instanceRoot) {
209
+ const byModule = {};
210
+ const seen = new Set();
211
+ for (const root of [coreRoot, instanceRoot]) {
212
+ const modulesDir = join(root, 'modules');
213
+ if (!existsSync(modulesDir)) continue;
214
+ for (const key of readdirSync(modulesDir).filter(globVisible).sort()) {
215
+ if (seen.has(key) || !statSync(join(modulesDir, key)).isDirectory()) continue;
216
+ seen.add(key);
217
+ const stems = sqlStems(join(modulesDir, key, 'migrations'));
218
+ if (stems.length) byModule[key] = stems;
219
+ }
220
+ }
221
+ return { core: sqlStems(join(coreRoot, 'migrations')), byModule };
222
+ }
223
+ function appliedStems(database) {
224
+ const r = psql('SELECT version FROM schema_migrations', { database });
225
+ if (!r.ok) throw new Error(`could not read schema_migrations from ${database}: ${r.err}`);
226
+ return new Set(r.out.split('\n').map((s) => s.trim()).filter(Boolean));
227
+ }
59
228
 
60
229
  // Run a node snippet in `cwd` and parse its stdout as JSON. Used to introspect the
61
230
  // INSTALLED core (its loader / fitness / generators) exactly as a consumer process
62
231
  // would resolve them — absolute paths are injected so the snippet never depends on
63
- // the snippet file's own location (mirrors tests/fitness_two_root.mjs).
64
- function runNodeJSON(snippet, cwd) {
65
- const r = spawnSync(process.execPath, ['-e', snippet], { cwd, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
232
+ // the snippet file's own location (mirrors tests/fitness_two_root.mjs). `env` defaults to
233
+ // the caller's; the database leg passes an instance environment instead.
234
+ function runNodeJSON(snippet, cwd, env = process.env) {
235
+ const r = spawnSync(process.execPath, ['-e', snippet], { cwd, env, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
66
236
  if (r.status !== 0) {
67
237
  throw new Error(`node snippet exited ${r.status}\n--- stdout ---\n${r.stdout}\n--- stderr ---\n${r.stderr}`);
68
238
  }
@@ -81,9 +251,10 @@ function freePort() {
81
251
  });
82
252
  }
83
253
 
84
- function getHealthz(port) {
254
+ // GET a path on a local listener → { status, body }, or null when nothing answered.
255
+ function httpGet(port, path, timeout = 2000) {
85
256
  return new Promise((resolve) => {
86
- const req = http.get({ host: '127.0.0.1', port, path: '/healthz', timeout: 2000 }, (res) => {
257
+ const req = http.get({ host: '127.0.0.1', port, path, timeout }, (res) => {
87
258
  let body = '';
88
259
  res.on('data', (c) => (body += c));
89
260
  res.on('end', () => resolve({ status: res.statusCode, body }));
@@ -96,30 +267,51 @@ function getHealthz(port) {
96
267
  async function waitForHealthz(port, timeoutMs = 20000) {
97
268
  const deadline = Date.now() + timeoutMs;
98
269
  while (Date.now() < deadline) {
99
- const res = await getHealthz(port);
270
+ const res = await httpGet(port, '/healthz');
100
271
  if (res) return res;
101
272
  await new Promise((r) => setTimeout(r, 300));
102
273
  }
103
274
  return null;
104
275
  }
105
276
 
277
+ // Start the INSTALLED server from the consumer root on a free port and wait for /healthz.
278
+ // Returns { child, port, health, stderr() } — the caller owns killing the child.
279
+ async function bootInstalledServer(coreDir, consumer, env) {
280
+ const port = await freePort();
281
+ const child = spawn(process.execPath, [join(coreDir, 'src', 'platform-server.js')], {
282
+ cwd: consumer,
283
+ env: { ...env, PORT: String(port), HOST: '127.0.0.1' },
284
+ stdio: ['ignore', 'pipe', 'pipe'],
285
+ });
286
+ let err = '';
287
+ child.stderr.on('data', (c) => { err += c; });
288
+ child.stdout.on('data', () => {});
289
+ const health = await waitForHealthz(port);
290
+ return { child, port, health, stderr: () => err.slice(-2000) };
291
+ }
292
+ function stopServer(child) {
293
+ if (child && child.exitCode === null) { try { child.kill('SIGTERM'); } catch { /* already gone */ } }
294
+ }
295
+
106
296
  async function main() {
107
- console.log('=== consumer-layout boot proof (task 2142): install @bongos/core, boot it, assert two-root module load ===');
297
+ console.log('=== consumer-layout boot proof (tasks 2142 + 1002863): install @bongos/core, boot it, assert two-root module load, migrate an empty database, serve a database-backed route ===');
108
298
 
109
299
  const workRoot = mkdtempSync(join(tmpdir(), 'consumer-layout-'));
110
300
  const consumer = join(workRoot, 'consumer');
111
301
  let child = null;
302
+ let dbChild = null;
112
303
 
113
304
  try {
114
305
  // --- 1. package -----------------------------------------------------------
115
306
  // Build the installable core .tgz from HEAD (the committed tree — so on a PR the
116
307
  // job packages exactly the code under review). Reuse a prebuilt one via CORE_TGZ.
308
+ // No database settings reach the packager (header: THE CHILD PROCESSES ...).
117
309
  let tgz = process.env.CORE_TGZ || '';
118
310
  check('package: scripts/gds/package-core.js builds an installable .tgz', () => {
119
311
  if (tgz) { assert.ok(existsSync(tgz), `CORE_TGZ does not exist: ${tgz}`); return; }
120
312
  const outDir = join(workRoot, 'dist');
121
313
  const r = spawnSync(process.execPath, [join(REPO_ROOT, 'scripts/gds/package-core.js'), '--out-dir', outDir],
122
- { cwd: REPO_ROOT, encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
314
+ { cwd: REPO_ROOT, env: withoutDbEnv(), encoding: 'utf8', maxBuffer: 256 * 1024 * 1024 });
123
315
  assert.equal(r.status, 0, `package-core exited ${r.status}\n${r.stderr}`);
124
316
  const pinLine = r.stdout.trim().split('\n').filter(Boolean).pop();
125
317
  const pin = JSON.parse(pinLine);
@@ -135,13 +327,14 @@ async function main() {
135
327
  // crux: it can only be discovered via resolveInstanceRoot(), so finding it proves
136
328
  // the second root is scanned.
137
329
  mkdirSync(consumer, { recursive: true });
138
- writeFileSync(join(consumer, 'package.json'), `${JSON.stringify({
330
+ // Scaffolded by init.js's own builder, so the consumer carries the exact start/migrate
331
+ // scripts a greenfield instance gets; `file:` pins the tarball the way a vendored core is.
332
+ writeFileSync(join(consumer, 'package.json'), `${JSON.stringify(buildInstancePackageJson({
139
333
  name: 'consumer-layout-probe',
140
334
  version: '0.0.0',
141
335
  private: true,
142
- description: 'Ephemeral @bongos/core consumer used by the R89 two-root CI proof (task 2142).',
143
- dependencies: { '@bongos/core': `file:${tgz}` },
144
- }, null, 2)}\n`);
336
+ description: 'Ephemeral @bongos/core consumer used by the R89 two-root CI proof (task 2142) and its database leg (task 1002863).',
337
+ }, { coreDep: `file:${tgz}` }), null, 2)}\n`);
145
338
  writeFileSync(join(consumer, 'bongos.config.json'), `${JSON.stringify({ instanceRoot: '.' }, null, 2)}\n`);
146
339
  // A tiny instance CLAUDE.md (the charter heading keeps fitness's budget check happy
147
340
  // if anything reads it) — makes the tree a realistic instance, not load-bearing here.
@@ -198,21 +391,18 @@ async function main() {
198
391
  });
199
392
 
200
393
  // --- 4. boot + /healthz ---------------------------------------------------
201
- const port = await freePort();
202
- child = spawn(process.execPath, [join(coreDir, 'src', 'platform-server.js')], {
203
- cwd: consumer,
204
- env: { ...process.env, PORT: String(port), HOST: '127.0.0.1' },
205
- stdio: ['ignore', 'pipe', 'pipe'],
206
- });
207
- let childErr = '';
208
- child.stderr.on('data', (c) => { childErr += c; });
209
- child.stdout.on('data', () => {});
210
- const health = await waitForHealthz(port);
394
+ // With NO database settings at all, so this asks the same question everywhere: does
395
+ // the package come up and answer /healthz before any database exists (the self-host
396
+ // image's HEALTHCHECK relies on exactly that)? Check 11 boots it against a real one.
397
+ const booted = await bootInstalledServer(coreDir, consumer, withoutDbEnv());
398
+ child = booted.child;
399
+ const health = booted.health;
211
400
  check('boot: node node_modules/@bongos/core/src/platform-server.js serves /healthz 200', () => {
212
- assert.ok(health, `/healthz never came up on port ${port}\n--- server stderr ---\n${childErr.slice(-2000)}`);
401
+ assert.ok(health, `/healthz never came up on port ${booted.port}\n--- server stderr ---\n${booted.stderr()}`);
213
402
  assert.equal(health.status, 200, `/healthz status ${health.status}`);
214
403
  assert.equal(health.body.trim(), 'ok', `/healthz body was "${health.body}"`);
215
404
  });
405
+ stopServer(child); // nothing below needs this one; check 11 boots its own
216
406
 
217
407
  // --- 5. two-root module load (the task-2133 done-when) --------------------
218
408
  // The real default scan (DEFAULT_ROOTS) PLUS an explicit single-root scan that
@@ -290,13 +480,155 @@ async function main() {
290
480
  assert.ok(model.files > 50, `core scan found too few files (${model.files}) — core root not resolved to the package?`);
291
481
  assert.equal(model.hasFitness, true, 'gen-repo-map did not map the core file scripts/gds/fitness.js');
292
482
  });
483
+
484
+ // --- 9. npm ci from the SHIPPED, pruned lockfile --------------------------
485
+ // Unpack the tarball exactly as published and install it from its own lockfile — the
486
+ // end-to-end form of tests/pruned_lockfile_integrity.mjs, the network-free guard for the
487
+ // same R88 break. The resolved paths must land in THIS install, so a dependency found by
488
+ // walking up into some other node_modules cannot pass for one the lockfile provided.
489
+ check('npm ci: the pruned lockfile shipped inside the package installs its runtime dependencies', () => {
490
+ const unpacked = join(workRoot, 'unpacked');
491
+ mkdirSync(unpacked, { recursive: true });
492
+ const x = spawnSync('tar', ['-xzf', tgz, '-C', unpacked], { encoding: 'utf8' });
493
+ assert.equal(x.status, 0, `could not unpack ${tgz}: ${x.error ? x.error.message : x.stderr}`);
494
+ const pkgRoot = join(unpacked, 'package');
495
+ assert.ok(existsSync(join(pkgRoot, 'package-lock.json')), 'the package ships no package-lock.json to install from');
496
+ const r = spawnSync(NPM_BIN, ['ci', '--omit=dev', '--no-audit', '--no-fund', '--loglevel=error'],
497
+ { cwd: pkgRoot, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
498
+ assert.equal(r.status, 0, `npm ci from the shipped lockfile exited ${r.status}\n${tail(r.stderr)}`);
499
+ const deps = runNodeJSON(`
500
+ const pkg = require('./package.json');
501
+ process.stdout.write(JSON.stringify(Object.keys(pkg.dependencies || {}).map((d) => [d, require.resolve(d)])));
502
+ `, pkgRoot);
503
+ assert.ok(deps.length > 0, 'the shipped package.json declares no dependencies, so nothing was proven installable');
504
+ const ownModules = join(realpathSync(pkgRoot), 'node_modules') + sep;
505
+ const elsewhere = deps.filter(([, p]) => !p.startsWith(ownModules)).map(([d, p]) => `${d} -> ${p}`);
506
+ assertNone(elsewhere, `resolved from OUTSIDE ${ownModules}`);
507
+ });
508
+
509
+ // --- 10-12. the database leg ----------------------------------------------
510
+ const DB_CHECKS = [
511
+ 'migrate: npm run migrate applies every core + default-module migration to an EMPTY database, and nothing of a default-off module',
512
+ 'db route: the installed server, booted against that database, serves GET /api/bongos/public/versions from it',
513
+ 'modules: with every optional module switched on, an EMPTY database takes all of their migrations too',
514
+ ];
515
+ const pg = probePostgres();
516
+ if (!pg.ok) {
517
+ const why = `no usable Postgres (${pg.reason.split('\n')[0]}). Point PGHOST / PGPORT / PGUSER / PGPASSWORD at a server whose role may CREATE DATABASE.`;
518
+ if (process.env.CI) check('db: a Postgres is reachable for the database leg (MANDATORY under CI)', () => assert.fail(why));
519
+ else for (const name of DB_CHECKS) skip(name, why);
520
+ } else {
521
+ const inventory = migrationInventory(coreDir, consumer);
522
+ // Which modules the INSTALLED core enables, asked from the environment the migration
523
+ // gets — so the expectation and the run can never be answering different questions.
524
+ const moduleState = (env) => runNodeJSON(`
525
+ const mods = require(${A('src/modules.js')});
526
+ const ic = require(${A('src/instance-config.js')});
527
+ process.stdout.write(JSON.stringify({ enabled: mods.enabledModules(), envPrefix: ic.envPrefix() }));
528
+ `, consumer, env);
529
+ const migrate = (env) => spawnSync(NPM_BIN, ['run', 'migrate'],
530
+ { cwd: consumer, env, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
531
+ const migrateFailure = (r) => `npm run migrate exited ${r.status}${r.error ? ` (${r.error.message})` : ''}\n`
532
+ + `--- stdout (tail) ---\n${tail(r.stdout)}\n--- stderr (tail) ---\n${tail(r.stderr)}`;
533
+
534
+ // 10. A DEFAULT install — the modules the package switches on by itself, nothing more.
535
+ let defaultsDb = null;
536
+ let defaultsMigrated = false;
537
+ check(DB_CHECKS[0], () => {
538
+ assert.ok(inventory.core.length > 0, 'the installed package ships no core migrations at all');
539
+ defaultsDb = createEmptyDatabase('defaults');
540
+ const env = instanceEnv(pg.conn, defaultsDb);
541
+ const on = new Set(moduleState(env).enabled);
542
+ const r = migrate(env);
543
+ assert.equal(r.status, 0, migrateFailure(r));
544
+ const applied = appliedStems(defaultsDb);
545
+ assertNone(inventory.core.filter((s) => !applied.has(s)), 'core migration(s) the package ships were not recorded');
546
+ const missing = [];
547
+ const leaked = [];
548
+ for (const [key, stems] of Object.entries(inventory.byModule)) {
549
+ if (on.has(key)) missing.push(...stems.filter((s) => !applied.has(s)).map((s) => `${key}/${s}`));
550
+ else leaked.push(...stems.filter((s) => applied.has(s)).map((s) => `${key}/${s}`));
551
+ }
552
+ assertNone(missing, 'migration(s) of a default-on module were not recorded');
553
+ assertNone(leaked, 'migration(s) of a module a default install leaves OFF were applied, so this was not a default install');
554
+ defaultsMigrated = true;
555
+ });
556
+
557
+ // 11. The server, booted against that database, answers from it. The sentinel row is
558
+ // written straight into the fresh database, so finding it in the HTTP response proves
559
+ // the route really read this database through the installed code.
560
+ if (defaultsMigrated) {
561
+ const sentinelId = `CONSUMER-PROBE-${randomBytes(4).toString('hex').toUpperCase()}`;
562
+ const ins = psql(`INSERT INTO versions (id, name, status) VALUES ('${sentinelId}', 'consumer_layout_boot sentinel (task 1002863)', 'shipped')`, { database: defaultsDb });
563
+ const boot = ins.ok ? await bootInstalledServer(coreDir, consumer, instanceEnv(pg.conn, defaultsDb)) : null;
564
+ dbChild = boot ? boot.child : null;
565
+ const route = boot && boot.health ? await httpGet(boot.port, '/api/bongos/public/versions', 5000) : null;
566
+ check(DB_CHECKS[1], () => {
567
+ assert.ok(ins.ok, `could not write the sentinel version row into ${defaultsDb}: ${ins.err}`);
568
+ assert.ok(boot.health, `/healthz never came up against the migrated database\n--- server stderr ---\n${boot.stderr()}`);
569
+ assert.equal(boot.health.status, 200, `/healthz status ${boot.health.status}`);
570
+ assert.ok(route, 'GET /api/bongos/public/versions got no answer at all');
571
+ assert.equal(route.status, 200,
572
+ `GET /api/bongos/public/versions -> ${route.status}: ${route.body.slice(0, 500)}\n--- server stderr ---\n${boot.stderr()}`);
573
+ const ids = (JSON.parse(route.body).versions || []).map((v) => v.id);
574
+ assert.ok(ids.includes(sentinelId),
575
+ `the sentinel ${sentinelId} written into ${defaultsDb} is not in the response (${JSON.stringify(ids)}), so the server did not read the database that was migrated`);
576
+ });
577
+ stopServer(dbChild);
578
+ } else {
579
+ check(DB_CHECKS[1], () => assert.fail('not attempted: the default-install migration (check 10) failed, so there is no migrated database to boot against'));
580
+ }
581
+
582
+ // 12. Every optional module on. Migration only: booting with all of them on is a
583
+ // different question, since a module may need an npm dependency the core does not
584
+ // carry (discord needs discord.js, which an instance that enables it installs itself).
585
+ const probeEnv = instanceEnv(pg.conn, 'postgres');
586
+ const defaults = moduleState(probeEnv);
587
+ const optional = Object.keys(inventory.byModule).filter((k) => !defaults.enabled.includes(k));
588
+ if (optional.length === 0) {
589
+ skip(DB_CHECKS[2], 'every module that ships migrations is already on by default, so check 10 covered them all');
590
+ } else {
591
+ check(DB_CHECKS[2], () => {
592
+ const modulesDb = createEmptyDatabase('modules');
593
+ // <PREFIX>_MODULE_<KEY> is the documented per-module switch (src/modules.js). The
594
+ // spelling is checked below against the installed core itself, so a changed rule or
595
+ // a refused module fails here loudly instead of quietly testing a default install.
596
+ const flags = Object.fromEntries(optional.map((k) => [`${defaults.envPrefix}_MODULE_${k.toUpperCase().replace(/-/g, '_')}`, '1']));
597
+ const env = { ...instanceEnv(pg.conn, modulesDb), ...flags };
598
+ const nowOn = new Set(moduleState(env).enabled);
599
+ assertNone(optional.filter((k) => !nowOn.has(k)), `module(s) did not switch on with ${Object.keys(flags).join(', ')}`);
600
+ const r = migrate(env);
601
+ assert.equal(r.status, 0, `${migrateFailure(r)}\n(modules switched on: ${optional.join(', ')})`);
602
+ const applied = appliedStems(modulesDb);
603
+ assertNone(inventory.core.filter((s) => !applied.has(s)), 'core migration(s) the package ships were not recorded');
604
+ const missing = [];
605
+ for (const [key, stems] of Object.entries(inventory.byModule)) {
606
+ missing.push(...stems.filter((s) => !applied.has(s)).map((s) => `${key}/${s}`));
607
+ }
608
+ assertNone(missing, 'module migration(s) were not recorded with every module switched on');
609
+ });
610
+ }
611
+ }
293
612
  } finally {
294
- if (child && child.exitCode === null) { try { child.kill('SIGTERM'); } catch { /* already gone */ } }
613
+ stopServer(child);
614
+ stopServer(dbChild);
615
+ dropCreatedDatabases();
295
616
  try { rmSync(workRoot, { recursive: true, force: true }); } catch { /* best-effort */ }
296
617
  }
297
618
 
298
- console.log(`\nconsumer_layout_boot: ${passed} passed, ${failed} failed`);
619
+ console.log(`\nconsumer_layout_boot: ${passed} passed, ${failed} failed${skipped ? `, ${skipped} skipped` : ''}`);
299
620
  process.exit(failed ? 1 : 0);
300
621
  }
301
622
 
623
+ // The last lines of a child's output — enough to read a failure, bounded so a 300-migration
624
+ // log cannot bury it.
625
+ function tail(text, lines = 40) {
626
+ return String(text || '').trim().split('\n').slice(-lines).join('\n');
627
+ }
628
+
629
+ // Fail with the offending entries NAMED. assert.deepEqual(list, [], msg) would print only msg.
630
+ function assertNone(list, what) {
631
+ if (list.length) assert.fail(`${list.length} ${what}: ${list.slice(0, 25).join(', ')}${list.length > 25 ? ', ...' : ''}`);
632
+ }
633
+
302
634
  main().catch((e) => { console.error(e); process.exit(1); });
@@ -18,6 +18,7 @@ import { createRequire } from 'node:module';
18
18
  import { readFileSync, readdirSync } from 'node:fs';
19
19
  import { fileURLToPath } from 'node:url';
20
20
  import { dirname, join } from 'node:path';
21
+ import { parseRepoNote } from '../modules/provisioning/core-upgrade.js';
21
22
 
22
23
  const require = createRequire(import.meta.url);
23
24
  const P = require('../modules/provisioning/provisioning.js');
@@ -537,12 +538,11 @@ await t('a guard refusal reaches the owner as ITS OWN sentence, not "internal er
537
538
  // was shown as a 500 "internal error" while the real sentence sat in the journal —
538
539
  // which is how the preview refusal stayed invisible until it was pressed live.
539
540
  // Elsewhere in this file we already assert that sentence is well-formed; nothing
540
- // asserted it ARRIVED.
541
+ // asserted it ARRIVED. The refusal is the guard's REAL one: a look-alike carrying the
542
+ // same three fields is deliberately not forwarded any more (task 1004126).
541
543
  const h = validatorHarness();
542
- const refusal = Object.assign(new Error('blocked'), {
543
- code: 'control_plane_protected', status: 409,
544
- publicMessage: 'That action cannot be aimed at the machine the platform itself runs on.',
545
- });
544
+ const selfRow = { query: async () => ({ rows: [{ hosting_shape: 'control-plane' }] }) };
545
+ const refusal = await G.assertIntentAllowed(selfRow, 7, 'teardown').then(() => null, (e) => e);
546
546
  h.provisioning.enqueueIntent = async () => { throw refusal; };
547
547
  await h.handler({ builder: { id: 42 }, params: { id: '7' }, body: { to: '1.19.926' } }, h.res);
548
548
  const { body } = h.read();
@@ -553,7 +553,7 @@ await t('a guard refusal reaches the owner as ITS OWN sentence, not "internal er
553
553
 
554
554
  await t('an UNEXPECTED throw is still opaque — this is not a blanket error passthrough', async () => {
555
555
  // Forwarding every error would leak internals. Only an error that declared itself
556
- // public (code + status + publicMessage) is forwarded.
556
+ // public (a PublicRefusal, modules/provisioning/public-refusal.js) is forwarded.
557
557
  const h = validatorHarness();
558
558
  h.provisioning.enqueueIntent = async () => { throw new Error('connection terminated: relation "secrets" does not exist'); };
559
559
  await h.handler({ builder: { id: 42 }, params: { id: '7' }, body: { to: '1.19.926' } }, h.res);
@@ -732,6 +732,40 @@ await t('a SUCCESSFUL move is attributable, not merely finished', () => {
732
732
  assert.equal(p.last_move.error, null);
733
733
  });
734
734
 
735
+ await t('a move that LANDED but could not update GitHub carries a warning, never an error (task 1004065)', () => {
736
+ // The runner resolves such a move `done` — the site really runs the new core — with
737
+ // a note in last_error. Reading that note as an error would paint a working upgrade
738
+ // as a failed one; dropping it is the silence this task exists to end.
739
+ const done = { action: 'core-upgrade', state: 'done', target_version: '1.19.949', resolved_at: new Date().toISOString(),
740
+ last_error: 'repo_not_updated:no_access: The hub had no permission to write to the project\'s GitHub repository.' };
741
+ const p = CU.previewFromSnapshot(ACTIVE, { ...SNAP, served_version: '1.19.949', recommended: null }, null, done);
742
+ assert.equal(p.last_move.state, 'done');
743
+ assert.equal(p.last_move.error, null, 'not an error — the move worked');
744
+ assert.deepEqual(p.last_move.warning, { code: 'no_access', text: 'The hub had no permission to write to the project\'s GitHub repository.' });
745
+ });
746
+
747
+ await t('an ERROR move keeps its error and carries no warning', () => {
748
+ const failed = { action: 'core-upgrade', state: 'error', target_version: '1.19.949', resolved_at: new Date().toISOString(), last_error: 'working tree not clean' };
749
+ const p = CU.previewFromSnapshot(ACTIVE, SNAP, null, failed);
750
+ assert.match(p.last_move.error, /working tree not clean/);
751
+ assert.equal(p.last_move.warning, null);
752
+ });
753
+
754
+ await t('a clean move has neither', () => {
755
+ const done = { action: 'core-upgrade', state: 'done', target_version: '1.19.949', resolved_at: new Date().toISOString(), last_error: null };
756
+ const p = CU.previewFromSnapshot(ACTIVE, { ...SNAP, served_version: '1.19.949', recommended: null }, null, done);
757
+ assert.equal(p.last_move.error, null);
758
+ assert.equal(p.last_move.warning, null);
759
+ });
760
+
761
+ await t('only the runner\'s own note shape is read as a repo warning — anything else is not invented into one', () => {
762
+ assert.equal(parseRepoNote(null), null);
763
+ assert.equal(parseRepoNote('working tree not clean'), null);
764
+ assert.equal(parseRepoNote('repo_not_updated:Bad Code: x'), null, 'a code is a fixed lower-case word');
765
+ assert.deepEqual(parseRepoNote('repo_not_updated:behind: GitHub has changes.'), { code: 'behind', text: 'GitHub has changes.' });
766
+ assert.ok(parseRepoNote(`repo_not_updated:failed: ${'x'.repeat(5000)}`).text.length <= 600, 'bounded — it reaches a DOM');
767
+ });
768
+
735
769
  await t('a long refusal is truncated — it reaches a DOM', () => {
736
770
  // The dirty-tree refusal carries a whole `git status`; test4's ran to dozens of lines.
737
771
  const huge = { action: 'core-upgrade', state: 'error', target_version: '1.19.949', resolved_at: new Date().toISOString(), last_error: 'x'.repeat(5000) };