@bongos/core 1.21.5 → 1.21.6

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 (76) hide show
  1. package/.bongos-core.json +138 -68
  2. package/clients/bongos-client/index.d.ts +1 -1
  3. package/docs/adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md +1 -0
  4. package/docs/api/openapi.json +2 -1
  5. package/docs/api-reference.md +1 -1
  6. package/docs/copy-inventory.md +156 -144
  7. package/docs/copy-registry.json +286 -158
  8. package/docs/module-api-changelog.md +2 -0
  9. package/docs/onboarding/diagrams/03-drachmae-karma.mmd +1 -1
  10. package/docs/onboarding/diagrams/assertions.json +1 -1
  11. package/docs/page-inventory.json +31 -4
  12. package/docs/page-readings.json +121 -94
  13. package/modules/autonomy/config-idle.js +192 -0
  14. package/modules/autonomy/routes/autonomy.js +22 -0
  15. package/modules/autonomy/runner-health.js +22 -1
  16. package/modules/hall-ui/public/city-draw.js +438 -0
  17. package/modules/hall-ui/public/city.css +110 -0
  18. package/modules/hall-ui/public/city.html +71 -0
  19. package/modules/hall-ui/public/city.js +177 -0
  20. package/modules/hall-ui/public/city.states.json +30 -0
  21. package/modules/hall-ui/public/gate.js +11 -4
  22. package/modules/hall-ui/public/profile.css +4 -0
  23. package/modules/hall-ui/public/profile.js +25 -1
  24. package/modules/hall-ui/public/settings-autobongos.js +19 -1
  25. package/modules/hall-ui/public/settings.css +26 -0
  26. package/modules/hall-ui/public/settings.html +8 -0
  27. package/modules/hall-ui/public/settings.js +43 -3
  28. package/modules/platform-identity/craft-rollup.js +72 -0
  29. package/modules/platform-identity/migrations/platform_identity_029_activity_crafts.sql +23 -0
  30. package/modules/platform-identity/platform-identity.js +12 -6
  31. package/modules/platform-identity/routes/sso.js +4 -0
  32. package/modules/platform-identity/tests/platform-identity.mjs +1 -1
  33. package/modules/provisioning/core-upgrade.js +34 -7
  34. package/modules/provisioning/module.json +2 -1
  35. package/modules/provisioning/seams.js +2 -0
  36. package/modules/public-landing/public/projects.states.json +1 -0
  37. package/package-lock.json +2 -2
  38. package/package.json +1 -1
  39. package/release-notes.json +58 -0
  40. package/scripts/gds/autobongos-grade-cap.js +187 -0
  41. package/scripts/gds/autobongos-loop.js +4 -0
  42. package/scripts/gds/autobongos-run.js +154 -13
  43. package/scripts/gds/autobongos-verify.js +211 -14
  44. package/scripts/gds/provision-core-upgrade.js +14 -2
  45. package/scripts/gds/provision-repo.js +77 -13
  46. package/scripts/gds/ship-flow.js +5 -0
  47. package/scripts/gds/ship-preflight-steps.js +2 -1
  48. package/scripts/gds/ship.js +10 -0
  49. package/scripts/gds/update-sweep.js +24 -8
  50. package/scripts/gds/upgrade-outcome.js +1 -0
  51. package/src/bongos/auth-admission.js +95 -13
  52. package/src/bongos/db-kernel.js +1 -1
  53. package/src/bongos/routes/core-update.js +27 -2
  54. package/src/bongos/serve-internal.js +14 -0
  55. package/src/bongos/software-update.js +3 -2
  56. package/src/module-api.js +1 -1
  57. package/tests/activity_rollup_order_db.mjs +3 -1
  58. package/tests/activity_snapshots_db.mjs +2 -0
  59. package/tests/autobongos_grade_cap.mjs +125 -0
  60. package/tests/autobongos_loop.mjs +230 -1
  61. package/tests/autobongos_verify.mjs +239 -3
  62. package/tests/autonomy_config_idle.mjs +272 -0
  63. package/tests/core_update_banner.mjs +29 -0
  64. package/tests/core_upgrade_door.mjs +31 -1
  65. package/tests/core_upgrade_runner.mjs +20 -0
  66. package/tests/hall_audit.mjs +10 -1
  67. package/tests/hall_city.mjs +369 -0
  68. package/tests/hall_page_gate_map.mjs +5 -0
  69. package/tests/hub_craft_rollup.mjs +350 -0
  70. package/tests/profile_rollup_consent.mjs +2 -0
  71. package/tests/profile_route.mjs +16 -2
  72. package/tests/provision.mjs +78 -0
  73. package/tests/settings_main_role.mjs +190 -0
  74. package/tests/software_update.mjs +56 -1
  75. package/tests/update_subscription_engine.mjs +55 -3
  76. package/tests/wizard_physics_more.mjs +19 -0
