@bongos/core 1.19.789 → 1.19.790

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 (47) hide show
  1. package/.bongos-core.json +110 -40
  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 +31 -14
  12. package/docs/copy-registry.json +205 -31
  13. package/docs/module-api-changelog.md +2 -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/shell.js +4 -0
  23. package/modules/provisioning/control-plane-guard.js +102 -0
  24. package/modules/provisioning/core-upgrade.js +289 -0
  25. package/modules/provisioning/migrations/provisioning_018_core_upgrade_intent.sql +98 -0
  26. package/modules/provisioning/module.json +1 -1
  27. package/modules/provisioning/provisioning.js +8 -8
  28. package/modules/provisioning/routes/core-upgrade.js +95 -0
  29. package/package-lock.json +2 -2
  30. package/package.json +1 -1
  31. package/scripts/gds/enroll-control-plane.js +101 -0
  32. package/scripts/gds/provision-core-upgrade.js +311 -0
  33. package/scripts/gds/provision.js +4 -2
  34. package/scripts/gds/upgrade.js +53 -14
  35. package/src/bongos/auth.js +19 -0
  36. package/src/bongos/serve-internal.js +21 -0
  37. package/src/module-api.js +1 -1
  38. package/tests/auth_resolve_failure.mjs +4 -2
  39. package/tests/core_upgrade_door.mjs +408 -0
  40. package/tests/core_upgrade_runner.mjs +377 -0
  41. package/tests/hall_nav.mjs +4 -1
  42. package/tests/hall_page_gate_map.mjs +7 -0
  43. package/tests/provisioning_disconnect.mjs +30 -5
  44. package/tests/provisioning_paid_shape_gate.mjs +14 -4
  45. package/tests/provisioning_restart.mjs +25 -9
  46. package/tests/provisioning_settings_apply.mjs +11 -2
  47. package/tests/provisioning_teardown_intent.mjs +7 -0
@@ -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);
@@ -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
@@ -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']]) {
@@ -161,10 +161,20 @@ await ta('a case-variant shape does not slip past the gate INTO a dedicated row'
161
161
  }
162
162
  });
163
163
 
