@bongos/core 1.19.789 → 1.19.791

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 (64) hide show
  1. package/.bongos-core.json +143 -53
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +6 -0
  4. package/clients/bongos-client/index.cjs +6 -0
  5. package/clients/bongos-client/index.d.ts +6 -0
  6. package/clients/bongos-client/index.mjs +6 -0
  7. package/docs/adr/0293-the-owners-deploy-door-is-an-intent-the-control-plane-drains.md +124 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +129 -1
  10. package/docs/api-reference.md +5 -2
  11. package/docs/copy-inventory.md +89 -72
  12. package/docs/copy-registry.json +272 -98
  13. package/docs/module-api-changelog.md +4 -0
  14. package/docs/recipes/core-release-pipeline.md +12 -0
  15. package/migrations/core_241_core_upgrades_actor.sql +65 -0
  16. package/migrations/core_242_core_version_snapshot.sql +54 -0
  17. package/modules/dev-box/app/src/vendor/bongos-client.cjs +6 -0
  18. package/modules/government/catalog.js +8 -0
  19. package/modules/government/migrations/government_014_core_pin_move.sql +46 -0
  20. package/modules/hall-ui/public/deploy.html +64 -0
  21. package/modules/hall-ui/public/deploy.js +188 -0
  22. package/modules/hall-ui/public/hall-kit.js +7 -65
  23. package/modules/hall-ui/public/hall-render.js +1 -1
  24. package/modules/hall-ui/public/hall-visual.js +85 -0
  25. package/modules/hall-ui/public/index.html +1 -0
  26. package/modules/hall-ui/public/profile.html +1 -0
  27. package/modules/hall-ui/public/profile.js +2 -2
  28. package/modules/hall-ui/public/shell.js +4 -0
  29. package/modules/hall-ui/public/studio.css +1 -1
  30. package/modules/hall-ui/public/studio.html +1 -0
  31. package/modules/hall-ui/public/studio.js +4 -3
  32. package/modules/hall-ui/public/style.css +0 -16
  33. package/modules/hall-ui/public/task.html +1 -0
  34. package/modules/hall-ui/public/task.js +10 -13
  35. package/modules/provisioning/control-plane-guard.js +102 -0
  36. package/modules/provisioning/core-upgrade.js +289 -0
  37. package/modules/provisioning/migrations/provisioning_018_core_upgrade_intent.sql +98 -0
  38. package/modules/provisioning/module.json +1 -1
  39. package/modules/provisioning/provisioning.js +8 -8
  40. package/modules/provisioning/routes/core-upgrade.js +95 -0
  41. package/package-lock.json +2 -2
  42. package/package.json +1 -1
  43. package/scripts/gds/accessibility-replay.js +195 -0
  44. package/scripts/gds/enroll-control-plane.js +101 -0
  45. package/scripts/gds/provision-core-upgrade.js +311 -0
  46. package/scripts/gds/provision.js +4 -2
  47. package/scripts/gds/upgrade.js +53 -14
  48. package/src/bongos/auth.js +19 -0
  49. package/src/bongos/serve-internal.js +21 -0
  50. package/src/module-api.js +1 -1
  51. package/tests/accessibility_replay.mjs +201 -0
  52. package/tests/auth_resolve_failure.mjs +4 -2
  53. package/tests/core_upgrade_door.mjs +408 -0
  54. package/tests/core_upgrade_runner.mjs +377 -0
  55. package/tests/fixtures/accessibility-replay.json +93 -0
  56. package/tests/hall_nav.mjs +4 -1
  57. package/tests/hall_page_gate_map.mjs +7 -0
  58. package/tests/hall_studio_world.mjs +19 -11
  59. package/tests/provisioning_disconnect.mjs +30 -5
  60. package/tests/provisioning_paid_shape_gate.mjs +14 -4
  61. package/tests/provisioning_restart.mjs +25 -9
  62. package/tests/provisioning_settings_apply.mjs +11 -2
  63. package/tests/provisioning_teardown_intent.mjs +7 -0
  64. package/tests/task_visuals.mjs +91 -27
