@bongos/core 1.19.972 → 1.19.974

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 (59) hide show
  1. package/.bongos-core.json +74 -64
  2. package/docs/adr/0111-instance-hosting-provisioning-module.md +1 -0
  3. package/docs/adr/0323-hosting-is-three-shapes-and-we-are-not-the-landlord.md +1 -1
  4. package/docs/adr/0325-the-managed-fleet-control-plane-is-the-hub-we-already-run.md +1 -1
  5. package/docs/adr/0327-a-cloud-host-runs-on-the-owners-account-and-the-key-is-borrowed.md +107 -0
  6. package/docs/adr/README.md +1 -0
  7. package/docs/architecture.md +6 -6
  8. package/docs/branding-contract.md +1 -1
  9. package/docs/canonical-permissions.md +24 -24
  10. package/docs/copy-inventory.md +45 -38
  11. package/docs/copy-registry.json +130 -67
  12. package/docs/design/projects-hub-direction-v2.md +1 -1
  13. package/docs/design/vanilla-hall-ui-redesign-scope.md +2 -2
  14. package/docs/file-map.md +7 -7
  15. package/docs/handoff-template.md +1 -1
  16. package/docs/module-api-changelog.md +4 -0
  17. package/docs/modules-contract.md +1 -1
  18. package/docs/onboarding/drachmae-and-karma.md +2 -2
  19. package/docs/packs/engineer.md +5 -5
  20. package/docs/packs/ideator.md +1 -1
  21. package/docs/recipes/README.md +1 -1
  22. package/docs/recipes/audit-replay.md +2 -2
  23. package/docs/recipes/gds-db-backup.md +3 -3
  24. package/docs/recipes/managed-settings-remote-control.md +9 -9
  25. package/docs/recipes/memory-sync-conflicts.md +2 -2
  26. package/docs/recipes/ops-gotchas.md +24 -5
  27. package/docs/recipes/overnight-builder.md +3 -3
  28. package/docs/recipes/parallel-goal-waves.md +3 -3
  29. package/docs/recipes/reranking-a-goals-board.md +2 -2
  30. package/docs/recipes/search-index-ingestion.md +1 -1
  31. package/docs/recipes/ship-and-regrade.md +2 -2
  32. package/docs/recipes/staging-environment.md +1 -1
  33. package/docs/recipes/windows-builders.md +1 -1
  34. package/modules/hall-ui/public/deploy.html +10 -5
  35. package/modules/hall-ui/public/deploy.js +199 -110
  36. package/modules/hall-ui/public/project-settings.css +36 -0
  37. package/modules/provisioning/core-upgrade.js +61 -2
  38. package/modules/public-landing/public/contact.html +1 -1
  39. package/modules/public-landing/public/index.html +1 -1
  40. package/modules/public-landing/public/privacy.html +1 -1
  41. package/modules/public-landing/public/projects.html +1 -1
  42. package/modules/public-landing/public/terms.html +1 -1
  43. package/package-lock.json +2 -2
  44. package/package.json +1 -1
  45. package/scripts/gds/api-alias-caller-scan.js +53 -9
  46. package/scripts/gds/cloudbongos-token-isolation.conf +33 -0
  47. package/scripts/gds/provision-core-upgrade.js +132 -1
  48. package/scripts/gds/run-unit-tests.js +14 -9
  49. package/scripts/hall-preview/server.js +5 -0
  50. package/src/module-api.js +1 -1
  51. package/tests/api_alias_caller_ratchet.mjs +74 -2
  52. package/tests/core_upgrade_door.mjs +81 -0
  53. package/tests/core_upgrade_runner.mjs +132 -0
  54. package/tests/deploy_page_projects.mjs +234 -79
  55. package/tests/idea_spark_hall.mjs +101 -12
  56. package/tests/known_failing_ratchet.mjs +19 -9
  57. package/tests/landing_legal_pages.mjs +5 -1
  58. package/tests/projects_hub_pre_uat.mjs +12 -6
  59. package/tests/served_paths_shipped.mjs +36 -2
@@ -1,5 +1,5 @@
1
1
  // deploy.js — the owner's deploy door (task 1003159, ADR 0293; widened to the
2
- // hosted projects by task 1004046).
2
+ // hosted projects by task 1004046, narrowed back to ONE project by task 1004171).
3
3
  //
4
4
  // WHAT THIS PAGE IS FOR. The owner, twice in a month: "I don't think I can make anything
5
5
  // live", then "this is above my paygrade." Moving a core pin is gate 3 of the release
@@ -9,18 +9,27 @@
9
9
  // `sudo -u <app user>` over ssh by design. So the one gate that is the owner's to hold was the
10
10
  // one gate with no instrument. This page is the instrument.
11
11
  //
12
- // WHY THE PROJECTS ARE HERE AND NOT IN THEIR OWN HALLS. The obvious shape — each owner
13
- // pressing Upgrade in their own project's hall — is not buildable: the upgrade route and
14
- // the intent queue belong to the PROVISIONING module, and a hosted project does not run
15
- // it. Probed 2026-09-18: /api/bongos/provisioning/health answers 200 here and 404 on
16
- // every tenant. The control plane is the only place that can act on them, so this is
17
- // where the button lives. Their own halls get the banner instead (task 1004045), which
18
- // each instance derives for itself from the registry and needs nothing from us.
12
+ // ONE PAGE, ONE PROJECT (task 1004171). The owner, 2026-09-22: "Should not show core pin
13
+ // upgrades for all of the projects", and then the rule — "it should only show the project
14
+ // of that builders hall", "every project has the option to upgrade its version of bongos
15
+ // including bongos itself."
19
16
  //