@@ -0,0 +1,272 @@
1
+ // tests/autonomy_config_idle.mjs — idle on a setting is not idle on no work
2
+ // (task 1004539).
3
+ //
4
+ // The incident: ten hours fenced with no_builder_scope while the hall said
5
+ // "alive, waiting for work". These pin the done-when: runner health reports a
6
+ // configuration idle as NOT ok with the fix, the owner's kill switch does not,
7
+ // and the Discord notice is said once per change rather than once per loop.
8
+
9
+ import assert from 'node:assert/strict';
10
+ import { test } from 'node:test';
11
+ import { createRequire } from 'node:module';
12
+
13
+ const require = createRequire(import.meta.url);
14
+ const ci = require('../modules/autonomy/config-idle.js');
15
+ const { describeRunner } = require('../modules/autonomy/runner-health.js');
16
+ const runner = require('../scripts/gds/autobongos-run.js');
17
+
18
+ const NOW = Date.parse('2026-10-02T09:47:00.000Z');
19
+ const agoS = (s) => new Date(NOW - s * 1000).toISOString();
20
+ const hb = (over = {}) => ({ host: 'winbox', mode: 'full', last_seen_at: agoS(30), ...over });
21
+
22
+ // ── runner health: the hall's verdict ───────────────────────────────────────
23
+
24
+ test('a runner idle on no_builder_scope is NOT ok, and the hall gets the fix', () => {
25
+ const d = describeRunner(hb({ last_event: 'config_idle:no_builder_scope' }), { nowMs: NOW });
26
+ assert.equal(d.state, 'idle_config');
27
+ assert.equal(d.ok, false, 'the incident: this read ok=true for ten hours');
28
+ assert.equal(d.idleCode, 'no_builder_scope');
29
+ assert.match(d.remedy, /Settings/);
30
+ assert.match(d.detail, /no goals are picked/);
31
+ assert.match(d.detail, /winbox/);
32
+ });
33
+
34
+ test('every configuration code reads not-ok with its own remedy', () => {
35
+ for (const code of Object.keys(ci.CONFIG_IDLE)) {
36
+ const d = describeRunner(hb({ last_event: `config_idle:${code}` }), { nowMs: NOW });
37
+ assert.equal(d.ok, false, code);
38
+ assert.equal(d.remedy, ci.CONFIG_IDLE[code].remedy, code);
39
+ }
40
+ });
41
+
42
+ test('a config-idle runner that goes quiet past the dead line is DEAD, not idle', () => {
43
+ // A dead runner cannot report its configuration; silence still wins.
44
+ const d = describeRunner(hb({ last_event: 'config_idle:no_builder_scope', last_seen_at: agoS(3 * 3600) }), { nowMs: NOW });
45
+ assert.equal(d.state, 'dead');
46
+ });
47
+
48
+ test('the kill switch, a plain wait and an unknown code stay ok', () => {
49
+ for (const last_event of ['fenced', 'waiting', 'nothing_claimable', 'hold', 'config_idle:kill_switch', 'config_idle:<b>x</b>']) {
50
+ const d = describeRunner(hb({ last_event }), { nowMs: NOW });
51
+ assert.equal(d.state, 'alive', last_event);
52
+ assert.equal(d.ok, true, last_event);
53
+ assert.equal(d.remedy, undefined, last_event);
54
+ }
55
+ });
56
+
57
+ // ── the runner side: which rows count, and the grace ────────────────────────
58
+
59
+ test('configIdleCode: configuration refusals count; deliberate stops and no-work do not', () => {
60
+ assert.equal(ci.configIdleCode({ event: 'fenced', code: 'no_builder_scope' }), 'no_builder_scope');
61
+ assert.equal(ci.configIdleCode({ event: 'fenced', code: 'no_allowlist' }), 'no_allowlist');
62
+ assert.equal(ci.configIdleCode({ event: 'fenced', code: 'fence_unreadable' }), 'fence_unreadable');
63
+ assert.equal(ci.configIdleCode({ event: 'fenced', code: 'goal_not_allowlisted', refused_goals: [7] }), 'goal_not_allowlisted');
64
+ assert.equal(ci.configIdleCode({ event: 'fenced', code: 'goal_not_allowlisted', task_id: 5 }), null, 'one task outside the allowlist is the fence working');
65
+ assert.equal(ci.configIdleCode({ event: 'hold', unknown: true, reason: 'no gauge file' }), 'gauge_unknown');
66
+ for (const row of [
67
+ { event: 'fenced', code: 'kill_switch' },
68
+ { event: 'fenced', code: 'builder_paused' },
69
+ { event: 'fenced', code: 'not_permitted' },
70
+ { event: 'fenced', code: 'protected_path', task_id: 3 },
71
+ { event: 'hold', unknown: false, sleep_until: 1 },
72
+ { event: 'hold', unknown: false, usage_limit: true },
73
+ { event: 'nothing_claimable' },
74
+ null,
75
+ ]) assert.equal(ci.configIdleCode(row), null, JSON.stringify(row));
76
+ });
77
+
78
+ test('the grace: nothing is said until the stretch has held for it', () => {
79
+ const t0 = NOW;
80
+ let tr = ci.trackIdle(null, { event: 'fenced', code: 'no_builder_scope' }, t0);
81
+ assert.equal(ci.idleEvent(tr, t0 + (ci.GRACE_S - 1) * 1000), null);
82
+ tr = ci.trackIdle(tr, { event: 'fenced', code: 'no_builder_scope' }, t0 + 600_000);
83
+ assert.equal(tr.sinceMs, t0, 'the same code continues the stretch');
84
+ assert.equal(ci.idleEvent(tr, t0 + ci.GRACE_S * 1000), 'config_idle:no_builder_scope');
85
+ // A blip-prone code waits longer.
86
+ const fu = ci.trackIdle(null, { event: 'fenced', code: 'fence_unreadable' }, t0);
87
+ assert.equal(ci.idleEvent(fu, t0 + ci.GRACE_S * 1000), null);
88
+ assert.equal(ci.idleEvent(fu, t0 + ci.LONG_GRACE_S * 1000), 'config_idle:fence_unreadable');
89
+ // A different code restarts the clock; any other row ends it.
90
+ assert.equal(ci.trackIdle(tr, { event: 'fenced', code: 'no_allowlist' }, t0 + 1).sinceMs, t0 + 1);
91
+ assert.equal(ci.trackIdle(tr, { event: 'fenced', code: 'kill_switch' }, t0 + 1), null);
92
+ assert.equal(ci.trackIdle(tr, { event: 'nothing_claimable' }, t0 + 1), null);
93
+ });
94
+
95
+ test('markBeat leaves a working, booting or exiting beat alone', () => {
96
+ const tr = { code: 'no_builder_scope', sinceMs: NOW - 3600_000 };
97
+ assert.equal(ci.markBeat({ last_event: 'waiting' }, tr, NOW).last_event, 'config_idle:no_builder_scope');
98
+ assert.equal(ci.markBeat({ last_event: 'loop', mode: 'full' }, tr, NOW).mode, 'full');
99
+ assert.equal(ci.markBeat({ last_event: 'worked', working_task_id: 9 }, tr, NOW).last_event, 'worked');
100
+ assert.equal(ci.markBeat({ last_event: 'boot' }, tr, NOW).last_event, 'boot');
101
+ assert.equal(ci.markBeat({ last_event: 'upgrade_exit' }, tr, NOW).last_event, 'upgrade_exit');
102
+ assert.equal(ci.markBeat({ last_event: 'waiting' }, null, NOW).last_event, 'waiting');
103
+ });
104
+
105
+ test('THE LOOP: a runner fenced on no_builder_scope says so on its heartbeat once the grace has passed', async () => {
106
+ // Driven through the real forever(), so the beat override is wired, not just written.
107
+ let clock = NOW;
108
+ const beats = [];
109
+ const deps = {
110
+ reconcileLeftoverClaims: async () => ({ event: 'boot_reconcile', ok: true, leftover: 0 }),
111
+ iteration: async () => ({ event: 'fenced', code: 'no_builder_scope', reason: 'r' }),
112
+ waitOrJump: async (waitS, d) => { clock += 10 * 60_000; await d.beat({ last_event: 'waiting' }); return { waited: waitS, jumped: false }; },
113
+ record: (r) => r,
114
+ emit: () => {},
115
+ writeHeartbeat: () => true,
116
+ postHeartbeat: async (fields) => { beats.push(fields.last_event); return true; },
117
+ codeDrifted: async () => false,
118
+ loadedHead: async () => null,
119
+ now: () => clock,
120
+ };
121
+ await runner.forever({ goals: [], maxTasks: 1, maxLoops: 4 }, deps);
122
+ assert.equal(beats[0], 'boot');
123
+ // The first loop is inside the grace: pending, which the hall reads as a fence wait.
124
+ assert.equal(beats[1], 'loop');
125
+ assert.equal(beats[2], 'config_pending:no_builder_scope');
126
+ assert.equal(describeRunner(hb({ last_event: beats[2] }), { nowMs: NOW }).ok, true, 'inside the grace is not an alert');
127
+ assert.ok(beats.includes('config_idle:no_builder_scope'), `beats: ${beats.join(',')}`);
128
+ assert.equal(beats[beats.length - 1], 'config_idle:no_builder_scope', 'and keeps saying it while it holds');
129
+ });
130
+
131
+ test('THE LOOP: the kill switch never turns into a config_idle beat', async () => {
132
+ let clock = NOW;
133
+ const beats = [];
134
+ const deps = {
135
+ reconcileLeftoverClaims: async () => ({ event: 'boot_reconcile', ok: true, leftover: 0 }),
136
+ iteration: async () => ({ event: 'fenced', code: 'kill_switch', reason: 'r' }),
137
+ waitOrJump: async (waitS, d) => { clock += 60 * 60_000; await d.beat({ last_event: 'waiting' }); return { waited: waitS, jumped: false }; },
138
+ record: (r) => r, emit: () => {},
139
+ writeHeartbeat: () => true,
140
+ postHeartbeat: async (fields) => { beats.push(fields.last_event); return true; },
141
+ codeDrifted: async () => false, loadedHead: async () => null, now: () => clock,
142
+ };
143
+ await runner.forever({ goals: [], maxTasks: 1, maxLoops: 4 }, deps);
144
+ assert.ok(!beats.some((b) => String(b).startsWith('config_idle:')), beats.join(','));
145
+ });
146
+
147
+ // ── the notice: once per change ─────────────────────────────────────────────
148
+
149
+ test('the Discord notice is DE-DUPLICATED: once when it starts, once when it clears', async () => {
150
+ const posts = [];
151
+ const n = ci.createNotifier({ post: async (m) => { posts.push(m); return { posted: true }; } });
152
+ const beat = (lastEvent, extra = {}) => n.observe({ builderId: '3', host: 'WinBox', lastEvent, ...extra });
153
+
154
+ await beat('waiting');
155
+ assert.equal(posts.length, 0, 'quiet idle says nothing');
156
+ for (let i = 0; i < 30; i++) await beat('config_idle:no_builder_scope');
157
+ // A runner restart mid-stretch: boot, then the same setting pending again.
158
+ for (const e of ['loop', 'waiting', 'upgrade_exit', 'boot', 'loop', 'config_pending:no_builder_scope', 'waiting']) await beat(e);
159
+ await beat('config_idle:no_builder_scope');
160
+ assert.equal(posts.length, 1, 'thirty beats and a restart are one notice');
161
+ assert.match(posts[0].text, /no goals are picked/);
162
+ assert.match(posts[0].text, /Settings/);
163
+ assert.equal(posts[0].channel, 'ship_news');
164
+
165
+ await beat('config_idle:no_allowlist');
166
+ assert.equal(posts.length, 2, 'a different cause is a new notice');
167
+ await beat('nothing_claimable');
168
+ assert.equal(posts.length, 3, 'clearing is said once');
169
+ assert.match(posts[2].text, /no longer held/);
170
+ assert.match(posts[2].text, /no goal is allowed/, 'the clear names the setting in words');
171
+ for (const p of posts) assert.doesNotMatch(p.text, /no_allowlist|no_builder_scope/, 'never a raw code in a sentence a person reads');
172
+ await beat('nothing_claimable');
173
+ await beat('waiting');
174
+ assert.equal(posts.length, 3);
175
+ });
176
+
177
+ test('the kill switch never posts, and a working beat clears a notice', async () => {
178
+ const posts = [];
179
+ const n = ci.createNotifier({ post: async (m) => { posts.push(m); return { posted: true }; } });
180
+ await n.observe({ builderId: 1, host: 'a', lastEvent: 'fenced' });
181
+ await n.observe({ builderId: 1, host: 'a', lastEvent: 'config_idle:kill_switch' });
182
+ assert.equal(posts.length, 0);
183
+ await n.observe({ builderId: 1, host: 'a', lastEvent: 'config_idle:gauge_unknown' });
184
+ await n.observe({ builderId: 1, host: 'a', lastEvent: 'loop', workingTaskId: 42 });
185
+ assert.equal(posts.length, 2);
186
+ // Separate runners are separate notices.
187
+ await n.observe({ builderId: 1, host: 'b', lastEvent: 'config_idle:gauge_unknown' });
188
+ await n.observe({ builderId: 2, host: 'a', lastEvent: 'config_idle:gauge_unknown' });
189
+ assert.equal(posts.length, 4);
190
+ });
191
+
192
+ test('a gauge notice clears when the runner moves to an ordinary capacity hold, or the owner throws the switch', async () => {
193
+ for (const after of ['hold', 'fenced']) {
194
+ const posts = [];
195
+ const n = ci.createNotifier({ post: async (m) => { posts.push(m); return { posted: true }; } });
196
+ await n.observe({ builderId: 1, host: 'a', lastEvent: 'config_idle:gauge_unknown' });
197
+ await n.observe({ builderId: 1, host: 'a', lastEvent: 'waiting' });
198
+ await n.observe({ builderId: 1, host: 'a', lastEvent: after });
199
+ assert.equal(posts.length, 2, after);
200
+ assert.match(posts[1].text, /no longer held/, after);
201
+ }
202
+ });
203
+
204
+ test('the host lands in Discord as plain code, never a link or formatting', () => {
205
+ const t = ci.noticeText({ action: 'raise', code: 'no_allowlist', host: '[click](https://evil.example)*@everyone*' });
206
+ assert.doesNotMatch(t, /\]\(|https?:|\*|@/);
207
+ assert.match(t, /on `clickhttpsevil\.exampleeveryone`/);
208
+ });
209
+
210
+ test('no Discord on the instance: nothing posts and nothing is remembered', async () => {
211
+ const n = ci.createNotifier({ post: null });
212
+ const r = await n.observe({ builderId: 1, host: 'a', lastEvent: 'config_idle:no_allowlist' });
213
+ assert.deepEqual([r.posted, r.reason], [false, 'no_discord']);
214
+ assert.equal(n.notified.size, 0);
215
+ // A port that answers "no webhook for that channel" is the same.
216
+ const m = ci.createNotifier({ post: async () => ({ posted: false, reason: 'no_webhook_for_channel' }) });
217
+ await m.observe({ builderId: 1, host: 'a', lastEvent: 'config_idle:no_allowlist' });
218
+ assert.equal(m.notified.size, 0);
219
+ });
220
+
221
+ test('the hall draws idle_config as a warning with the fix, on the Gate page and in Settings', () => {
222
+ const fs = require('node:fs');
223
+ const gate = fs.readFileSync('modules/hall-ui/public/gate.js', 'utf8');
224
+ assert.match(gate, /idle_config: 'warn'/, 'the dot is a warning, not ok');
225
+ assert.match(gate, /h\.state === 'idle_config'[\s\S]{0,200}h\.remedy/, 'the checklist carries the remedy');
226
+ const panel = fs.readFileSync('modules/hall-ui/public/settings-autobongos.js', 'utf8');
227
+ assert.match(panel, /'GET', `\$\{API\}\/autonomy\/runners`/);
228
+ assert.match(panel, /h\.state !== 'idle_config'/);
229
+ assert.match(panel, /escapeHtml\(h\.remedy/);
230
+ });
231
+
232
+ // ── the route: POST /autonomy/heartbeat posts through the discord.post port ──
233
+
234
+ test('POST /autonomy/heartbeat: thirty config-idle beats are ONE Discord post', async () => {
235
+ const path = require('node:path');
236
+ const express = require('express');
237
+ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
238
+ const api = require('../src/module-api.js');
239
+ const saved = { b: api.requireBuilder, p: api.requirePermission, r: api.resolveOptional, q: api.pool.query };
240
+ const posts = [];
241
+ api.requireBuilder = (r, _s, next) => { r.builder = { id: 7, rank: 'metic' }; next(); };
242
+ api.requirePermission = () => (_r, _s, next) => next();
243
+ api.resolveOptional = (name, dflt) => (name === 'discord.post' ? async (m) => { posts.push(m); return { posted: true }; } : dflt);
244
+ api.pool.query = async () => ({ rows: [{ host: 'winbox' }] });
245
+ const routePath = require.resolve(path.resolve('modules/autonomy/routes/autonomy.js'));
246
+ delete require.cache[routePath];
247
+ let server;
248
+ try {
249
+ const router = require(routePath)();
250
+ const app = express();
251
+ app.use(express.json()); app.use(attachFail); app.use(router);
252
+ server = app.listen(0);
253
+ await new Promise((r) => server.once('listening', r));
254
+ const base = `http://127.0.0.1:${server.address().port}`;
255
+ const beat = async (last_event) => {
256
+ const r = await fetch(`${base}/autonomy/heartbeat`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ host: 'WinBox', last_event }) });
257
+ assert.equal(r.status, 200);
258
+ await new Promise((res) => setImmediate(res));
259
+ };
260
+ await beat('fenced');
261
+ for (let i = 0; i < 30; i++) await beat('config_idle:no_builder_scope');
262
+ await beat('waiting');
263
+ await beat('config_idle:no_builder_scope');
264
+ assert.equal(posts.length, 1);
265
+ assert.match(posts[0].text, /`WinBox`/);
266
+ assert.match(posts[0].text, /Settings/);
267
+ } finally {
268
+ if (server) server.close();
269
+ api.requireBuilder = saved.b; api.requirePermission = saved.p; api.resolveOptional = saved.r; api.pool.query = saved.q;
270
+ delete require.cache[routePath];
271
+ }
272
+ });
@@ -398,3 +398,32 @@ test('a trailing slash is still /deploy', async () => {
398
398
  await new Promise((r) => setTimeout(r, 0));
399
399
  assert.doesNotMatch(b.banner().innerHTML, /href="\/deploy"/);
400
400
  });