@@ -0,0 +1,377 @@
1
+ // tests/core_upgrade_runner.mjs — the control-plane half of the owner's deploy door
2
+ // (task 1003159, ADR 0293).
3
+ //
4
+ // core_upgrade_door.mjs covers the WEB tier: what may be asked for, by whom, and that
5
+ // asking never escalates. This file covers what happens when the runner drains that
6
+ // request — the half that actually restarts the live service and changes what code the
7
+ // platform runs.
8
+ //
9
+ // WHAT IS ACTUALLY AT RISK HERE, in the order it would hurt:
10
+ //
11
+ // 1. THE SELF-ROW GUARD AT THE CHOKE POINT. Every enqueue funnels through
12
+ // enqueueIntent. Until task 1003159 the guard existed and nothing called it for any
13
+ // action but core-upgrade, so a teardown aimed at the control-plane row would have
14
+ // been queued and drained. A test that only checks the pure predicate would have
15
+ // passed the whole time.
16
+ // 2. A TARGET THAT IS NOT PUBLISHED YET. The release lane mints a git tag BEFORE the
17
+ // npm publish completes — 8–13 minutes on 2026-09-16 (learnings 1000263, 1000266).
18
+ // A door that trusted the request would routinely queue a version that does not
19
+ // exist and die inside npm.
20
+ // 3. A REFUSAL THAT MARKS A HEALTHY INSTANCE BROKEN. The drain loop's catch flips the
21
+ // instance to 'error'. Every expected refusal here must RETURN, never throw, or the
22
+ // one act the owner was finally given would leave their project labelled broken.
23
+ // 4. THE READ-BACK LYING. The box's own /version is the only honest answer (ADR 0293
24
+ // D6), and a body this parser cannot read must report "unconfirmed", never a
25
+ // mismatch — a mismatch rolls back a deploy that worked.
26
+ //
27
+ // Run: node tests/core_upgrade_runner.mjs
28
+
29
+ import { strict as assert } from 'node:assert';
30
+ import { createRequire } from 'node:module';
31
+
32
+ const require = createRequire(import.meta.url);
33
+ const R = require('../scripts/gds/provision-core-upgrade.js');
34
+ const U = require('../scripts/gds/upgrade.js');
35
+ const P = require('../modules/provisioning/provisioning.js');
36
+ const G = require('../modules/provisioning/control-plane-guard.js');
37
+
38
+ let passed = 0;
39
+ let failed = 0;
40
+ const t = async (name, fn) => {
41
+ try { await fn(); passed += 1; console.log(` PASS ${name}`); }
42
+ catch (e) { failed += 1; console.log(` FAIL ${name}\n ${e.message}`); }
43
+ };
44
+
45
+ // A runner dependency bag whose every side effect is observable and whose clock is
46
+ // instant. `apply:true` because the interesting behaviour is the applied path.
47
+ function makeDeps({ shape = 'control-plane', served = '1.19.760', available = ['1.19.758', '1.19.760', '1.19.762'], installed = '1.19.760', queries = [] } = {}) {
48
+ const ran = [];
49
+ const exec = (cmd) => {
50
+ ran.push(cmd);
51
+ if (/\/version/.test(cmd)) return { ok: true, stdout: served == null ? '' : JSON.stringify({ coreVersion: served }) };
52
+ return { ok: true, stdout: '' };
53
+ };
54
+ return {
55
+ ran,
56
+ queries,
57
+ db: { query: async (sql, params) => { queries.push({ sql, params }); return { rows: [] }; } },
58
+ provisioning: { recordEvent: async () => {}, clearErrorNote: async () => {} },
59
+ exec,
60
+ log: () => {},
61
+ apply: true,
62
+ privileged: false,
63
+ sleep: async () => {},
64
+ readInstalledCoreVersion: () => installed,
65
+ updateChannel: {
66
+ DEFAULT_CHANNEL: 'patch',
67
+ normalizeChannel: (c) => c,
68
+ channelAllows: () => true,
69
+ resolveChannelTarget: ({ available: a }) => (a.length ? a[a.length - 1] : null),
70
+ listAvailableVersions: () => ({ ok: true, versions: available }),
71
+ },
72
+ _shape: shape,
73
+ };
74
+ }
75
+
76
+ const inst = (over = {}) => ({ id: 7, slug: 'cloudbongos', port: 3002, status: 'active', hosting_shape: 'control-plane', owner_builder_id: 1, ...over });
77
+ const intent = (over = {}) => ({ id: 99, action: 'core-upgrade', target_version: '1.19.762', requested_by: 'api:archon:42', ...over });
78
+
79
+ console.log('\nthe choke point — what may be aimed at the platform itself:');
80
+
81
+ await t('enqueueIntent REFUSES a teardown aimed at the control-plane row', async () => {
82
+ const db = { query: async (sql) => {
83
+ if (/SELECT hosting_shape/.test(sql)) return { rows: [{ hosting_shape: 'control-plane' }] };
84
+ throw new Error('the INSERT must never be reached');
85
+ } };
86
+ await assert.rejects(() => P.enqueueIntent(db, 7, 'teardown', 'api:archon:42'),
87
+ (e) => e.code === 'control_plane_protected');
88
+ });
89
+
90
+ await t('and every other action in the vocabulary is refused there too, except the two named', async () => {
91
+ for (const action of P.INTENT_ACTIONS) {
92
+ const db = { query: async (sql) => {
93
+ if (/SELECT hosting_shape/.test(sql)) return { rows: [{ hosting_shape: 'control-plane' }] };
94
+ return { rows: [{ id: 1, action, state: 'pending' }] };
95
+ } };
96
+ const allowed = G.CONTROL_PLANE_ACTIONS.includes(action);
97
+ if (allowed) {
98
+ const r = await P.enqueueIntent(db, 7, action, 'api:archon:42', action === 'core-upgrade' ? '1.19.762' : null);
99
+ assert.equal(r.created, true, `${action} is allowed on the self row`);
100
+ } else {
101
+ await assert.rejects(() => P.enqueueIntent(db, 7, action, 'api:archon:42'),
102
+ (e) => e.code === 'control_plane_protected', `${action} must be refused on the self row`);
103
+ }
104
+ }
105
+ });
106
+
107
+ await t('an ORDINARY instance is untouched by the guard — it is not a general gate', async () => {
108
+ const seen = [];
109
+ const db = { query: async (sql, params) => {
110
+ seen.push(sql);
111
+ if (/SELECT hosting_shape/.test(sql)) return { rows: [{ hosting_shape: 'co-tenant' }] };
112
+ return { rows: [{ id: 1, action: params[1], state: 'pending' }] };
113
+ } };
114
+ const r = await P.enqueueIntent(db, 7, 'teardown', 'api:self');
115
+ assert.equal(r.created, true);
116
+ assert.ok(seen.some((s) => /INSERT INTO provisioning_intents/.test(s)));
117
+ });
118
+
119
+ await t('a row that does NOT exist is left to the FK, not answered with the wrong refusal', async () => {
120
+ const db = { query: async (sql) => {
121
+ if (/SELECT hosting_shape/.test(sql)) return { rows: [] };
122
+ const e = new Error('violates foreign key constraint'); e.code = '23503'; throw e;
123
+ } };
124
+ await assert.rejects(() => P.enqueueIntent(db, 404, 'teardown', 'api:self'),
125
+ (e) => e.code === '23503', 'the FK error survives — a missing row is not "control plane protected"');
126
+ });
127
+
128
+ console.log('\nwhat the runner refuses, and the shape of the refusal:');
129
+
130
+ await t('a non-active instance is a clean NO-OP — it must not burn the retry budget or look broken', async () => {
131
+ for (const status of ['requested', 'provisioning', 'tearing_down', 'torn_down', 'error']) {
132
+ const r = await R.coreUpgradeInstance(inst({ status }), makeDeps(), intent());
133
+ assert.equal(r.ok, true, status);
134
+ assert.equal(r.noop, true, status);
135
+ }
136
+ });
137
+
138
+ await t('a dedicated droplet is refused — the runner has no exec path to it, so queueing one would never land', async () => {
139
+ const r = await R.coreUpgradeInstance(inst({ hosting_shape: 'dedicated' }), makeDeps(), intent());
140
+ assert.equal(r.ok, false);
141
+ assert.match(r.error, /not automated/);
142
+ });
143
+
144
+ await t('an intent with no target is refused rather than guessed at', async () => {
145
+ const r = await R.coreUpgradeInstance(inst(), makeDeps(), intent({ target_version: null }));
146
+ assert.equal(r.ok, false);
147
+ assert.match(r.error, /refusing to guess/);
148
+ });
149
+
150
+ await t('EVERY refusal returns ok:false — none of them throws, because the drain loop marks a throw as a broken instance', async () => {
151
+ const cases = [
152
+ [inst({ hosting_shape: 'dedicated' }), makeDeps(), intent()],
153
+ [inst(), makeDeps(), intent({ target_version: null })],
154
+ [inst(), makeDeps({ available: [] }), intent()],
155
+ [inst(), makeDeps({ available: ['1.19.758'] }), intent({ target_version: '1.19.999' })],
156
+ ];
157
+ for (const [i, d, n] of cases) {
158
+ const r = await R.coreUpgradeInstance(i, d, n);
159
+ assert.equal(typeof r.ok, 'boolean');
160
+ }
161
+ });
162
+
163
+ await t('A SLUG THAT IS NOT A SLUG NEVER REACHES THE SHELL, whatever the DB holds', async () => {
164
+ // The slug is interpolated into the `bongos upgrade` command. isValidSlug is
165
+ // enforced on the CREATE route, but the control-plane self row is written by an
166
+ // operator script and any future write path is one more place to remember — so the
167
+ // check that matters is the one at the point of use.
168
+ for (const bad of ['cloud bongos', 'a;rm -rf /', '$(id)', '`id`', 'UPPER', 'x', '--lead', 'a--b', '']) {
169
+ const d = makeDeps();
170
+ const r = await R.coreUpgradeInstance(inst({ slug: bad }), d, intent());
171
+ assert.equal(r.ok, false, `'${bad}' must be refused`);
172
+ assert.match(r.error, /is not a valid slug/, `'${bad}'`);
173
+ assert.equal(d.ran.length, 0, `'${bad}' must not reach an executor`);
174
+ }
175
+ });
176
+
177
+ await t('and an ordinary slug still runs', async () => {
178
+ const d = makeDeps({ served: '1.19.762' });
179
+ const r = await R.coreUpgradeInstance(inst({ slug: 'cloud-bongos-2' }), d, intent());
180
+ assert.equal(r.ok, true);
181
+ assert.ok(d.ran.some((c) => c.includes('--service cloud-bongos-2')));
182
+ });
183
+
184
+ await t('the guard error carries a status and a sentence, so a route CAN answer with it', async () => {
185
+ // No route maps it today — reaching it means a route forgot its own check, which
186
+ // deserves a 500 and a stack. The fields exist so that stays a choice rather than a
187
+ // rewrite, and so the wording is settled once.
188
+ const selfRow = { query: async () => ({ rows: [{ hosting_shape: 'control-plane' }] }) };
189
+ await assert.rejects(() => G.assertIntentAllowed(selfRow, 1, 'teardown'), (err) => {
190
+ assert.equal(err.code, 'control_plane_protected');
191
+ assert.equal(err.status, 409);
192
+ assert.match(err.publicMessage, /cannot be aimed at the machine the platform itself runs on/);
193
+ assert.ok(!/hosting_shape|provisioning_instances/.test(err.publicMessage),
194
+ 'and the public sentence names no column or table');
195
+ return true;
196
+ });
197
+ });
198
+
199
+ console.log('\nthe target has to be REAL (ADR 0293 D3):');
200
+
201
+ await t('a version that is not published yet is refused, and the message says the tag precedes the package', () => {
202
+ const f = R.targetFault('1.19.999', ['1.19.758', '1.19.760'], '1.19.758', 'patch', () => true);
203
+ assert.match(f, /not published yet/);
204
+ assert.match(f, /tag appears several minutes before/);
205
+ });
206
+
207
+ await t('a registry that could not be read is refused DIFFERENTLY from a version that does not exist', () => {
208
+ const empty = R.targetFault('1.19.760', [], '1.19.758', 'patch', () => true);
209
+ assert.match(empty, /could not read the published core versions/);
210
+ assert.ok(!/not published yet/.test(empty), 'the two states must not be collapsed into one sentence');
211
+ });
212
+
213
+ await t('a published version the CHANNEL forbids is refused, and says so rather than claiming it does not exist', () => {
214
+ const f = R.targetFault('2.0.0', ['1.19.758', '2.0.0'], '1.19.758', 'stable', () => false);
215
+ assert.match(f, /outside this project's 'stable' update channel/);
216
+ });
217
+
218
+ await t('a published, permitted version is admitted', () => {
219
+ assert.equal(R.targetFault('1.19.760', ['1.19.758', '1.19.760'], '1.19.758', 'patch', () => true), null);
220
+ });
221
+
222
+ await t('the runner refuses an unpublished target BEFORE it runs anything', async () => {
223
+ const d = makeDeps({ available: ['1.19.758', '1.19.760'] });
224
+ const r = await R.coreUpgradeInstance(inst(), d, intent({ target_version: '1.19.999' }));
225
+ assert.equal(r.ok, false);
226
+ assert.equal(d.ran.length, 0, 'nothing was executed on the box');
227
+ });
228
+
229
+ console.log('\nthe move itself:');
230
+
231
+ await t('drives upgrade.js with the registry, the health gate, the version read-back and the committed pin', async () => {
232
+ // served === the target: the state after a move that worked, which is what the flags
233
+ // are being asserted for.
234
+ const d = makeDeps({ served: '1.19.762' });
235
+ const r = await R.coreUpgradeInstance(inst(), d, intent());
236
+ assert.equal(r.ok, true);
237
+ const cmd = d.ran.find((c) => /upgrade\.js/.test(c));
238
+ assert.ok(cmd, 'it ran the upgrade CLI');
239
+ for (const flag of ['--registry', '--to 1.19.762', '--service cloudbongos', '--health-url', '--version-url', '--commit-pin']) {
240
+ assert.ok(cmd.includes(flag), `carries ${flag}`);
241
+ }
242
+ // --commit-pin is the one that cannot be dropped: without it the pin lives only in
243
+ // the working tree and the next pull-deploy resets it away. That is the silent
244
+ // thirteen-release revert upgrade.js's own header records.
245
+ assert.ok(cmd.includes('--commit-pin'));
246
+ });
247
+
248
+ await t('the health URL and the version URL are LOOPBACK — the apex is behind basic_auth and a CDN (ADR 0293 D6)', async () => {
249
+ const d = makeDeps();
250
+ await R.coreUpgradeInstance(inst(), d, intent());
251
+ const cmd = d.ran.find((c) => /upgrade\.js/.test(c));
252
+ assert.ok(cmd.includes('--health-url http://127.0.0.1:3002/healthz'));
253
+ assert.ok(cmd.includes('--version-url http://127.0.0.1:3002/version'));
254
+ });
255
+
256
+ await t('the ACTOR rides through to the ledger — a privileged act with no attributed row is the thing ADR 0293 D5 refuses', async () => {
257
+ const d = makeDeps();
258
+ await R.coreUpgradeInstance(inst(), d, intent());
259
+ const cmd = d.ran.find((c) => /upgrade\.js/.test(c));
260
+ assert.ok(cmd.includes('--requested-by 42'), 'the builder id is carried, not the decorated string');
261
+ assert.ok(cmd.includes('--upgrade-source owner-control'));
262
+ });
263
+
264
+ await t('a CLI-shaped requester (no builder behind it) carries NEITHER flag rather than a half-attributed row', async () => {
265
+ const d = makeDeps();
266
+ await R.coreUpgradeInstance(inst(), d, intent({ requested_by: 'runner' }));
267
+ const cmd = d.ran.find((c) => /upgrade\.js/.test(c));
268
+ assert.ok(!cmd.includes('--requested-by'));
269
+ // The DB constraint core_upgrades_door_needs_actor_chk refuses source='owner-control'
270
+ // with no actor, so emitting one without the other would fail at the insert.
271
+ assert.ok(!cmd.includes('--upgrade-source'));
272
+ });
273
+
274
+ await t('asking for the version already installed changes nothing', async () => {
275
+ const d = makeDeps({ installed: '1.19.762' });
276
+ const r = await R.coreUpgradeInstance(inst(), d, intent({ target_version: '1.19.762' }));
277
+ assert.equal(r.ok, true);
278
+ assert.equal(r.noop, true);
279
+ assert.equal(d.ran.length, 0);
280
+ });
281
+
282
+ console.log('\nreading the box back (ADR 0293 D6):');
283
+
284
+ await t('the served version is read from 127.0.0.1/version, and a match confirms the move', async () => {
285
+ const d = makeDeps({ served: '1.19.762' });
286
+ const r = await R.coreUpgradeInstance(inst(), d, intent());
287
+ assert.equal(r.ok, true);
288
+ assert.equal(r.served, '1.19.762');
289
+ assert.equal(r.confirmed, true);
290
+ assert.ok(d.ran.some((c) => c.includes('http://127.0.0.1:3002/version')));
291
+ });
292
+
293
+ await t('a box serving something ELSE is reported as a move that did not take', async () => {
294
+ const d = makeDeps({ served: '1.19.758' });
295
+ const r = await R.coreUpgradeInstance(inst(), d, intent());
296
+ assert.equal(r.ok, false);
297
+ assert.match(r.error, /serving core 1\.19\.758, not the requested 1\.19\.762/);
298
+ });
299
+
300
+ await t('a box that will not answer is UNCONFIRMED, not failed — an unfamiliar reply must never roll back a good deploy', async () => {
301
+ const d = makeDeps({ served: null });
302
+ const r = await R.coreUpgradeInstance(inst(), d, intent());
303
+ assert.equal(r.ok, true, 'silence is not a failure verdict');
304
+ assert.equal(r.served, null);
305
+ assert.equal(r.confirmed, false);
306
+ });
307
+
308
+ await t('parseServedVersion reads the shapes a /version endpoint actually answers with', () => {
309
+ assert.equal(R.parseServedVersion(JSON.stringify({ coreVersion: '1.19.762' })), '1.19.762');
310
+ assert.equal(R.parseServedVersion(JSON.stringify({ core_version: '1.19.762' })), '1.19.762');
311
+ assert.equal(R.parseServedVersion(JSON.stringify({ version: '1.19.762' })), '1.19.762');
312
+ // An OLDER core is exactly what this reads, so a shape this parser has never seen is
313
+ // the expected case, not the exotic one.
314
+ assert.equal(R.parseServedVersion('cloudbongos 1.19.762 (ok)'), '1.19.762');
315
+ assert.equal(R.parseServedVersion(''), null);
316
+ assert.equal(R.parseServedVersion(null), null);
317
+ assert.equal(R.parseServedVersion('{"coreVersion":'), null, 'malformed JSON with no number in it is null, never a throw');
318
+ });
319
+
320
+ console.log('\nthe ledger carries who asked (ADR 0293 D5):');
321
+
322
+ await t('no actor → the original four columns, so this still works against a DB without core_241', () => {
323
+ const s = U.ledgerInsert({ fromVersion: '1.19.758', toVersion: '1.19.762' });
324
+ assert.deepEqual(s.cols, ['from_version', 'to_version', 'source_commit', 'note']);
325
+ assert.ok(!s.sql.includes('requested_by_builder_id'));
326
+ assert.equal(s.vals.length, 4);
327
+ });
328
+
329
+ await t('an actor → the two attribution columns join the INSERT, in lockstep with the values', () => {
330
+ const s = U.ledgerInsert({ fromVersion: '1.19.758', toVersion: '1.19.762', requestedByBuilderId: 42, source: 'owner-control' });
331
+ assert.deepEqual(s.cols.slice(-2), ['requested_by_builder_id', 'source']);
332
+ assert.equal(s.cols.length, s.vals.length, 'a column list longer than its values is how a bound-parameter INSERT goes wrong');
333
+ assert.equal(s.vals[4], '42');
334
+ assert.equal(s.vals[5], 'owner-control');
335
+ assert.match(s.sql, /\$6\)$/, 'the placeholders grow with the columns');
336
+ });
337
+
338
+ console.log('\nthe snapshot the deploy control reads:');
339
+
340
+ await t('the snapshot records the served version, the registry list and what the channel would take', async () => {
341
+ const queries = [];
342
+ const d = makeDeps({ served: '1.19.760', available: ['1.19.758', '1.19.760', '1.19.762'], queries });
343
+ await R.snapshotCoreVersions(inst(), d);
344
+ const w = queries.find((q) => /core_version_snapshot/.test(q.sql));
345
+ assert.ok(w, 'it wrote the snapshot');
346
+ assert.equal(w.params[1], '1.19.760', 'the served version');
347
+ assert.deepEqual(JSON.parse(w.params[2]), ['1.19.758', '1.19.760', '1.19.762']);
348
+ assert.equal(w.params[3], '1.19.762', 'and the version the channel would actually take');
349
+ });
350
+
351
+ await t('a box that does not answer still writes a snapshot — a NULL reading is a real state, not a missing row', async () => {
352
+ const queries = [];
353
+ const d = makeDeps({ served: null, queries });
354
+ await R.snapshotCoreVersions(inst(), d);
355
+ const w = queries.find((q) => /core_version_snapshot/.test(q.sql));
356
+ assert.ok(w);
357
+ assert.equal(w.params[1], null);
358
+ assert.equal(w.params[3], null, 'and nothing is recommended against a version we could not read');
359
+ });
360
+
361
+ await t('a shape the runner cannot reach is skipped entirely — no snapshot, no claim about it', async () => {
362
+ const queries = [];
363
+ const r = await R.snapshotCoreVersions(inst({ hosting_shape: 'dedicated' }), makeDeps({ queries }));
364
+ assert.equal(r.skipped, true);
365
+ assert.equal(queries.length, 0);
366
+ });
367
+
368
+ await t('the snapshot NEVER fails a tick — a registry hiccup leaves the old reading and its visibly older timestamp', async () => {
369
+ const d = makeDeps();
370
+ d.updateChannel.listAvailableVersions = () => { throw new Error('npm exploded'); };
371
+ const r = await R.snapshotCoreVersions(inst(), d);
372
+ assert.equal(r.ok, true);
373
+ assert.equal(r.skipped, true);
374
+ });
375
+
376
+ console.log(`\ncore_upgrade_runner: ${passed} passed, ${failed} failed`);
377
+ if (failed) process.exit(1);
@@ -0,0 +1,93 @@
1
+ {
2
+ "_comment": "The fixed beginner-prompt set for the accessibility replay regression (task 1002517, clauses 5 and 8 of criterion accessibility-fluency-profile). EIGHT prompts a newcomer to this instance actually asks; each one is the documented trigger of a real surface, so the click-paths named in the non-technical replies are paths that EXIST (Settings -> API keys, Settings -> Session, Settings -> Interaction, the hall task list, the hall blockers page, the hall leaderboard, the hall Deploy page). Prompt 'make-it-live' is the owner's own words, verbatim, 2026-08-22 (task 1003159). Each prompt carries TWO answer runs: the same question answered in the register each profile asks for. The runs are the CONTROLLED variable and the prompt set is fixed - that is what makes this a replay rather than a fixture parade. Nothing here is tuned to a target score: the replies are written in each register and the harness measures what falls out, so a detector that goes blind moves these numbers.",
3
+ "floor": 6.0,
4
+ "_floor_comment": "6.0 is not a new number. It is bandFor()'s 'workable' boundary in modules/sessions/accessibility.js - the point the scorer itself calls the difference between prose a newcomer can work with and prose that is merely dense. accessibility_replay.mjs asserts the two stay tied, so retuning the bands cannot silently move the floor.",
5
+ "prompts": [
6
+ { "id": "what-to-work-on", "prompt": "What can I work on?" },
7
+ { "id": "make-it-live", "prompt": "I don't think I can make anything live." },
8
+ { "id": "art-key", "prompt": "My image generation stopped working." },
9
+ { "id": "session-expired", "prompt": "It says my session expired." },
10
+ { "id": "how-you-talk", "prompt": "Can you stop using so much jargon with me?" },
11
+ { "id": "credits", "prompt": "How do credits work here?" },
12
+ { "id": "stuck", "prompt": "What's stuck waiting on me?" },
13
+ { "id": "why-slow", "prompt": "Why is that page so slow?" }
14
+ ],
15
+ "runs": {
16
+ "non_technical": {
17
+ "_comment": "The register the non-technical profile asks for: lead with the answer, name the click-path before the terminal, define a term the first time it is needed, and frame the trade-off as cost, time and what the reader will see. Two turns deliberately exercise the scorer's FORGIVING paths rather than dodging them - 'why-slow' turn 1 uses the word migration and defines it in the same breath, and turn 2 names the architecture AND what it costs - because a run that scores well only by avoiding every trigger proves nothing about the detectors.",
18
+ "profile": { "technical_level": "non_technical", "toolchain_familiarity": "new" },
19
+ "replies": {
20
+ "what-to-work-on": [
21
+ "Four jobs are open to you right now. They are on the hall task list, newest first, and each row shows what it pays and roughly how long it takes.",
22
+ "The second one is the quickest - about twenty minutes, and it pays thirty credits. Tell me its number and I will take it on for you."
23
+ ],
24
+ "make-it-live": [
25
+ "You can do this yourself now, and it is two clicks. In the hall, open Deploy: the page shows the version your site is actually serving and the newer one it would move to.",
26
+ "The first click previews the move and the second makes it. If the new version does not answer within a minute it puts the old one back on its own, so a bad release cannot leave your site down. It costs nothing and takes about two minutes."
27
+ ],
28
+ "art-key": [
29
+ "Your free art coverage has ended, so pictures now come out of an art key of your own - think of a key as a password that lets us make images on your account, and you pay only for what you make.",
30
+ "Add it in Settings, under API keys. Do not paste it to me in chat; the Settings page is the only place it should ever go. Most people spend a few dollars per month on this."
31
+ ],
32
+ "session-expired": [
33
+ "That message means your sign-in has timed out. Nothing is lost, and it happens every few weeks on purpose.",
34
+ "Open Settings, then Session, and choose Re-issue. That hands you a fresh code and I take it from there. It takes under a minute."
35
+ ],
36
+ "how-you-talk": [
37
+ "Yes - and it is a setting, so you will not have to keep asking. Open Settings, then Interaction, and set your technical level to non-technical.",
38
+ "Every session after that starts already knowing to define a term the first time it comes up, and to talk in cost, time and what you will see on screen. You will notice the difference in the very next reply."
39
+ ],
40
+ "credits": [
41
+ "Credits are the record of what building here actually cost. When a piece of work ships you earn what the session really cost plus twenty per cent, and one credit is one dollar.",
42
+ "The figure on a task row is only a guide - you are paid on the real cost, never the estimate. Your running total sits on the hall leaderboard."
43
+ ],
44
+ "stuck": [
45
+ "Four things are waiting on you rather than on me. Two need a yes or no and two need a number I cannot guess for you.",
46
+ "They are all on the hall blockers page, oldest first. The one at the top has been sitting eleven days and is holding up two other jobs, so it is the one worth five minutes."
47
+ ],
48
+ "why-slow": [
49
+ "The page re-reads the whole list every time someone opens it. The fix is a migration - that is a one-off change to the way the information is kept - and it would take about an hour.",
50
+ "The architecture underneath is fine; the cost is that repeated reading. After the change the page should open in under a second instead of four."
51
+ ]
52
+ }
53
+ },
54
+ "engineer": {
55
+ "_comment": "The same eight questions answered the way an engineer profile asks to be answered: terse, command-first, and in the vocabulary of the trade with nothing defined. This run is DELIBERATELY dense in all three signals - that is the point. Under the engineer profile it must not be scored for any of it; under the non-technical profile the very same prose must fall through the floor. One run, two profiles, opposite verdicts is what proves the dimension is profile-relative rather than a taste test.",
56
+ "profile": { "technical_level": "engineer", "toolchain_familiarity": "comfortable" },
57
+ "replies": {
58
+ "what-to-work-on": [
59
+ "Four ready. Run `node scripts/gds/start.js`, then claim a task by id.",
60
+ "Second row is a refactor behind the module boundary. The claim is idempotent, so re-running it is a no-op."
61
+ ],
62
+ "make-it-live": [
63
+ "Pin move is gate 3. `bongos upgrade --registry --to <ver> --commit-pin` on the control plane, or the Deploy page enqueues the same intent.",
64
+ "The control flow is enqueue, drain, health-gate, rollback. SIGTERM on a failed probe and the symlink goes back."
65
+ ],
66
+ "art-key": [
67
+ "Art key is unset. Run `node scripts/gds/api.js` against the settings route, or set it in Settings - either path ends up in the same env file.",
68
+ "The upstream call is async and the error surfaces on stderr, not in the hall."
69
+ ],
70
+ "session-expired": [
71
+ "Session token expired. Run `bongos auth --reissue`.",
72
+ "The JWT is short-lived by design and the refresh is idempotent, so a double call is harmless."
73
+ ],
74
+ "how-you-talk": [
75
+ "That is the interaction profile. Run `node scripts/gds/api.js PATCH /settings`, or set it in Settings.",
76
+ "It is injected per session by the Conductor hook and never memoized, so the change takes on the next turn."
77
+ ],
78
+ "credits": [
79
+ "Cost-plus. Run `node scripts/gds/api.js GET /builders/me` for your balance, or read the leaderboard.",
80
+ "The write is an upsert keyed on the session, so a replayed ship cannot double-pay."
81
+ ],
82
+ "stuck": [
83
+ "Four open blockers. Run `node scripts/gds/blocker-review.js` to walk them.",
84
+ "Resolving one will upsert the linked rows to ready inside the same transaction."
85
+ ],
86
+ "why-slow": [
87
+ "N+1 on the list read.",
88
+ "The ORM will hydrate each row separately. Fold it into one query against the schema with a jsonb aggregate."
89
+ ]
90
+ }
91
+ }
92
+ }
93
+ }
@@ -281,7 +281,10 @@ test('sidebar groups read by audience, in order', () => {
281
281
  // 'board-room' LED this group from the ADR 0266 carve until task 1003744 moved
282
282
  // it to the project group — see the note on nav[0]. What is left is rank surface
283
283
  // throughout, which is what lets the group keep its metic head honestly.
284
- assert.deepEqual(nav[4].items.map((i) => i.id), ['watch', 'harbor', 'gate', 'sessions', 'project-settings', 'government']);
284
+ // 'deploy' (task 1003159, ADR 0293) is the SECOND archon item, beside 'government':
285
+ // its gate is the `core.pin.move` atom, archon-floor and system:true. It sits with
286
+ // the steering surfaces because that is what it is — the owner's third release gate.
287
+ assert.deepEqual(nav[4].items.map((i) => i.id), ['watch', 'harbor', 'gate', 'sessions', 'project-settings', 'deploy', 'government']);
285
288
  });