164
- await ta('HOSTING_SHAPES still carries exactly the three shapes the gate reasons about', () => {
165
- // If a fourth, also-metered shape is ever added, this test is the tripwire that says the
166
- // gate's single-shape assumption needs revisiting.
167
- assert.deepEqual([...provisioning.HOSTING_SHAPES].sort(), ['co-tenant', 'dedicated', 'standalone']);
164
+ await ta('HOSTING_SHAPES carries the shapes the gate reasons about, and control-plane is NOT metered', () => {
165
+ // This tripwire fired for the first time at task 1003159, which added a fourth
166
+ // shape — 'control-plane', the platform's own box, enrolled so the owner's deploy
167
+ // door has a row to point at (ADR 0293 D2). Its question was "is the new shape
168
+ // also metered?", and the answer is NO, stated here rather than implied by
169
+ // absence: the control plane is infrastructure the project already runs and pays
170
+ // for, not a droplet allocated per request, so nothing about standing it up is
171
+ // billed through this gate. It is also not creatable through the create route at
172
+ // all — the self row is enrolled by the platform, not asked for by a builder.
173
+ assert.deepEqual([...provisioning.HOSTING_SHAPES].sort(),
174
+ ['co-tenant', 'control-plane', 'dedicated', 'standalone']);
175
+ // The assumption the tripwire actually guards is that exactly ONE shape is
176
+ // metered. A fifth shape that IS billed must widen the gate itself, in
177
+ // paid-shape-gate.js, and not merely this list.
168
178
  });
169
179
 
170
180
  server.close();
@@ -29,7 +29,7 @@
29
29
 
30
30
  import { strict as assert } from 'node:assert';
31
31
  import { createRequire } from 'node:module';
32
- import { readFileSync } from 'node:fs';
32
+ import { readFileSync, readdirSync } from 'node:fs';
33
33
  import http from 'node:http';
34
34
 
35
35
  const require = createRequire(import.meta.url);
@@ -51,17 +51,33 @@ const PROMISE = /shortly|briefly|minute|second|soon\b/i;
51
51
 
52
52
  console.log('the module slice — constants, the migration, the enqueue classifier:');
53
53
 
54
- t("INTENT_ACTIONS includes 'restart', in lockstep with the provisioning_011 CHECK", () => {
54
+ t("INTENT_ACTIONS includes 'restart', in lockstep with the LATEST action CHECK", () => {
55
+ // Pinned to provisioning_011 by name until task 1003159, which added
56
+ // 'core-upgrade' in provisioning_016 — and a lockstep assertion aimed at a
57
+ // superseded migration compares the live vocabulary against a frozen one, so it
58
+ // reds on the next widening rather than on a real drift. It now reads whichever
59
+ // migration most recently defines the CHECK, the shape
60
+ // tests/claimable_blocker_status.mjs uses for the claimable view.
55
61
  assert.ok(provisioning.INTENT_ACTIONS.includes('restart'));
56
- const sql = readFileSync(new URL('../modules/provisioning/migrations/provisioning_011_restart.sql', import.meta.url), 'utf8');
62
+ const migDir = new URL('../modules/provisioning/migrations/', import.meta.url);
63
+ const defining = readdirSync(migDir)
64
+ .filter((f) => f.endsWith('.sql'))
65
+ .filter((f) => /CHECK \(action IN \(/.test(readFileSync(new URL(f, migDir), 'utf8')))
66
+ .sort();
67
+ assert.ok(defining.length, 'some migration defines the action CHECK');
68
+ const latest = defining[defining.length - 1];
69
+ const sql = readFileSync(new URL(latest, migDir), 'utf8');
57
70
  const m = /CHECK \(action IN \(([^)]*)\)\)/.exec(sql);
58
71
  assert.ok(m, 'the migration widens the action CHECK');
59
- const inSql = m[1].split(',').map((s) => s.trim().replace(/^'|'$/g, ''));
60
- assert.ok(inSql.includes('restart'), 'this migration is the one that admitted it');
61
- // The NEWEST action migration owns the strict set equality (provisioning_016,
62
- // tests/provisioning_disconnect.mjs); an older one is a prefix of the set.
63
- for (const a of inSql) assert.ok(provisioning.INTENT_ACTIONS.includes(a), `${a} is still an INTENT_ACTION`);
64
- assert.match(sql, /INSERT INTO schema_migrations \(version\) VALUES \('provisioning_011_restart'\)/, 'self-registers');
72
+ const inSql = m[1].split(',').map((s) => s.trim().replace(/^'|'$/g, '')).sort();
73
+ // The NEWEST migration owns the strict set equality — the CHECK a live DB ends up
74
+ // with is the last one applied, so this is the assertion that would catch an action
75
+ // added in code but never admitted by the schema. That is provisioning_018 today:
76
+ // 016_disconnect and 018_core_upgrade_intent each rewrite the CHECK, so the later
77
+ // one must carry BOTH actions or the earlier one's is silently dropped.
78
+ assert.deepEqual(inSql, [...provisioning.INTENT_ACTIONS].sort(), 'the CHECK and INTENT_ACTIONS carry the same set');
79
+ const sql011 = readFileSync(new URL('provisioning_011_restart.sql', migDir), 'utf8');
80
+ assert.match(sql011, /INSERT INTO schema_migrations \(version\) VALUES \('provisioning_011_restart'\)/, 'self-registers');
65
81
  });
66
82
 
67
83
  t('RESTART_FROM is exactly {active} — every other state is already served by the provision path', () => {
@@ -84,8 +84,17 @@ await ta('enqueueSettingsApply: a fresh intent → created:true; the INSERT carr
84
84
  const r = await provisioning.enqueueSettingsApply(db, 18, 'api:self');
85
85
  assert.equal(r.created, true);
86
86
  assert.equal(r.conflict, undefined);
87
- assert.match(calls[0].sql, /INSERT INTO provisioning_intents/);
88
- assert.deepEqual(calls[0].params, [18, 'settings-apply', 'api:self']);
87
+ // calls[0] is the self-row guard's shape lookup (ADR 0293 D2) — every enqueue now
88
+ // asks "is this the control plane?" before it writes. Assert on the INSERT itself
89
+ // rather than on a position, so this test says what it means and does not move
90
+ // again the next time a check joins the front of this function.
91
+ const insert = calls.find((c) => /INSERT INTO provisioning_intents/.test(c.sql));
92
+ assert.ok(insert, 'the enqueue reaches its INSERT');
93
+ assert.match(calls[0].sql, /SELECT hosting_shape FROM provisioning_instances/, 'and the guard ran FIRST');
94
+ // The trailing null is the target_version enqueueIntent gained in task 1003159:
95
+ // only a 'core-upgrade' names one, and the column's CHECK is an EQUIVALENCE, so a
96
+ // settings-apply carrying a target would be refused by the database.
97
+ assert.deepEqual(insert.params, [18, 'settings-apply', 'api:self', null]);
89
98
  });
90
99
 
91
100
  await ta('enqueueSettingsApply: an identical PENDING push already open → created:false, no conflict (it carries this change)', async () => {
@@ -55,6 +55,13 @@ function fakeIntentDb(seed = []) {
55
55
  return {
56
56
  rows,
57
57
  query: async (sql, params) => {
58
+ // The self-row guard's shape lookup (task 1003159, ADR 0293 D2): every enqueue
59
+ // now asks "is this the control plane?" before it writes. These cases are all
60
+ // ORDINARY instances, so the honest answer is a tenant shape — answering
61
+ // 'control-plane' here would refuse every teardown in this file.
62
+ if (/SELECT hosting_shape FROM provisioning_instances/.test(sql)) {
63
+ return { rows: [{ hosting_shape: 'co-tenant' }] };
64
+ }
58
65
  if (/INSERT INTO provisioning_intents/.test(sql)) {
59
66
  const [instance_id, action, requested_by] = params;
60
67
  if (open(instance_id).length) {