401
+
402
+ test('the banner asks with the project\'s stored update rule (task 1004535)', async () => {
403
+ const r2 = createRequire(import.meta.url);
404
+ const authPath = r2.resolve(path.join(ROOT, 'src/bongos/auth.js'));
405
+ const routePath = r2.resolve(path.join(ROOT, 'src/bongos/routes/core-update.js'));
406
+ const realAuth = r2(authPath);
407
+ const savedBuilder = realAuth.requireBuilder;
408
+ const savedPerm = realAuth.requirePermission;
409
+ realAuth.requireBuilder = (_req, _res, next) => next();
410
+ realAuth.requirePermission = () => (_req, _res, next) => next();
411
+ delete r2.cache[routePath];
412
+ let seen;
413
+ const express = r2('express');
414
+ const app = express();
415
+ app.use(r2(routePath)({ coreUpdate: async (a) => { seen = a; return { reading: 'ok', behind: true, channel: a.channel }; }, selfChannel: async () => 'minor' }));
416
+ const server = app.listen(0);
417
+ await new Promise((res) => server.once('listening', res));
418
+ try {
419
+ const res = await fetch(`http://127.0.0.1:${server.address().port}/core-update`);
420
+ assert.equal(res.status, 200);
421
+ assert.equal(seen.channel, 'minor', 'the rule set on /deploy, not the platform default');
422
+ assert.equal((await res.json()).update.channel_source, 'deploy');
423
+ } finally {
424
+ server.close();
425
+ realAuth.requireBuilder = savedBuilder;
426
+ realAuth.requirePermission = savedPerm;
427
+ delete r2.cache[routePath];
428
+ }
429
+ });
@@ -866,10 +866,25 @@ await t('a box that reached the target by another road supersedes the failure',
866
866
 
867
867
  await t('AN UNREADABLE REGISTRY LEAVES A FAILURE STANDING — not knowing is not evidence of recovery', () => {
868
868
  const now = { action: 'core-upgrade', state: 'error', target_version: '1.19.949', resolved_at: new Date().toISOString(), last_error: 'x' };
869
- const p = CU.previewFromSnapshot(ACTIVE, { ...SNAP, recommended: null }, null, now);
869
+ // An unreadable registry is what the snapshot writer stores when `npm view` fails: an
870
+ // EMPTY available list, and so no recommendation (provision-core-upgrade.js snapshotCoreVersions).
871
+ const p = CU.previewFromSnapshot(ACTIVE, { ...SNAP, available: [], recommended: null }, null, now);
870
872
  assert.equal(p.last_move.superseded, false, 'the direction to fail in is showing an old failure, never hiding a live one');
871
873
  });
872
874
 
875
+ await t('an UP-TO-DATE box supersedes an old failure: a read registry with nothing on offer is not "unknown" (task 1004534)', () => {
876
+ // The live shape, 2026-10-02: cloudbongos served 1.21.5, the registry answered (1168
877
+ // versions), nothing newer was within its rule, and the page still led with a day-old
878
+ // "waiting on a decision" about 1.20.73. recommended=null was being read as "the
879
+ // registry could not be read", so the failure could never become history.
880
+ const old = { action: 'core-upgrade', state: 'error', target_version: '1.20.73', resolved_at: new Date().toISOString(), last_error: 'x', failure_class: 'needs_decision', failure_reason: 'outside_channel' };
881
+ const p = CU.previewFromSnapshot(ACTIVE, { served_version: '1.21.5', available: ['1.20.73', '1.21.5'], recommended: null, observed_at: new Date().toISOString() }, null, old);
882
+ assert.equal(p.behind, false);
883
+ assert.equal(p.last_move.superseded, true, 'the box is current; a failure about another version is history');
884
+ assert.equal(CU.lastMoveSuperseded('1.20.73', '1.21.5', null, true), true);
885
+ assert.equal(CU.lastMoveSuperseded('1.20.73', '1.21.5', null, false), false, 'an unread registry still leaves it standing');
886
+ });
887
+
873
888
  await t('lastMoveSuperseded computes no version ORDERING — only identity', () => {
874
889
  // The page's standing rule is that no version comparison happens outside the runner's
875
890
  // channel resolution. This helper answers "is this news" without ever asking which of
@@ -885,6 +900,21 @@ await t('lastMoveSuperseded computes no version ORDERING — only identity', ()
885
900
  assert.equal(CU.lastMoveSuperseded('1.19.921', '1.19.900', '1.19.949'), true);
886
901
  });
887
902
 
903
+ console.log('\nthe platform\'s own rule, for Settings and the banner (task 1004535):');
904
+
905
+ await t('selfUpdateChannel reads the CONTROL-PLANE row\'s rule', async () => {
906
+ const sqls = [];
907
+ const db = { query: async (sql) => { sqls.push(sql); return { rows: [{ update_channel: 'minor' }] }; } };
908
+ assert.equal(await CU.selfUpdateChannel(db), 'minor');
909
+ assert.match(sqls[0], /hosting_shape = 'control-plane'/, 'this hall\'s own row, never a tenant\'s');
910
+ });
911
+
912
+ await t('selfUpdateChannel answers null with no row, an unknown value, or a failed read', async () => {
913
+ assert.equal(await CU.selfUpdateChannel({ query: async () => ({ rows: [] }) }), null);
914
+ assert.equal(await CU.selfUpdateChannel({ query: async () => ({ rows: [{ update_channel: 'nightly' }] }) }), null);
915
+ assert.equal(await CU.selfUpdateChannel({ query: async () => { throw new Error('relation does not exist'); } }), null);
916
+ });
917
+
888
918
  console.log('\nthe project\'s update rule (task 1004445):');
889
919
 
890
920
  const channelHandler = (inst, calls) => updateChannelHandlerFor({
@@ -232,6 +232,26 @@ await t('the channel refusal names a step that EXISTS on /deploy, never "change
232
232
  assert.match(major, /upgrading-the-core\.md/);
233
233
  });
234
234
 
235
+ await t('a target the box ALREADY SERVES is not a channel refusal — the move is a no-op (task 1004534)', () => {
236
+ // 2026-10-01 16:32: the sweep and the door both went for 1.20.73; the sweep got there
237
+ // first, and the door's press was refused as "outside this project's 'minor' update
238
+ // channel … across a major version or onto a prerelease" — false on every count.
239
+ const channelAllows = require('../scripts/gds/update-channel.js').channelAllows;
240
+ const compare = require('../scripts/gds/update-channel.js').compareSemver;
241
+ assert.equal(R.targetFault('1.20.73', ['1.20.72', '1.20.73'], '1.20.73', 'minor', channelAllows, compare), null,
242
+ 'no fault: the runner\'s own already-serving check answers it as a no-op');
243
+ });
244
+
245
+ await t('a target BELOW what the box serves says so, and is never called outside the update rule (task 1004534)', () => {
246
+ const channelAllows = require('../scripts/gds/update-channel.js').channelAllows;
247
+ const compare = require('../scripts/gds/update-channel.js').compareSemver;
248
+ const f = R.targetFault('1.20.73', ['1.20.73', '1.21.5'], '1.21.5', 'minor', channelAllows, compare);
249
+ assert.ok(f, 'still refused — a move never goes backwards on its own');
250
+ assert.doesNotMatch(f, /update channel|major version|prerelease/, 'none of the channel sentence is true here');
251
+ assert.match(f, /already runs core 1\.21\.5/);
252
+ assert.equal(require('../scripts/gds/upgrade-outcome.js').runnerReason(f), 'downgrade');
253
+ });
254
+
235
255
  await t('a registry that could not be read is refused DIFFERENTLY from a version that does not exist', () => {
236
256
  const empty = R.targetFault('1.19.760', [], '1.19.758', 'patch', () => true);
237
257
  assert.match(empty, /could not read the published core versions/);
@@ -369,7 +369,16 @@ test('the module doc still records where the 2179 density pass landed', () => {
369
369
  // /thinking left this list in task 1004327 (owner decision 2026-09-27): it sits
370
370
  // in the hall chrome now, with the shell's full-screen switch to hide it. The
371
371
  // project sky (/goal-map) followed in task 1004430, so the list is empty again.
372
- const STANDALONE_EXEMPT = {};
372
+ // THE GOVERNOR CITY's home (task 1004524, goal 1000125) is on it now, for the
373
+ // reason the sky first was: the task and its reference design
374
+ // (docs/design/mocks/governor-city/Street.dc.html, the home view) say nothing
375
+ // else is on the page but the wordmark, a sun/moon switch, start governing and
376
+ // one line. It still loads style.css (the hall's tokens, so no private palette:
377
+ // city.css spends var(--bg), var(--ink), var(--accent) …), and reads and writes
378
+ // the hall's own 'hall-theme' preference. Whether it should join the chrome
379
+ // behind the full-screen switch, as the sky did in task 1004327, is the
380
+ // owner's call, named in that task's ship notes.
381
+ const STANDALONE_EXEMPT = { 'city.html': 'the governor city home: nothing on the page but the prototype home (task 1004524)' };
373
382
 
374
383
  test('every hall page loads style.css and shell.js', () => {
375
384
  const missing = [];