286
289
 
287
290
  test('Economy lives in Docs and points at the reward-schedule page', () => {
@@ -81,6 +81,13 @@ const ORACLE = {
81
81
  // its neighbours: a steering surface, metic+ per ADR 0157. Its two WRITES gate
82
82
  // independently on `project.settings.manage`, so this row is only about the shell.
83
83
  '/builders/project-settings': 'requireGovernmentPage',
84
+ // The deploy door (task 1003159, ADR 0293) — the NARROWEST gate in this table, on
85
+ // the `core.pin.move` atom: archon-floor and system:true, the same key its preview
86
+ // and its move both check. Correct for the one page whose button restarts the live
87
+ // service and changes what code the platform runs. Its READ is gated as tightly as
88
+ // its write on purpose: the preview discloses the exact core version the box serves
89
+ // and how far behind it is, which is a vulnerability window.
90
+ '/builders/deploy': 'requireCorePinPage',
84
91
 
85
92
  // Metic+ via the `project.curate` atom — the exact permission GET /scouting
86
93
  // checks, so the page can never drift wider than the API it fronts. Its
@@ -106,15 +106,17 @@ test('the verdict is offered only to a viewer the routes will accept', () => {
106
106
  assert.match(JS, /studio-verdict__sealed/, 'a viewer below the floor gets a sentence, not three dead buttons');
107
107
  });
108
108
 
109
- test('the review card shows the reviewed work\'s picture, through the kit', () => {
110
- // "the picture is the card" — and drawn by the kit's ONE full-size renderer
111
- // rather than a third hand-rolled <figure> (the task's own instruction).
112
- assert.match(JS, /kit\.taskVisualFigureHtml\(/, 'the plate comes from the kit renderer');
109
+ test('the review card shows the reviewed work\'s picture, through the shared renderer', () => {
110
+ // "the picture is the card" — and drawn by the ONE full-size renderer rather
111
+ // than a hand-rolled <figure>. That renderer moved out of hall-kit.js into
112
+ // hall-visual.js in task 1003835, so the Studio now reaches for OTBVisual; the
113
+ // rule this pins is unchanged, only where the one copy lives.
114
+ assert.match(JS, /vis\.taskVisualFigureHtml\(/, 'the plate comes from the shared renderer');
113
115
  assert.ok(!/<figure/.test(JS), 'studio.js builds no <figure> of its own');
114
- assert.match(HTML, /hall-kit\.js/, 'and the page loads the kit that owns it');
116
+ assert.match(HTML, /hall-visual\.js/, 'and the page loads the file that owns it');
115
117
  // The degrade: a review with no visual (or one that could not be read) draws
116
118
  // nothing at all rather than an empty frame, so it still reads as a review.
117
- assert.match(JS, /if \(!p\.shipped \|\| !kit/, 'no reviewed task, no kit → no plate');
119
+ assert.match(JS, /if \(!p\.shipped \|\| !vis/, 'no reviewed task, no renderer → no plate');
118
120
  // The reviewed task is read for the card in focus, once — not 50 times up front.
119
121
  assert.match(JS, /p\.hydrated = true;/, 'the read is made once per review');
120
122
  assert.match(JS, /\$\{API\}\/tasks\/\$\{encodeURIComponent\(p\.shippedId\)\}/, 'and it reads the SHIPPED task the review names');
@@ -204,7 +206,8 @@ test('the accent is spent only where the rule allows', () => {
204
206
  });
205
207
 
206
208
  // ---------------------------------------------------------------------------
207
- // Boot the REAL studio.js under a DOM stub, with the REAL hall-kit.js beside it
209
+ // Boot the REAL studio.js under a DOM stub, with the REAL hall-kit.js and
210
+ // hall-visual.js beside it
208
211
  // (the bootShell pattern below, pointed at the page script). The static-source
209
212
  // assertions above pin the wiring; these render the card and read what it
210
213
  // actually says, which is the only way to pin a DEGRADE — "no picture" and
@@ -215,11 +218,15 @@ test('the accent is spent only where the rule allows', () => {
215
218
  // NEW_TASK_ID so the "needs work" pair can be followed to its merge.
216
219
  const NEW_TASK_ID = '2001';
217
220
  async function bootStudio({ reviews = [], shipped = {}, rank = 'metic', flags = null, onWrite = null } = {}) {
218
- // The REAL kit, not a stub — the plate's URL guard and escaping are its code,
219
- // and a stub here would make the degrade tests below pass for the wrong reason
220
- // (no kit also means no plate). It is node-require-safe by its own contract.
221
+ // The REAL modules, not stubs — the plate's URL guard and escaping are their
222
+ // code, and a stub here would make the degrade tests below pass for the wrong
223
+ // reason (no renderer also means no plate). Both are node-require-safe by their
224
+ // own contract. hall-visual.js carries the renderer since task 1003835; the kit
225
+ // is still loaded because the page still uses the rest of it.
221
226
  const kit = (await import(`file://${path.join(HALL, 'hall-kit.js').replace(/\\/g, '/')}`)).default;
222
- assert.equal(typeof kit.taskVisualFigureHtml, 'function', 'the real kit loaded');
227
+ const vis = (await import(`file://${path.join(HALL, 'hall-visual.js').replace(/\\/g, '/')}`)).default;
228
+ assert.equal(typeof vis.taskVisualFigureHtml, 'function', 'the real visual module loaded');
229
+ assert.equal(typeof kit.taskVisualFigureHtml, 'undefined', 'and the kit no longer carries a second copy');
223
230
  const els = new Map();
224
231
  const el = (id) => {
225
232
  if (!els.has(id)) els.set(id, { id, innerHTML: '', textContent: '', hidden: false, value: '' });
@@ -258,6 +265,7 @@ async function bootStudio({ reviews = [], shipped = {}, rank = 'metic', flags =
258
265
  errMessage: (d) => (d && d.error && d.error.message) || '',
259
266
  },
260
267
  OTBKit: kit,
268
+ OTBVisual: vis,
261
269
  scrollTo: () => {},
262
270
  };
263
271
  const document = { addEventListener: (t, h) => handlers.push({ t, h }) };
@@ -22,7 +22,7 @@
22
22
 
23
23
  import { strict as assert } from 'node:assert';
24
24
  import { createRequire } from 'node:module';
25
- import { readFileSync } from 'node:fs';
25
+ import { readFileSync, readdirSync } from 'node:fs';
26
26
  import http from 'node:http';
27
27
 
28
28
  const require = createRequire(import.meta.url);
@@ -43,14 +43,31 @@ const PROMISE = /shortly|briefly|minute|second|soon\b/i;
43
43
 
44
44
  console.log('the schema:');
45
45
 
46
- t("INTENT_ACTIONS includes 'disconnect', in strict lockstep with the provisioning_016 CHECK (the newest action migration)", () => {
46
+ t("INTENT_ACTIONS includes 'disconnect', and 016 ADMITTED it while the LAST action migration must still carry it", () => {
47
47
  assert.ok(provisioning.INTENT_ACTIONS.includes('disconnect'));
48
48
  const sql = readFileSync(new URL('../modules/provisioning/migrations/provisioning_016_disconnect.sql', import.meta.url), 'utf8');
49
49
  const m = /CHECK \(action IN \(([^)]*)\)\)/.exec(sql);
50
50
  assert.ok(m, 'the migration widens the action CHECK');
51
- const inSql = m[1].split(',').map((s) => s.trim().replace(/^'|'$/g, '')).sort();
52
- assert.deepEqual(inSql, [...provisioning.INTENT_ACTIONS].sort(), 'the CHECK a live DB ends with and INTENT_ACTIONS carry the same set');
51
+ const inSql = m[1].split(',').map((s) => s.trim().replace(/^'|'$/g, ''));
52
+ assert.ok(inSql.includes('disconnect'), 'this migration is the one that admitted it');
53
53
  assert.match(sql, /INSERT INTO schema_migrations \(version\) VALUES \('provisioning_016_disconnect'\)/, 'self-registers');
54
+
55
+ // THE SET EQUALITY BELONGS TO WHICHEVER MIGRATION RUNS LAST, not to this one.
56
+ // Each of these files REPLACES the action CHECK rather than adding to it, so the
57
+ // constraint a live DB ends up with is the last one applied in name order — and
58
+ // a later migration that forgot 'disconnect' would silently drop it while this
59
+ // file still read as correct. 016 and 018 (task 1003159's core-upgrade) landed on
60
+ // separate branches, which is exactly how that goes wrong.
61
+ const migDir = new URL('../modules/provisioning/migrations/', import.meta.url);
62
+ const defining = readdirSync(migDir)
63
+ .filter((f) => f.endsWith('.sql'))
64
+ .filter((f) => /CHECK \(action IN \(/.test(readFileSync(new URL(f, migDir), 'utf8')))
65
+ .sort();
66
+ const lastSql = readFileSync(new URL(defining[defining.length - 1], migDir), 'utf8');
67
+ const lastIn = /CHECK \(action IN \(([^)]*)\)\)/.exec(lastSql)[1]
68
+ .split(',').map((s) => s.trim().replace(/^'|'$/g, '')).sort();
69
+ assert.deepEqual(lastIn, [...provisioning.INTENT_ACTIONS].sort(),
70
+ `the LAST action migration (${defining[defining.length - 1]}) and INTENT_ACTIONS carry the same set`);
54
71
  });
55
72
 
56
73
  console.log('\nthe queue:');
@@ -61,6 +78,11 @@ function queueDb({ open = null } = {}) {
61
78
  return {
62
79
  inserts, deletes,
63
80
  query: async (sql, params) => {
81
+ // The self-row guard's shape lookup (task 1003159, ADR 0293 D2) — every enqueue
82
+ // now asks "is this the control plane?" first. A tenant shape is the honest
83
+ // answer here; answering 'control-plane' would refuse the disconnect outright,
84
+ // which is itself correct behaviour and is covered in core_upgrade_runner.mjs.
85
+ if (/SELECT hosting_shape FROM provisioning_instances/.test(sql)) return { rows: [{ hosting_shape: 'co-tenant' }] };
64
86
  if (/SELECT \* FROM provisioning_intents/.test(sql)) return { rows: open ? [open] : [] };
65
87
  if (/INSERT INTO provisioning_intents/.test(sql)) {
66
88
  if (open) { const e = new Error('dup'); e.code = '23505'; throw e; }
@@ -77,7 +99,10 @@ await ta('an idle project queues a disconnect intent', async () => {
77
99
  const db = queueDb();
78
100
  const r = await disconnect.enqueueDisconnect(db, 7, 'api:self');
79
101
  assert.equal(r.created, true);
80
- assert.deepEqual(db.inserts, [[7, 'disconnect', 'api:self']]);
102
+ // The trailing null is the target_version enqueueIntent gained in task 1003159:
103
+ // only a 'core-upgrade' names one, and the column's CHECK is an EQUIVALENCE, so a
104
+ // disconnect carrying a target would be refused by the database.
105
+ assert.deepEqual(db.inserts, [[7, 'disconnect', 'api:self', null]]);
81
106
  });
82
107
 
83
108
  for (const [action, state] of [['provision', 'pending'], ['provision', 'running'], ['teardown', 'running'], ['restart', 'pending']]) {