20
- // Nothing about the ENGINE had to change for this. UPGRADE_SHAPES already admitted
21
- // 'co-tenant' and 'standalone' alongside 'control-plane', coreUpgradeInstance already
22
- // resolved each shape's own checkout and built that box's own health and version URLs,
23
- // and the preview route already took any instance id. Only this page was single-row.
17
+ // So the default view is the hall's OWN project and nothing else. On the control plane
18
+ // that is the self row. What this replaces is task 1004046's shape, which listed every
19
+ // live instance here because a hosted project cannot act on itself: the upgrade route and
20
+ // the intent queue belong to the PROVISIONING module, and a tenant does not run it
21
+ // (probed 2026-09-18: /api/bongos/provisioning/health answers 200 here, 404 on every
22
+ // tenant). That reasoning still holds — the ACT still has to happen on the control plane —
23
+ // but it does not license a page in one project's hall that offers to restart five other
24
+ // projects, two of them belonging to builders who never asked. On 2026-09-22 this page
25
+ // listed six boxes and the owner wanted one.
26
+ //
27
+ // WHICH IS WHY ?project= EXISTS, and it is not a fleet browser. A hosted project's hall
28
+ // hands its owner off to this page scoped to THAT project (task 1004174), because the
29
+ // hand-off is how a tenant reaches a working control without the tenant's web tier
30
+ // gaining any capability — the split ADR 0293 D1 and ADR 0043 both turn on. Scoped or
31
+ // not, the page renders exactly one row, and authority is still resolved server-side per
32
+ // request: a slug in a query string decides what is LOOKED at, never what is allowed.
24
33
  //
25
34
  // THE RULE THAT SHAPES EVERY LINE HERE: this page KNOWS NOTHING. Every version on it is a
26
35
  // reading the control-plane runner took on the box, carried through the server with the
@@ -34,10 +43,11 @@
34
43
  // already guards the bad release; this guards the wrong button. Step one shows what would
35
44
  // move; step two names the version it saw and sends that. The confirm button is built
36
45
  // FROM the previewed version, so a release that lands between the two steps cannot
37
- // silently change what the press does. With several boxes on one page the step is held
38
- // PER BOX, and each button's handler CLOSES OVER the preview object it was built from —
39
- // so the version sent is the one that box displayed, and a repaint cannot swap it. A
40
- // page-wide "currently previewing" would be a wrong-box press waiting to happen.
46
+ // silently change what the press does. The step is still keyed BY INSTANCE rather than
47
+ // held page-wide, and each button's handler still CLOSES OVER the preview object it was
48
+ // built from — so the version sent is the one that was displayed and a repaint cannot
49
+ // swap it. That survives the narrowing to one row on purpose: the identity being carried
50
+ // is the box's, and keying it to the box is what made a wrong-box press unrepresentable.
41
51
  (() => {
42
52
  'use strict';
43
53
 
@@ -106,7 +116,14 @@
106
116
  if (secs < 90) return `${secs}s ago`;
107
117
  const mins = Math.round(secs / 60);
108
118
  if (mins < 90) return `${mins} min ago`;
109
- return `${Math.round(mins / 60)}h ago`;
119
+ const hours = Math.round(mins / 60);
120
+ // PAST TWO DAYS, HOURS STOP BEING A DURATION AND BECOME A NUMBER (task 1004172).
121
+ // The failure that motivated dating these at all rendered "55h ago", which is
122
+ // arithmetic the reader has to do before they can tell whether it is news. Days are
123
+ // the unit a two-day-old attempt is actually read in.
124
+ if (hours < 48) return `${hours}h ago`;
125
+ const days = Math.round(hours / 24);
126
+ return `${days} day${days === 1 ? '' : 's'} ago`;
110
127
  }
111
128
 
112
129
  function versionHtml(v) {
@@ -147,15 +164,47 @@
147
164
  // AND WHAT IT SEES AFTERWARDS. Three outcomes, and the failure is the one that had no
148
165
  // surface at all before this: an errored intent is not an OPEN intent, so the payload
149
166
  // after a rolled-back upgrade used to be identical to the payload before it.
167
+ //
168
+ // A FAILURE IS DATED, AND STOPS BEING THE HEADLINE ONCE THE BOX HAS MOVED ON
169
+ // (task 1004172). What this block used to render, undated, at the top of the page:
170
+ // "The move to core 1.19.921 did not land — this project was put back on 1.19.969 and
171
+ // is running." The attempt was two days old, the box had reached 1.19.969 by another
172
+ // road, and the cause it named had been fixed and released in between. Three defects in
173
+ // one sentence: no date, a PAST-tense claim ("was put back on") built from a PRESENT
174
+ // reading, and no notion that a box can recover.
175
+ //
176
+ // The rollback clause is present-tense now, which is the whole fix for the false half:
177
+ // "is still running core X" is a statement about the reading in hand, and true whenever
178
+ // the reading is. The page does not decide whether a failure is history — `superseded`
179
+ // is the server's answer (modules/provisioning/core-upgrade.js), because this page
180
+ // compares no versions.
150
181
  function settledHtml(p) {
151
182
  const m = p.last_move;
152
183
  if (!m) return '';
184
+ const when = m.at ? ` ${escapeHtml(ago(m.at))}` : '';
185
+ // The runner's own words, which name a fix addressed to someone holding a shell. Kept
186
+ // verbatim and kept CLOSED: the owner cannot run `sudo -u`, so 30 lines of it (the
187
+ // dirty-tree refusal prints a whole `git status`) is not what a deploy page leads on.
188
+ const detail = m.error
189
+ ? `<details class="dp-settled__detail"><summary>What the upgrade said</summary>
190
+ <pre class="dp-preflight__error">${escapeHtml(String(m.error))}</pre></details>`
191
+ : '';
192
+ if (m.state === 'error' && m.superseded) {
193
+ return `
194
+ <div class="dp-settled dp-settled--past">
195
+ <p class="ov-row__sub"><strong>An earlier attempt did not land.</strong> A move to core
196
+ ${escapeHtml(String(m.target_version))} failed${when} — this project has moved on since,
197
+ and is serving ${escapeHtml(String(p.served_version))} now. Whatever stopped that one
198
+ may or may not still apply; the check above is the live answer.</p>
199
+ ${detail}
200
+ </div>`;
201
+ }
153
202
  if (m.state === 'error') {
154
203
  return `
155
204
  <div class="dp-settled dp-settled--failed">
156
205
  <p class="ov-row__sub"><strong>The move to core ${escapeHtml(String(m.target_version))} did not land</strong>
157
- — this project was put back on ${escapeHtml(String(p.served_version))} and is running.</p>
158
- ${m.error ? `<pre class="dp-preflight__error">${escapeHtml(String(m.error))}</pre>` : ''}
206
+ — attempted${when}. This project is still running core ${escapeHtml(String(p.served_version))}.</p>
207
+ ${detail}
159
208
  </div>`;
160
209
  }
161
210
  if (m.state === 'done' && m.target_version === p.served_version) {
@@ -193,7 +242,11 @@
193
242
  function preflightHtml(p) {
194
243
  const pf = p.preflight;
195
244
  if (checking.has(String(p.instance_id)) && !(pf && pf.target === p.recommended_version)) {
196
- return `<p class="ov-row__sub dp-preflight dp-preflight--checking">Checking what this would do…</p>`;
245
+ // NAME WHAT THE WAIT IS FOR (task 1004173). The runner picks this up on its own
246
+ // ~2-minute tick and then reads the box, so a bare spinner leaves the owner watching
247
+ // an unexplained pause on the one page where a pause could mean a live box is busy.
248
+ return `<p class="ov-row__sub dp-preflight dp-preflight--checking">Checking what this would do, and what it would bring in.
249
+ The answer comes from the machine itself, so it can take a couple of minutes.</p>`;
197
250
  }
198
251
  // No verdict is its own state, and it is NOT "fine". A silent gap here is how the
199
252
  // page used to promise a preview it never performed.
@@ -253,9 +306,7 @@
253
306
  if (steps.get(p.instance_id) !== 'confirm') break; // the row was closed; stop.
254
307
  try {
255
308
  await refreshOne(p, { keepStep: true });
256
- const now = (view.self && String(view.self.instance_id) === id)
257
- ? view.self
258
- : view.boxes.find((b) => String(b.instance_id) === id);
309
+ const now = view.box && String(view.box.instance_id) === id ? view.box : null;
259
310
  if (now && now.preflight && now.preflight.target === now.recommended_version) break;
260
311
  } catch (e) { break; }
261
312
  }
@@ -264,10 +315,51 @@
264
315
  repaint();
265
316
  }
266
317
 
318
+ // WHAT THE MOVE WOULD CARRY (task 1004173). The owner: "I don't actually see what would
319
+ // change when I hit the button." The pre-flight below answers whether the machinery will
320
+ // allow the move; this answers what taking it buys, in the words the ships themselves
321
+ // wrote — which are already addressed to this reader, because ship.js demands a summary
322
+ // an owner can read and posts it to Discord.
323
+ //
324
+ // AN EMPTY LIST IS NEVER RENDERED AS "NOTHING CHANGED". A range the runner could not
325
+ // resolve — no core history on the machine, a release it cannot find — comes back as an
326
+ // error and says so. "Nothing changed" over ninety-one releases is the one wrong
327
+ // impression this section could give, and it is the shape a silent failure takes.
328
+ function changesHtml(p) {
329
+ const pf = p.preflight;
330
+ const c = pf && pf.changes;
331
+ if (!c) return '';
332
+ if (c.error) {
333
+ return `<div class="dp-changes dp-changes--unknown">
334
+ <p class="ov-row__sub">Could not read what this would bring in: ${escapeHtml(String(c.error))}.
335
+ The check below still applies.</p>
336
+ </div>`;
337
+ }
338
+ const entries = Array.isArray(c.entries) ? c.entries : [];
339
+ if (!entries.length) {
340
+ return `<div class="dp-changes dp-changes--unknown">
341
+ <p class="ov-row__sub">No shipped work is recorded between core ${escapeHtml(String(c.from))} and
342
+ ${escapeHtml(String(c.to))}.</p>
343
+ </div>`;
344
+ }
345
+ const items = entries.map((e) => `<li>${escapeHtml(String(e.text))}</li>`).join('');
346
+ const more = c.more > 0
347
+ ? `<p class="ov-row__sub dp-changes__more">…and ${escapeHtml(String(c.more))} more, not listed.</p>`
348
+ : '';
349
+ return `<div class="dp-changes">
350
+ <p class="ov-row__sub"><strong>What this brings in</strong> — ${escapeHtml(String(entries.length))}
351
+ ${entries.length === 1 ? 'piece' : 'pieces'} of work landed between core ${escapeHtml(String(c.from))}
352
+ and ${escapeHtml(String(c.to))}${c.more > 0 ? ', newest first' : ''}:</p>
353
+ <ul class="dp-changes__list">${items}</ul>
354
+ ${more}
355
+ </div>`;
356
+ }
357
+
267
358
  function stepTwoHtml(p, isSelf) {
268
359
  const id = escapeHtml(String(p.instance_id));
269
360
  return `
270
361
  <div class="ps-controls">
362
+ ${changesHtml(p)}
271
363
  ${preflightHtml(p)}
272
364
  <p class="ov-row__sub">This restarts ${isSelf ? 'the platform' : escapeHtml(String(p.slug))} onto core
273
365
  <strong>${escapeHtml(String(p.recommended_version))}</strong>. It waits for the box to answer and
@@ -315,13 +407,13 @@
315
407
  </div></li>`;
316
408
  }
317
409
 
318
- // A project whose preview could not be read at all. It is LISTED rather than dropped:
319
- // a box silently missing from a deploy page reads as a box that does not need
320
- // deploying, which is the one wrong impression this page must never give.
321
- function unreadableHtml(inst, message) {
410
+ // The one project's preview could not be read. It is still RENDERED, named, rather
411
+ // than leaving the page blank: a box missing from a deploy page reads as a box that
412
+ // does not need deploying, which is the one wrong impression this page must never give.
413
+ function unreadableHtml(name, message) {
322
414
  return `
323
415
  <li class="ov-row"><div class="ov-row__main">
324
- <span class="ov-row__title">${escapeHtml(String(inst.slug))}</span>
416
+ <span class="ov-row__title">${escapeHtml(String(name))}</span>
325
417
  <p class="ps-invalid">Could not read this project's deploy state: ${escapeHtml(message)}</p>
326
418
  </div></li>`;
327
419
  }
@@ -338,33 +430,30 @@
338
430
  if (confirm) confirm.addEventListener('click', () => move(p));
339
431
  }
340
432
 
341
- // What the page is currently showing: the self row (or null), the readable project
342
- // previews, and the ones that would not read. Held so a repaint after a click does not
343
- // have to re-fetch everything.
344
- let view = { self: null, boxes: [], unreadable: [], notEnrolled: null };
433
+ // What the page is currently showing: the ONE box this hall is about, whether that box
434
+ // is this platform itself, and — instead of a box — the reason there isn't one. Held so
435
+ // a repaint after a click does not have to re-fetch.
436
+ let view = { box: null, isSelf: true, name: 'This platform', notEnrolled: null, unreadable: null };
345
437
 
346
438
  function repaint() {
347
- const items = [];
348
- if (view.self) items.push(boxHtml(view.self, true));
439
+ let item;
440
+ if (view.box) item = boxHtml(view.box, view.isSelf);
349
441
  else if (view.notEnrolled) {
350
442
  // The server's own sentence names the one command that fixes it, which is why it
351
443
  // is shown rather than a generic failure line.
352
- items.push(`<li class="ov-row"><div class="ov-row__main">
353
- <span class="ov-row__title">This platform</span>
444
+ item = `<li class="ov-row"><div class="ov-row__main">
445
+ <span class="ov-row__title">${escapeHtml(String(view.name))}</span>
354
446
  <p class="ps-invalid">${escapeHtml(view.notEnrolled)}</p>
355
- </div></li>`);
356
- }
357
- for (const p of view.boxes) items.push(boxHtml(p, false));
358
- for (const u of view.unreadable) items.push(unreadableHtml(u.inst, u.message));
359
- if (!items.length) {
360
- items.push(`<li class="ov-row"><div class="ov-row__main">
447
+ </div></li>`;
448
+ } else if (view.unreadable) item = unreadableHtml(view.name, view.unreadable);
449
+ else {
450
+ item = `<li class="ov-row"><div class="ov-row__main">
361
451
  <p class="ov-row__sub">No running box to deploy.</p>
362
- </div></li>`);
452
+ </div></li>`;
363
453
  }
364
- $('dp-panel').innerHTML = `<ul class="ov-lines">${items.join('')}</ul>
454
+ $('dp-panel').innerHTML = `<ul class="ov-lines">${item}</ul>
365
455
  <p class="ps-prov">reads the control plane's own observation · ADR 0293 · every move is recorded with who asked</p>`;
366
- if (view.self) wire(view.self);
367
- for (const p of view.boxes) wire(p);
456
+ if (view.box) wire(view.box);
368
457
  }
369
458
 
370
459
  async function move(p) {
@@ -400,9 +489,7 @@
400
489
  // keepStep, for the reason the pre-flight poll documents: a refresh that resets
401
490
  // the row would fight the state this loop is painting, every interval.
402
491
  await refreshOne(p, { keepStep: true });
403
- now = (view.self && String(view.self.instance_id) === id)
404
- ? view.self
405
- : view.boxes.find((b) => String(b.instance_id) === id);
492
+ now = view.box && String(view.box.instance_id) === id ? view.box : null;
406
493
  } catch (e) { break; }
407
494
  if (!now) break;
408
495
  // Settled two ways, and BOTH need the intent to be closed: served_version can
@@ -419,21 +506,18 @@
419
506
  if (!settled) toast(`Still working on core ${to}. It keeps going without this page — reload to see where it got to.`);
420
507
  }
421
508
 
422
- // Re-read ONLY the box that just changed. The full load() fans out one probe per
423
- // hosted project, and firing all of them to refresh one row is cache invalidation
424
- // far wider than the change — it also re-probes boxes the owner never touched. A
425
- // failure here falls back to the full read, because a stale row on a deploy page is
426
- // the one thing worse than an extra request.
509
+ // Re-read the box. Its own endpoint, never the full load(): the self row and a scoped
510
+ // project are read from different places, and load() also re-resolves the scope. A
511
+ // failure here falls back to the full read, because a stale row on a deploy page is the
512
+ // one thing worse than an extra request.
427
513
  // `keepStep` is for the pre-flight poll (task 1004121): a refresh AFTER a move should
428
514
  // return the row to step one, but a refresh DURING the wait must not — it would close
429
515
  // the panel the owner is reading, under them, every two seconds.
430
516
  async function refreshOne(p, { keepStep = false } = {}) {
431
517
  const id = String(p.instance_id);
432
- const isSelf = view.self && String(view.self.instance_id) === id;
433
518
  try {
434
- const d = await fetchJson(isSelf ? '/provisioning/control-plane' : `/provisioning/instances/${id}/core-upgrade`);
435
- if (isSelf) view.self = d.preview;
436
- else view.boxes = view.boxes.map((b) => (String(b.instance_id) === id ? d.preview : b));
519
+ const d = await fetchJson(view.isSelf ? '/provisioning/control-plane' : `/provisioning/instances/${id}/core-upgrade`);
520
+ view.box = d.preview;
437
521
  if (!keepStep) steps.set(p.instance_id, 'preview');
438
522
  repaint();
439
523
  } catch (e) {
@@ -442,68 +526,73 @@
442
526
  }
443
527
  }
444
528
 
445
- // The projects this control plane hosts. `/provisioning/fleet` is the whole roster and
446
- // needs its own atom; a holder of core.pin.move who lacks it still gets their OWN
447
- // projects rather than an error, because the pin move itself is gated server-side per
448
- // instance either way — this choice only decides what the page can LIST.
449
- async function hostedProjects() {
529
+ // Which project is this page about? The hall's own unless a scope was asked for.
530
+ //
531
+ // READ OFF `location`, DEFENSIVELY. The page runs in a vm harness whose `window.location`
532
+ // is a stub with no `search`, and a page that threw there would be untestable — but the
533
+ // deeper reason is that a missing scope is the ORDINARY case and must cost nothing.
534
+ function scopeSlug() {
535
+ try {
536
+ const q = String((window.location && window.location.search) || '');
537
+ const m = /[?&]project=([^&#]+)/.exec(q);
538
+ return m ? decodeURIComponent(m[1]) : null;
539
+ } catch (e) { return null; }
540
+ }
541
+
542
+ // Resolve a scoped slug to the instance the control plane knows by that name.
543
+ //
544
+ // `/provisioning/fleet` is the whole roster and needs its own atom; a holder of
545
+ // core.pin.move who lacks it falls back to their OWN projects, which is the set a
546
+ // project's owner is arriving from anyway. This is a LOOKUP, not a listing: exactly one
547
+ // row survives it, and the pin move is gated server-side per instance regardless — the
548
+ // slug decides what is looked at, never what is allowed.
549
+ async function findBySlug(slug) {
550
+ let rows = [];
450
551
  try {
451
- const d = await fetchJson('/provisioning/fleet');
452
- return d.instances || [];
552
+ rows = (await fetchJson('/provisioning/fleet')).instances || [];
453
553
  } catch (e) {
454
- if (e && e.kind === '403') {
455
- const d = await fetchJson('/provisioning/instances');
456
- return d.instances || [];
457
- }
458
- throw e;
554
+ if (e && e.kind !== '403') throw e;
555
+ rows = (await fetchJson('/provisioning/instances')).instances || [];
459
556
  }
557
+ return rows.find((i) => i && String(i.slug) === slug) || null;
460
558
  }
461
559
 
462
- // Only a RUNNING box can have its pin moved, and the runner refuses every other shape.
463
- // Filtering here keeps torn-down rows — there are far more of them than live ones — off
464
- // a page whose whole job is naming what is live.
465
- const UPGRADEABLE = new Set(['standalone', 'co-tenant']);
466
- const isLive = (i) => i && i.status === 'active' && UPGRADEABLE.has(i.hosting_shape);
467
-
468
560
  async function load() {
561
+ const slug = scopeSlug();
469
562
  try {
470
- let self = null;
471
- let notEnrolled = null;
472
- try {
473
- const d = await fetchJson('/provisioning/control-plane');
474
- self = d.preview;
475
- } catch (e) {
476
- if (e && e.kind === '401') return;
477
- if (e && e.kind === '403') throw e;
478
- notEnrolled = e.message || 'This platform has no deploy door yet.';
479
- }
480
-
481
- const live = (await hostedProjects()).filter(isLive);
482
- // One preview per project, in parallel and INDEPENDENTLY: one project that cannot
483
- // be read must not blank the page for the others. The request count scales with the
484
- // fleet, which is deliberate and bounded — each is a cached server-side reading, not
485
- // a live probe of the box, and this page exists precisely to show every box's state
486
- // at once. It is re-read on OPEN only; a move refreshes just its own row.
487
- const settled = await Promise.all(live.map(async (inst) => {
563
+ if (!slug) {
564
+ // THE HALL'S OWN PROJECT. On the control plane that is the self row, which is why
565
+ // this endpoint exists: the row is created once at install time and its id differs
566
+ // per install, so the page cannot be expected to know it.
567
+ view = { box: null, isSelf: true, name: 'This platform', notEnrolled: null, unreadable: null };
488
568
  try {
489
- const d = await fetchJson(`/provisioning/instances/${inst.id}/core-upgrade`);
490
- return { ok: true, preview: d.preview };
569
+ view.box = (await fetchJson('/provisioning/control-plane')).preview;
570
+ view.name = (view.box && view.box.slug) || 'This platform';
491
571
  } catch (e) {
492
- if (e && e.kind === '401') return { ok: false, inst, message: 'not signed in' };
493
- return { ok: false, inst, message: (e && e.message) || 'unknown error' };
572
+ if (e && e.kind === '401') return;
573
+ if (e && e.kind === '403') throw e;
574
+ view.notEnrolled = e.message || 'This platform has no deploy door yet.';
494
575
  }
495
- }));
496
-
497
- view = {
498
- self,
499
- notEnrolled,
500
- boxes: settled.filter((s) => s.ok).map((s) => s.preview),
501
- unreadable: settled.filter((s) => !s.ok).map((s) => ({ inst: s.inst, message: s.message })),
502
- };
503
- const n = view.boxes.length + view.unreadable.length;
504
- $('dp-sub').textContent = n
505
- ? `This platform and ${n} hosted project${n === 1 ? '' : 's'}`
506
- : 'This platform';
576
+ $('dp-sub').textContent = (view.box && view.box.slug) || 'This platform';
577
+ } else {
578
+ view = { box: null, isSelf: false, name: slug, notEnrolled: null, unreadable: null };
579
+ const inst = await findBySlug(slug);
580
+ if (!inst) {
581
+ // NAMED, NOT BLANK. A project the control plane cannot find is a wrong link or a
582
+ // torn-down project, and both are worth saying out loud — a deploy page that
583
+ // renders nothing reads as a project with nothing to deploy.
584
+ view.unreadable = 'no project by that name is hosted here.';
585
+ } else {
586
+ try {
587
+ view.box = (await fetchJson(`/provisioning/instances/${inst.id}/core-upgrade`)).preview;
588
+ } catch (e) {
589
+ if (e && e.kind === '401') return;
590
+ if (e && e.kind === '403') throw e;
591
+ view.unreadable = (e && e.message) || 'unknown error';
592
+ }
593
+ }
594
+ $('dp-sub').textContent = slug;
595
+ }
507
596
  $('dp-body').hidden = false;
508
597
  repaint();
509
598
  } catch (e) {
@@ -128,6 +128,23 @@
128
128
  overflow-x: auto;
129
129
  }
130
130
 
131
+ /* What the move would BRING IN (task 1004173) — the ships' own summaries between the two
132
+ releases. It sits above the pre-flight because it is the question the owner asked
133
+ ("I don't actually see what would change"); the verdict below it is the machinery's
134
+ answer to a different one. No mark of its own: this is the body of step two, not a
135
+ state, and a coloured bar here would compete with the three that DO mean something. */
136
+ .dp-changes { margin: 0 0 0.7rem; }
137
+ .dp-changes__list {
138
+ margin: 0.35rem 0 0;
139
+ padding-left: 1.1rem;
140
+ color: var(--ink-soft);
141
+ font-size: 0.86rem;
142
+ line-height: 1.55;
143
+ }
144
+ .dp-changes__list li { margin-bottom: 0.35rem; }
145
+ .dp-changes__more { color: var(--ink-soft); font-style: italic; }
146
+ .dp-changes--unknown { color: var(--ink-soft); }
147
+
131
148
  /* A move in flight, and how it ended (task 1004145). The owner pressed Upgrade and got
132
149
  a toast and then silence; these are the two states that silence was hiding. */
133
150
  .dp-moving {
@@ -143,3 +160,22 @@
143
160
  border-left: 3px solid var(--danger);
144
161
  padding-left: 0.7rem;
145
162
  }
163
+ /* A failure the box has already moved past (task 1004172). It keeps a mark, because it
164
+ is still a thing that happened and worth reading — but not the danger mark, which on
165
+ this page means "your project is in this state now". A two-day-old refusal wearing the
166
+ same red as a live one is how the owner read a solved problem as the current one. */
167
+ .dp-settled--past {
168
+ border-left: 3px solid var(--rule);
169
+ padding-left: 0.7rem;
170
+ color: var(--ink-soft);
171
+ }
172
+ /* The runner's own output, folded. It runs to thirty lines when the refusal is a dirty
173
+ tree (it prints the whole `git status`) and it addresses whoever holds a shell, which
174
+ the owner does not — so the sentence above it leads and this opens on request. */
175
+ .dp-settled__detail > summary {
176
+ cursor: pointer;
177
+ font-size: 0.82rem;
178
+ color: var(--ink-soft);
179
+ padding: 0.35rem 0;
180
+ min-height: 24px;
181
+ }
@@ -225,6 +225,60 @@ function makeCoreUpgradeHandler({ provisioning, pool, parseId, validateOrRespond
225
225
  };
226
226
  }
227
227
 
228
+ /**
229
+ * Has the box moved on from the last move this control would report? PURE (task 1004172).
230
+ *
231
+ * WHY THIS QUESTION EXISTS. On 2026-09-22 the deploy page's most prominent element was:
232
+ * "The move to core 1.19.921 did not land — this project was put back on 1.19.969 and is
233
+ * running", with no date on it. That move was attempted two days earlier, the box had
234
+ * since reached 1.19.969 by a different route, and the cause it named (`sudo` with no
235
+ * `-u`) had been fixed and released in 1.19.928. Every word of it was history rendered as
236
+ * the present, and the owner read it, correctly, as the page having no feedback to give.
237
+ *
238
+ * THE TEST IS THE ONE `preflight` ALREADY USES, and deliberately so: a verdict about
239
+ * 1.19.920 tells the owner nothing true about pressing a button that says 1.19.972, and
240
+ * neither does a failure about it. So a move is superseded when the box now SERVES what
241
+ * that move was reaching for — it got there, by whatever road — or when the move on offer
242
+ * is a different one entirely.
243
+ *
244
+ * NO VERSION ORDERING IS COMPUTED, here or anywhere else in this file. "Is 1.19.969 newer
245
+ * than 1.19.921" is a question this layer does not need to ask to answer "is this news",
246
+ * and not asking it is what keeps the one comparison in the system (the runner's channel
247
+ * resolution) the only one that can be wrong.
248
+ *
249
+ * An unknown `recommended` — the registry could not be read — leaves a failure STANDING.
250
+ * Not knowing what is on offer is not evidence that the box recovered, and the direction
251
+ * to fail in is showing an old failure, never hiding a live one.
252
+ */
253
+ function lastMoveSuperseded(target, served, recommended) {
254
+ if (!target) return false;
255
+ if (served && String(served) === String(target)) return true;
256
+ return !!(recommended && String(recommended) !== String(target));
257
+ }
258
+
259
+ /**
260
+ * The cached dry-run verdict, IF it is about the move now on offer. PURE (task 1004173).
261
+ *
262
+ * Two independent staleness questions, and they have different answers:
263
+ *
264
+ * • the VERDICT is about a target — a pass taken against 1.19.920 says nothing true
265
+ * about a button that reads 1.19.972, and a stale pass is worse than no answer
266
+ * because it reads as a guarantee. Withheld whole.
267
+ * • the CHANGE LIST is about a RANGE, so it also depends on where the box is starting
268
+ * from. Its `from` is the version that was live when the dry run ran; if the box has
269
+ * moved since, the list still describes a real stretch of history but not the one
270
+ * this move covers. Only the list is dropped — the verdict beside it is still true.
271
+ *
272
+ * Collapsing the two would mean either throwing away a good verdict or showing a range
273
+ * that starts somewhere the box no longer is.
274
+ */
275
+ function preflightForOffer(pf, served, recommended) {
276
+ if (!pf || !recommended || pf.target !== recommended) return null;
277
+ const ch = pf.changes;
278
+ if (!ch || !ch.from || String(ch.from) === String(served || '')) return pf;
279
+ return { ...pf, changes: null };
280
+ }
281
+
228
282
  /**
229
283
  * What the control shows BEFORE anything is pressed — step one of the two-step (D4).
230
284
  *
@@ -266,7 +320,7 @@ function previewFromSnapshot(inst, snap, openIntent, lastMove) {
266
320
  // Withheld unless it was taken against the move now on offer: a verdict about
267
321
  // 1.19.920 tells the owner nothing true about pressing a button that says 1.19.921,
268
322
  // and a stale pass is worse than no answer because it reads as a guarantee.
269
- preflight: pf && recommended && pf.target === recommended ? pf : null,
323
+ preflight: preflightForOffer(pf, served, recommended),
270
324
  preflight_at: pf && recommended && pf.target === recommended ? (snap.preflight_at || null) : null,
271
325
  open_intent: openIntent ? { action: openIntent.action, state: openIntent.state, target_version: openIntent.target_version || null } : null,
272
326
  // THE LAST MOVE, WHATEVER BECAME OF IT (task 1004145).
@@ -284,6 +338,11 @@ function previewFromSnapshot(inst, snap, openIntent, lastMove) {
284
338
  target_version: lastMove.target_version || null,
285
339
  state: lastMove.state,
286
340
  at: lastMove.resolved_at || lastMove.updated_at || null,
341
+ // Whether this is still news about the box's CURRENT state, or history the box has
342
+ // already moved past (task 1004172 — lastMoveSuperseded says why at length). The
343
+ // CLIENT must not work this out: the page's standing rule is that it compares no
344
+ // versions, and this is a version question wearing a different hat.
345
+ superseded: lastMoveSuperseded(lastMove.target_version, served, recommended),
287
346
  // Truncated because it reaches a DOM and upgrade.js's refusals can carry a whole
288
347
  // `git status` (the dirty-tree one lists every file).
289
348
  error: lastMove.last_error ? String(lastMove.last_error).slice(0, 600) : null,
@@ -356,5 +415,5 @@ function makeControlPlaneHandler({ provisioning, pool, log }) {
356
415
  };
357
416
  }
358
417
 
359
- module.exports = { CORE_UPGRADE_FROM, lastCoreMove, UPGRADE_SHAPES, VERSION_RE, isCoreVersion, coreUpgradeFault, enqueueCoreUpgrade, makeCoreUpgradeHandler, previewFromSnapshot, makeCoreUpgradePreviewHandler, makeControlPlaneHandler };
418
+ module.exports = { CORE_UPGRADE_FROM, lastCoreMove, lastMoveSuperseded, preflightForOffer, UPGRADE_SHAPES, VERSION_RE, isCoreVersion, coreUpgradeFault, enqueueCoreUpgrade, makeCoreUpgradeHandler, previewFromSnapshot, makeCoreUpgradePreviewHandler, makeControlPlaneHandler };
360
419
 
@@ -68,6 +68,6 @@
68
68
  </section>
69
69
  </main>
70
70
 
71
- <footer class="foot"><!--apex-only:start--><span>A non-profit, capture-resistant commons. </span><!--apex-only:end-->Built by agents, on Cloud&nbsp;Bongos. <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">Open source (AGPL-3.0)</a>.<nav class="footNav" aria-label="Site"><a href="/privacy">Privacy</a><a href="/terms">Terms</a><a href="/contact" aria-current="page">Contact</a></nav></footer>
71
+ <footer class="foot"><!--apex-only:start--><span>A non-profit, capture-resistant commons. </span><!--apex-only:end-->Built by agents, on Cloud&nbsp;Bongos. <a href="https://www.npmjs.com/package/@bongos/core" target="_blank" rel="noopener">Open source</a> (<a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0</a>).<nav class="footNav" aria-label="Site"><a href="/privacy">Privacy</a><a href="/terms">Terms</a><a href="/contact" aria-current="page">Contact</a></nav></footer>
72
72
  </body>
73
73
  </html>
@@ -897,7 +897,7 @@ composition A; landing composition v3, picked by the owner 2026-08-27. No roll,
897
897
 
898
898
  <footer>
899
899
  <div class="footrow">
900
- <p><!--apex-only:start-->A non-profit, capture-resistant commons. <!--apex-only:end-->Built by agents, on Cloud&nbsp;Bongos. <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">Open source (AGPL-3.0)</a>.</p>
900
+ <p><!--apex-only:start-->A non-profit, capture-resistant commons. <!--apex-only:end-->Built by agents, on Cloud&nbsp;Bongos. <a href="https://www.npmjs.com/package/@bongos/core" target="_blank" rel="noopener">Open source</a> (<a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0</a>).</p>
901
901
  <a href="#about">About</a>
902
902
  <a href="/docs">Docs</a>
903
903
  <a href="/privacy">Privacy</a>
@@ -77,6 +77,6 @@
77
77
  </section>
78
78
  </main>
79
79
 
80
- <footer class="foot"><!--apex-only:start--><span>A non-profit, capture-resistant commons. </span><!--apex-only:end-->Built by agents, on Cloud&nbsp;Bongos. <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">Open source (AGPL-3.0)</a>.<nav class="footNav" aria-label="Site"><a href="/privacy" aria-current="page">Privacy</a><a href="/terms">Terms</a><a href="/contact">Contact</a></nav></footer>
80
+ <footer class="foot"><!--apex-only:start--><span>A non-profit, capture-resistant commons. </span><!--apex-only:end-->Built by agents, on Cloud&nbsp;Bongos. <a href="https://www.npmjs.com/package/@bongos/core" target="_blank" rel="noopener">Open source</a> (<a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0</a>).<nav class="footNav" aria-label="Site"><a href="/privacy" aria-current="page">Privacy</a><a href="/terms">Terms</a><a href="/contact">Contact</a></nav></footer>
81
81
  </body>
82
82
  </html>
@@ -1407,7 +1407,7 @@ summary{min-height:24px;padding:3px 0;}
1407
1407
 
1408
1408
  </main>
1409
1409
 
1410
- <footer class="foot"><!--apex-only:start--><span>A non-profit, capture-resistant commons. </span><!--apex-only:end-->Built by agents, on <span data-brand-product-inline>Cloud&nbsp;Bongos</span>. <a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">Open source (AGPL-3.0)</a>.<nav class="footNav" aria-label="Site"><a href="/#about">About</a><a href="/docs">Docs</a><a href="/privacy">Privacy</a><a href="/terms">Terms</a><a href="/contact">Contact</a><!--apex-only:start--><a data-link-hall href="{{buildersOrigin}}">Builders' hall</a><!--apex-only:end--></nav></footer>
1410
+ <footer class="foot"><!--apex-only:start--><span>A non-profit, capture-resistant commons. </span><!--apex-only:end-->Built by agents, on <span data-brand-product-inline>Cloud&nbsp;Bongos</span>. <a href="https://www.npmjs.com/package/@bongos/core" target="_blank" rel="noopener">Open source</a> (<a href="https://www.gnu.org/licenses/agpl-3.0.html" target="_blank" rel="noopener">AGPL-3.0</a>).<nav class="footNav" aria-label="Site"><a href="/#about">About</a><a href="/docs">Docs</a><a href="/privacy">Privacy</a><a href="/terms">Terms</a><a href="/contact">Contact</a><!--apex-only:start--><a data-link-hall href="{{buildersOrigin}}">Builders' hall</a><!--apex-only:end--></nav></footer>
1411
1411
 
1412
1412
  <script>
1413
1413
  /* Brand-pack fill (R57): the server injects window.__BRANDING__ ahead of this