@bongos/core 1.19.971 → 1.19.973

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 (77) hide show
  1. package/.bongos-core.json +116 -76
  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/api/openapi.json +1 -1
  8. package/docs/api-reference.md +1 -1
  9. package/docs/architecture.md +6 -6
  10. package/docs/branding-contract.md +1 -1
  11. package/docs/canonical-permissions.md +24 -24
  12. package/docs/copy-inventory.md +193 -187
  13. package/docs/copy-registry.json +320 -276
  14. package/docs/design/projects-hub-direction-v2.md +1 -1
  15. package/docs/design/vanilla-hall-ui-redesign-scope.md +2 -2
  16. package/docs/file-map.md +7 -7
  17. package/docs/handoff-template.md +1 -1
  18. package/docs/module-api-changelog.md +4 -0
  19. package/docs/modules-contract.md +1 -1
  20. package/docs/onboarding/drachmae-and-karma.md +2 -2
  21. package/docs/packs/engineer.md +5 -5
  22. package/docs/packs/ideator.md +1 -1
  23. package/docs/recipes/README.md +1 -1
  24. package/docs/recipes/audit-replay.md +2 -2
  25. package/docs/recipes/gds-db-backup.md +3 -3
  26. package/docs/recipes/managed-settings-remote-control.md +9 -9
  27. package/docs/recipes/memory-sync-conflicts.md +2 -2
  28. package/docs/recipes/ops-gotchas.md +24 -5
  29. package/docs/recipes/overnight-builder.md +3 -3
  30. package/docs/recipes/parallel-goal-waves.md +3 -3
  31. package/docs/recipes/reranking-a-goals-board.md +2 -2
  32. package/docs/recipes/search-index-ingestion.md +1 -1
  33. package/docs/recipes/ship-and-regrade.md +2 -2
  34. package/docs/recipes/staging-environment.md +1 -1
  35. package/docs/recipes/windows-builders.md +1 -1
  36. package/modules/hall-ui/public/deploy.html +10 -5
  37. package/modules/hall-ui/public/deploy.js +199 -110
  38. package/modules/hall-ui/public/goals-page.js +6 -0
  39. package/modules/hall-ui/public/goals-render.js +16 -0
  40. package/modules/hall-ui/public/mine-lens.js +172 -0
  41. package/modules/hall-ui/public/project-settings.css +36 -0
  42. package/modules/hall-ui/public/work-summary.js +90 -0
  43. package/modules/hall-ui/public/work.css +50 -0
  44. package/modules/hall-ui/public/work.html +24 -0
  45. package/modules/hall-ui/public/work.js +38 -51
  46. package/modules/lifecycle/db-goals.js +20 -3
  47. package/modules/lifecycle/routes/goals.js +2 -2
  48. package/modules/provisioning/core-upgrade.js +61 -2
  49. package/modules/public-landing/public/contact.html +1 -1
  50. package/modules/public-landing/public/index.html +1 -1
  51. package/modules/public-landing/public/privacy.html +1 -1
  52. package/modules/public-landing/public/projects.html +1 -1
  53. package/modules/public-landing/public/terms.html +1 -1
  54. package/package-lock.json +2 -2
  55. package/package.json +1 -1
  56. package/scripts/gds/api-alias-caller-scan.js +47 -9
  57. package/scripts/gds/cloudbongos-token-isolation.conf +33 -0
  58. package/scripts/gds/fitness.js +3 -0
  59. package/scripts/gds/provision-core-upgrade.js +132 -1
  60. package/scripts/gds/run-unit-tests.js +14 -9
  61. package/scripts/gds/scope-roster-guard.js +118 -0
  62. package/scripts/hall-preview/server.js +5 -0
  63. package/src/bongos/module-scope-map.js +1 -0
  64. package/src/module-api.js +1 -1
  65. package/tests/api_alias_caller_ratchet.mjs +68 -2
  66. package/tests/core_upgrade_door.mjs +81 -0
  67. package/tests/core_upgrade_runner.mjs +132 -0
  68. package/tests/deploy_page_projects.mjs +234 -79
  69. package/tests/fitness_scope_roster.mjs +117 -0
  70. package/tests/goal_mine_lens.mjs +233 -0
  71. package/tests/goal_tier.mjs +30 -4
  72. package/tests/hall_mine_lens.mjs +381 -0
  73. package/tests/idea_spark_hall.mjs +101 -12
  74. package/tests/known_failing_ratchet.mjs +19 -9
  75. package/tests/landing_legal_pages.mjs +5 -1
  76. package/tests/projects_hub_pre_uat.mjs +12 -6
  77. package/tests/served_paths_shipped.mjs +36 -2
@@ -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
@@ -81,6 +81,6 @@
81
81
  </section>
82
82
  </main>
83
83
 
84
- <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" aria-current="page">Terms</a><a href="/contact">Contact</a></nav></footer>
84
+ <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" aria-current="page">Terms</a><a href="/contact">Contact</a></nav></footer>
85
85
  </body>
86
86
  </html>
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.971",
3
+ "version": "1.19.973",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.971",
9
+ "version": "1.19.973",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.971",
3
+ "version": "1.19.973",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -41,11 +41,22 @@
41
41
  // gate that cannot state its own rule cannot explain itself.
42
42
  // 5. tests/ (path-excluded). Tests are what keeps the alias exercised in CI. Moving them off it
43
43
  // would retire the alias by accident.
44
- // 6. docs/ (path-excluded) — a DELIBERATE SCOPE CALL, not an oversight. It is the largest
45
- // remaining bucket (~320) and it is where rename-history-check's per-file floors live, so a
46
- // docs pass has to separate the record of what we used to call it from instructions that
47
- // still teach the old spelling. 1004064's own brief says to treat docs as out of scope and
48
- // file it rather than widen the task; filed as the follow-up named in that task's session log.
44
+ // 6. THE FROZEN RECORD UNDER docs/ (path-excluded) — narrowed from a blanket `docs/` by task
45
+ // 1004113, which is the scope call 1004064 deferred. docs/ is not one kind of thing: part of
46
+ // it is the RECORD of what we used to call the thing, and part of it is live instruction that
47
+ // still TEACHES a reader to call the alias. A blanket exclusion left the second unpoliced
48
+ // forever; a blind sweep would have destroyed the first.
49
+ // The line is not invented here — it is the one the repo already draws twice over. The
50
+ // subtrees excluded (docs/adr, docs/session-logs, docs/audits) are exactly the frozen-record
51
+ // path list in the sibling detector gds-literal-scan.js, and — measured, not assumed — all 134 per-file
52
+ // floors in config/rename-history-baseline.json fall inside them (adr 116, session-logs 15,
53
+ // audits 3) and NONE falls outside. Rewriting a file with a floor fails the build on purpose:
54
+ // an ADR rewritten to today's vocabulary destroys the evidence that the words changed.
55
+ // docs/governance joins them on the same reading: every file in it is a dated authority sweep
56
+ // or a payment PROOF — an audit filed under another name. docs/recipes/failed-ship-recovery-proof.md
57
+ // is the one file named individually, for the same reason: it is a transcript of a recovery
58
+ // that happened, and rewriting the calls it records would make it misreport them.
59
+ // Everything else under docs/ is now COUNTED, and 1004113 swept it.
49
60
  // 7. MIGRATIONS + THE FROZEN RECORD (path-excluded). Same rule as the sibling detector: an
50
61
  // applied migration renamed is an applied migration re-run, and a rewritten ADR destroys the
51
62
  // evidence that the words changed.
@@ -55,6 +66,14 @@
55
66
  // so the ratchet and the sweep agree by construction.
56
67
  // 9. CAPTURED FIXTURES (path-excluded). scripts/hall-preview/fixtures/*.json are snapshots of
57
68
  // real API responses; rewriting one would make the fixture misreport what the server returned.
69
+ // 10. A LINE NAMING AN AUTH URL (line-excluded, task 1004113). `/api/gds/auth/...` in prose is the
70
+ // SAME pinned callback reason 2 path-excludes in code, reached from the docs side: the
71
+ // Authorization callback URL registered in GitHub's app settings is `${publicOrigin}/api/gds/auth/web/callback`,
72
+ // and a recipe that teaches a builder to register anything else breaks sign-in for the
73
+ // instance they are standing up. Seven occurrences across four recipes turn on this, and they
74
+ // must not be swept to make a number fall. This is a LINE rule rather than a path rule
75
+ // because the files carrying them (ops-gotchas.md, local-dev.md, staging-environment.md)
76
+ // are otherwise ordinary live instruction that SHOULD be counted.
58
77
  //
59
78
  // Usage:
60
79
  // node scripts/gds/api-alias-caller-scan.js # count + the top offending files
@@ -99,8 +118,15 @@ const ALIAS_EXCLUDED_PATHS = [
99
118
  // files are not tests (clients/bongos-client/index.mjs and its example, scripts/status-probe.mjs)
100
119
  // and the client is exactly the kind of caller this ratchet exists to police.
101
120
  /(^|\/)tests?\//,
102
- // 6. docs are a separate pass (scope call, see the header)
103
- /(^|\/)docs\//,
121
+ // 6. the FROZEN RECORD under docs/ — narrowed from a blanket `docs/` by task 1004113. These
122
+ // three subtrees mirror the frozen-record path list in gds-literal-scan.js and are where all 134
123
+ // rename-history floors live; docs/governance is dated audits + payment proofs under another
124
+ // name, and the one named file is a transcript of a recovery that happened.
125
+ /(^|\/)docs\/adr\//,
126
+ /(^|\/)docs\/session-logs\//,
127
+ /(^|\/)docs\/audits\//,
128
+ /(^|\/)docs\/governance\//,
129
+ /^docs\/recipes\/failed-ship-recovery-proof\.md$/,
104
130
  // 7. migrations + the frozen record
105
131
  /(^|\/)migrations\//,
106
132
  /(^|\/)limitations\//,
@@ -114,6 +140,15 @@ const ALIAS_EXCLUDED_TREES = /(^|\/)(node_modules|public-docs)\/|\.min\.js$|(^|\
114
140
  // A line that is ABOUT the alias rather than a caller of it. Rule 8 above.
115
141
  const ALIAS_COMMENTARY_RE = /\balias\b|\blegacy\b|\bpermanent\b|\bforever\b/i;
116
142
 
143
+ // A line naming an AUTH url under the alias. Rule 10 above: this is the pinned OAuth callback,
144
+ // registered outside the repo, so it is a fact about GitHub's app settings rather than a caller
145
+ // anyone may move. Kept as a line rule because the files it appears in are otherwise countable.
146
+ // Two shapes: the URL itself, and prose ABOUT it — a heading such as "Authorized callback URL must
147
+ // include the full /api/gds path" names the prefix without the /auth/ segment, and sweeping it left
148
+ // the heading contradicting the body two lines below it. Caught in review on task 1004113; the rule
149
+ // is widened rather than the heading rewritten, because the heading is right.
150
+ const ALIAS_PINNED_AUTH_RE = /\/api\/gds\/auth\/|\bcallback\b/i;
151
+
117
152
  function isExcludedPath(relPath) {
118
153
  const p = relPath.replace(/\\/g, '/');
119
154
  if (ALIAS_EXCLUDED_TREES.test(p)) return true;
@@ -127,6 +162,7 @@ function isCountedLine(line) {
127
162
  if (!line.includes(ALIAS)) return false;
128
163
  if (line.includes(CANONICAL)) return false;
129
164
  if (ALIAS_COMMENTARY_RE.test(line)) return false;
165
+ if (ALIAS_PINNED_AUTH_RE.test(line)) return false;
130
166
  return true;
131
167
  }
132
168
 
@@ -193,8 +229,10 @@ function main() {
193
229
  const show = process.argv.includes('--files') ? ranked : ranked.slice(0, 20);
194
230
  console.log(`in-repo /api/gds callers: ${total} across ${ranked.length} file(s) (${scanned} file(s) scanned)`);
195
231
  console.log('(the alias contract, the pinned OAuth callbacks, the uptime poller + smoke, the rename guardrails,');
196
- console.log(' tests/, docs/, migrations/ and captured fixtures are excluded by construction; so is any line that');
197
- console.log(' also names /api/bongos or carries the words alias / legacy / permanent / forever)');
232
+ console.log(' tests/, the FROZEN RECORD under docs/ (adr, session-logs, audits, governance), migrations/ and');
233
+ console.log(' captured fixtures are excluded by construction; so is any line that also names /api/bongos, carries');
234
+ console.log(' the words alias / legacy / permanent / forever, or names a pinned /api/gds/auth/ callback URL.');
235
+ console.log(' The REST of docs/ is counted — live instruction that still teaches the alias should shrink.)');
198
236
  console.log('');
199
237
  for (const [f, n] of show) console.log(` ${String(n).padStart(5)} ${f}`);
200
238
  if (show.length < ranked.length) console.log(` … and ${ranked.length - show.length} more file(s) — pass --files for all`);
@@ -0,0 +1,33 @@
1
+ # /etc/systemd/system/cloudbongos.service.d/task-1004176-token-isolation.conf
2
+ #
3
+ # task 1004176 / ADR 0327 §5 — the web tier must not be able to read the
4
+ # control-plane cloud tokens. Full rationale, the verification commands and the
5
+ # trap that makes a working config look broken: docs/recipes/ops-gotchas.md,
6
+ # "systemd". Kept there rather than repeated here so one edit cannot leave two
7
+ # copies disagreeing.
8
+ #
9
+ # A drop-in, not a unit edit: cloudbongos.service was hand-written at standup and
10
+ # is not generated from this repo, so an edit to it has no home here. A drop-in
11
+ # is additive, survives a main-unit rewrite, and rolls back by deleting one file.
12
+ #
13
+ # WHAT IS DELIBERATELY ABSENT. The task originally asked for the "standard
14
+ # sandboxing directives" and for the two tiers to run as different unix users.
15
+ # Neither is here, and ADR 0327 §5 records why:
16
+ # ProtectHome= would break the web tier outright — its checkout is
17
+ # /home/lars/cloudbongos-instance, and the secret-box
18
+ # master key it legitimately needs for per-builder AI keys
19
+ # is ~/.config/cloudbongos/builder-secret.key.
20
+ # ProtectSystem=strict needs a ReadWritePaths allowlist that a live site should
21
+ # not be handed blind. Worth doing deliberately, later.
22
+ # PrivateTmp= unevaluated: nothing established whether the web tier and
23
+ # the runners share anything through /tmp. Not guessed at.
24
+ # different users would mean relocating the checkout, the config dir,
25
+ # web.env and that master key. The namespace gives the same
26
+ # protection for this file at a fraction of the risk.
27
+ # NoNewPrivileges is present: no web-tier path shells out with sudo or setuid
28
+ # (checked in src/ and modules/), and 30 days of the unit's own logs contain no
29
+ # escalation or EPERM.
30
+ #
31
+ [Service]
32
+ InaccessiblePaths=/etc/cloudbongos/box.env
33
+ NoNewPrivileges=yes
@@ -1308,6 +1308,9 @@ const CHECKS = [
1308
1308
  checkCoreNeverImportsModule,
1309
1309
  checkModulesImportOnlyApi,
1310
1310
  checkScopeMapImportsProtected,
1311
+ // Check 36 — task 1002900: a carved module with no roster key walls nothing. Extracted
1312
+ // to its own file (the role-pack-guard.js precedent) to keep fitness.js under the size ratchet.
1313
+ () => require('./scope-roster-guard.js').checkScopeRosterCoversModules({ repoRoot: REPO_ROOT, discoveredModuleKeys }),
1311
1314
  checkCoreCarriesNoModuleCode,
1312
1315
  checkKernelImportsNoDomain,
1313
1316
  checkModuleMigrationsAdditive,
@@ -41,6 +41,12 @@ const { isValidSlug } = require('../../modules/provisioning/provisioning.js');
41
41
  // changes between the request and the drain.
42
42
  const UPGRADE_SHAPES = Object.freeze(new Set(['co-tenant', 'standalone', 'control-plane']));
43
43
 
44
+ // The strict three-number form a core version takes. Re-stated here rather than imported
45
+ // from the web tier's core-upgrade.js: a module may not be required from scripts/ (ADR
46
+ // 0083) and this side needs it to guard a SHELL interpolation, which is a stricter duty
47
+ // than validating a request body.
48
+ const CORE_VERSION_RE = /^[0-9]+\.[0-9]+\.[0-9]+$/;
49
+
44
50
  // The box's own answer to "what core am I running?" — loopback, on the instance's port.
45
51
  //
46
52
  // THE APEX CANNOT BE ASKED (ADR 0293 D6). It sits behind pre-launch basic_auth and its
@@ -432,6 +438,115 @@ function preflightUpgrade(inst, to, deps) {
432
438
  };
433
439
  }
434
440
 
441
+ // ── WHAT THE MOVE WOULD CARRY (task 1004173) ────────────────────────────────
442
+ //
443
+ // The owner, 2026-09-22: "I don't actually see what would change when I hit the button."
444
+ // The pre-flight above answers a different question — whether the machinery will let the
445
+ // move happen — and answering it was being presented as a preview. ADR 0293 D4 promised
446
+ // both halves in so many words: "the control shows what a bump would move — live version
447
+ // → target, AND WHAT SITS BETWEEN THEM." Only the first half was built, so the owner was
448
+ // asked to restart a live site to take ninety-one releases of work the page named none of.
449
+ //
450
+ // WHERE THE ANSWER ALREADY LIVES. Every ship writes a merge commit whose summary is
451
+ // already prose for exactly this reader — the owner, not a developer — because ship.js
452
+ // requires one and posts it to Discord. So this reads the merge summaries between the two
453
+ // releases rather than inventing a changelog nobody would maintain.
454
+ //
455
+ // IT READS THE CONTROL PLANE'S OWN CORE CHECKOUT, ALWAYS, whichever instance is being
456
+ // previewed. "What changed in the core between 1.19.881 and 1.19.972" is a question about
457
+ // the CORE repository, and a tenant's instance directory is a checkout of the TENANT's
458
+ // repo with the core vendored in from npm — no core history in it at all, and the wrong
459
+ // history if you squint. The control plane is the machine that builds the core, so it is
460
+ // the machine that can answer.
461
+ const CHANGE_CAP = 20;
462
+ const CHANGE_TEXT_MAX = 400;
463
+
464
+ /**
465
+ * The merge summaries in a `git log --merges --format=%s%x1f%b%x1e` dump. PURE.
466
+ *
467
+ * TWO MERGE SHAPES CARRY A SUMMARY AND THE SECOND IS THE COMMON ONE. A hand-landed ship
468
+ * writes `Merge <branch>: <summary>`; the CI auto-merge writes `Merge pull request #N
469
+ * from <owner>/<branch>` with `task <id>: <summary>` in the BODY. Measured over
470
+ * 1.19.881→1.19.972: 114 usable summaries, of which 98 were the second shape. A parser
471
+ * that read subjects only would have found eleven of them and quietly called that the
472
+ * release.
473
+ *
474
+ * EVERYTHING ELSE IS DROPPED, and the drops are the majority: `Merge origin/main into
475
+ * <branch> (pre-stage freshness…)`, `Merge remote-tracking branch…`, a regen chore. Those
476
+ * are the mechanics of landing work, not the work, and listing them would bury the
477
+ * eleven sentences the owner actually wants under a hundred they do not.
478
+ */
479
+ function parseChangeRecords(stdout) {
480
+ const out = [];
481
+ const seen = new Set();
482
+ for (const rec of String(stdout || '').split('\x1e')) {
483
+ const r = rec.trim();
484
+ if (!r) continue;
485
+ const i = r.indexOf('\x1f');
486
+ const subject = (i < 0 ? r : r.slice(0, i)).trim();
487
+ const body = i < 0 ? '' : r.slice(i + 1);
488
+ let text = null;
489
+ let id = null;
490
+ // The negative lookahead is what keeps the two housekeeping shapes out: both begin
491
+ // "Merge " and both would otherwise match a `<token>:` somewhere later in the line.
492
+ let m = /^Merge\s+(?!pull request\b|remote-tracking\b)([^\s:]+):\s*(\S.*)$/.exec(subject);
493
+ if (m) {
494
+ text = m[2];
495
+ } else {
496
+ const first = body.split('\n').map((l) => l.trim()).find(Boolean) || '';
497
+ m = /^task\s+(\d+):\s*(\S.*)$/.exec(first);
498
+ if (m) { id = m[1]; text = m[2]; }
499
+ }
500
+ if (!text) continue;
501
+ // It reaches a DOM, and a ship summary is occasionally a paragraph.
502
+ text = text.length > CHANGE_TEXT_MAX ? `${text.slice(0, CHANGE_TEXT_MAX - 1)}…` : text;
503
+ if (seen.has(text)) continue; // a branch merged twice says its sentence once
504
+ seen.add(text);
505
+ out.push(id ? { id, text } : { text });
506
+ }
507
+ return out;
508
+ }
509
+
510
+ /**
511
+ * What landed between two core releases, as the summaries their ships wrote.
512
+ *
513
+ * Returns `{ from, to, entries, more }`, or `{ from, to, error }` — NEVER an empty
514
+ * `entries` for a range it could not resolve. An empty list reads as "nothing changed",
515
+ * which on a page whose job is to say what a restart buys is the one wrong impression it
516
+ * must not give, and it is exactly what a missing checkout or a shallow clone produces.
517
+ *
518
+ * Every version reaching a shell is re-checked against the strict three-number form
519
+ * FIRST. They arrive from an intent row whose column has the same CHECK, but a value that
520
+ * is interpolated into a command is checked where it is interpolated — the rule
521
+ * upgradeInvocation states about the slug.
522
+ */
523
+ function changesBetween(fromVersion, toVersion, deps) {
524
+ const bad = [fromVersion, toVersion].find((v) => !CORE_VERSION_RE.test(String(v || '')));
525
+ if (bad !== undefined) return { from: fromVersion || null, to: toVersion || null, error: 'could not work out which releases this covers' };
526
+ // The CONTROL PLANE's executor and the CONTROL PLANE's checkout, never the previewed
527
+ // instance's: under PROVISION_REMOTE_HOST `exec` is the remote box, and a tenant's own
528
+ // directory holds no core history at all.
529
+ const exec = deps.controlExec || deps.exec;
530
+ const selfRoot = deps.selfInstanceRoot || selfInstanceRoot;
531
+ const dir = selfRoot();
532
+ const shaOf = (v) => {
533
+ const r = exec(`git -C ${dir} log --format=%H --max-count=1 -F --grep='release: core ${v} '`, { allowFail: true });
534
+ const sha = String((r && r.stdout) || '').trim().split('\n')[0] || '';
535
+ return /^[0-9a-f]{7,40}$/.test(sha) ? sha : null;
536
+ };
537
+ const from = shaOf(fromVersion);
538
+ const to = shaOf(toVersion);
539
+ if (!from || !to) {
540
+ return { from: fromVersion, to: toVersion, error: 'could not find those releases in the core history on this machine' };
541
+ }
542
+ const r = exec(`git -C ${dir} log --merges --format=%s%x1f%b%x1e ${from}..${to}`, { allowFail: true });
543
+ if (!r || r.softFailed === true || r.ok === false) {
544
+ return { from: fromVersion, to: toVersion, error: 'could not read the core history on this machine' };
545
+ }
546
+ const all = parseChangeRecords(r.stdout);
547
+ return { from: fromVersion, to: toVersion, entries: all.slice(0, CHANGE_CAP), more: Math.max(0, all.length - CHANGE_CAP) };
548
+ }
549
+
435
550
  // Run a dry run for one instance and cache its verdict — the ON-DEMAND leg, drained from
436
551
  // a `core-upgrade-preflight` intent the owner's arming queues (task 1004121). The tick
437
552
  // does not do this: a verdict that is only ever produced on a background schedule is
@@ -446,6 +561,22 @@ async function preflightCoreUpgrade(inst, deps, intent) {
446
561
  }
447
562
  if (!to) return { ok: false, error: 'no target version to preview' };
448
563
  const verdict = (deps.preflightUpgrade || preflightUpgrade)(inst, to, deps);
564
+ // AND WHAT THE MOVE WOULD CARRY, taken in the same breath (task 1004173). It rides the
565
+ // verdict rather than a column of its own for the reason the verdict's own `target`
566
+ // exists: both answers are about ONE move, and storing them together is what makes
567
+ // "is this still the move on offer" a single question with a single answer. It also
568
+ // costs the owner no second wait — the dry run is already the thing they are waiting on.
569
+ //
570
+ // `from` is the version the box is on RIGHT NOW, read back from the snapshot the tick
571
+ // writes, and it is stored because the answer is only true of that starting point. The
572
+ // web tier withholds the list when the box has moved since, the same way it withholds a
573
+ // verdict taken against a superseded target.
574
+ let servedNow = null;
575
+ try {
576
+ const { rows } = await db.query('SELECT served_version FROM provisioning_core_version_snapshot WHERE instance_id = $1', [inst.id]);
577
+ servedNow = (rows[0] && rows[0].served_version) || null;
578
+ } catch { /* no snapshot yet — changesBetween refuses on the missing version, and says so */ }
579
+ verdict.changes = (deps.changesBetween || changesBetween)(servedNow, to, deps);
449
580
  await db.query(
450
581
  `INSERT INTO provisioning_core_version_snapshot (instance_id, preflight, preflight_at, observed_at)
451
582
  VALUES ($1, $2::jsonb, now(), now())
@@ -537,4 +668,4 @@ async function snapshotAllCoreVersions(deps) {
537
668
  return { seen };
538
669
  }
539
670
 
540
- module.exports = { UPGRADE_SHAPES, resolveRunAs, versionCmd, parseServedVersion, parseSchemaPending, targetFault, parsePreflight, upgradeInvocation, preflightUpgrade, preflightCoreUpgrade, coreUpgradeInstance, snapshotCoreVersions, snapshotAllCoreVersions };
671
+ module.exports = { UPGRADE_SHAPES, CHANGE_CAP, parseChangeRecords, changesBetween, resolveRunAs, versionCmd, parseServedVersion, parseSchemaPending, targetFault, parsePreflight, upgradeInvocation, preflightUpgrade, preflightCoreUpgrade, coreUpgradeInstance, snapshotCoreVersions, snapshotAllCoreVersions };
@@ -209,15 +209,20 @@ const INTEGRATION = new Set([
209
209
  // (V4.R16) diagnosed them as STALE — they predated #481's Narc-advisory change,
210
210
  // NOT a grader regression. Now green and back under this gate so the quality-
211
211
  // gate brain can't silently regress.
212
- const KNOWN_FAILING = new Set([
213
- // #1002617: red in every clean environment (Linux CI and a Windows checkout
214
- // alike), surfaced by the unit lane's first-ever runs. Parked so the `unit`
215
- // check can be required (a gate that's already red gates nothing); per-test
216
- // diagnoses live in that task. Remove each here as it is fixed.
217
- // PAIRING: tests/known_failing_ratchet.mjs keeps EXECUTING each parked test
218
- // and fails the suite the moment one starts passing — so this set cannot rot.
219
- 'served_paths_shipped',
220
- ]);
212
+ // EMPTY, and that is the intended steady state (task 1002617 closed the last entry).
213
+ //
214
+ // This set existed because the unit lane's first-ever runs (task 1002610) surfaced three
215
+ // tests red in every clean environment, and a gate that is already red gates nothing —
216
+ // so they were parked to make `unit` requireable while each was diagnosed. platform_boot
217
+ // and publish_manifest were fixed earlier; served_paths_shipped was the survivor, and its
218
+ // failure turned out to be its own self-guard correctly reporting that the source scan
219
+ // had gone blind when the OpenAPI serve moved to a root ladder (task 1002200).
220
+ //
221
+ // Adding an entry here is a real cost and should be rare: a parked test is invisible
222
+ // coverage, and this one sat parked long enough that its diagnosis had to be redone.
223
+ // PAIRING: tests/known_failing_ratchet.mjs keeps EXECUTING each parked test and fails the
224
+ // suite the moment one starts passing — so this set cannot rot silently.
225
+ const KNOWN_FAILING = new Set([]);
221
226
 
222
227
  // Task 1003845: on Windows + Node 24 a suite that hard-exits after serving HTTP can
223
228
  // print a clean verdict and THEN abort natively (the cliExit class in
@@ -0,0 +1,118 @@
1
+ // scripts/gds/scope-roster-guard.js
2
+ //
3
+ // WHAT. The fitness check behind the module scope roster (task 1002900): every module
4
+ // discovered at modules/<key>/module.json must have a covering key in MODULE_GLOBS
5
+ // (src/bongos/module-scope-map.js).
6
+ //
7
+ // WHY IT IS A HARD FAIL, when the runtime symptom is a denial rather than a crash.
8
+ // The roster is maintained by HAND. A module lands, nobody adds the entry, and any goal
9
+ // scoped to that work hits an authorize-NOTHING wall: goal-scope-check.check() denies
10
+ // with empty_scope / unknown_scope. Nothing announces it — it surfaces only when someone
11
+ // tries to set a goal's wall and cannot, which is long after the module landed and far
12
+ // from the edit that caused it. CI is the only place the gap is visible at the moment it
13
+ // is created.
14
+ //
15
+ // It has happened three times by hand: task 1002690 (provisioning + public-landing,
16
+ // goal 1000059), task 1002322 (platform-identity, goal 1000046) and `agents`, the
17
+ // instance this check was filed for. Each was fixed after someone tripped over it.
18
+ //
19
+ // WHY SUBSET AND NOT EQUALITY. The roster legitimately carries keys with no
20
+ // modules/<key>/ directory, for two different reasons, and equality would fail on all
21
+ // five of them in this tree:
22
+ // - NON-MODULE SCOPES — `kernel`, `methodology`, `devsecops` are surfaces, not modules.
23
+ // - ABSENT MODULES — `art-pipeline`, `game` are rostered but not present in this
24
+ // instance (ADR 0108: a host may carry a different module set).
25
+ // So the invariant is one-directional: every DISCOVERED module must be rostered; a
26
+ // rostered key with no module is not a fault. Getting that backwards would produce a
27
+ // check nobody can keep green, which is worse than no check.
28
+ //
29
+ // Extracted from fitness.js (which the size ratchet caps) following the
30
+ // role-pack-guard.js precedent. The pure scanner is exported for tests/fitness_scope_roster.mjs.
31
+
32
+ 'use strict';
33
+
34
+ const fs = require('node:fs');
35
+ const path = require('node:path');
36
+
37
+ // The machinery already existed — fitness.js's discoveredModuleKeys() feeds the
38
+ // kernel-machinery check — but nothing cross-checked it against moduleKeys().
39
+ // checkScopeMapImportsProtected guards HOW `protected` is derived, not WHETHER every
40
+ // module is listed.
41
+
42
+ // SCOPE: modules/ ONLY — the two named orphans, decided rather than left open.
43
+ // Task 1002900 asked for a ruling on two files said to be covered by no glob. Both
44
+ // answers changed when checked against origin/main on 2026-09-22:
45
+ // - scripts/gds/fitness.js — NOT uncovered. The `devsecops` key matches it. The row's
46
+ // claim was stale.
47
+ // - src/server.js — does not exist any more.
48
+ // - src/platform-server.js — genuinely uncovered, and DELIBERATELY still is.
49
+ // This check asserts what its name says: every carved MODULE has a roster key. Widening
50
+ // it to arbitrary src/ entrypoints is a different question about a different registry —
51
+ // `kernel` lists individual files by hand, so "should platform-server join it" is a call
52
+ // for whoever owns that scope, not a side effect of adding a module-coverage lint. Left
53
+ // as a recorded decision rather than an exemption entry, because there is nothing to
54
+ // exempt: modules/ is the stated scope and platform-server.js was never in it.
55
+
56
+ const SCOPE_ROSTER_REL = 'src/bongos/module-scope-map.js';
57
+
58
+ function scanScopeRosterCoverage({ discovered, rostered, globs, exists }) {
59
+ const missing = discovered.filter((k) => !rostered.includes(k)).sort();
60
+ const violations = missing.map((k) =>
61
+ `module '${k}' (modules/${k}/module.json) has no key in ${SCOPE_ROSTER_REL} MODULE_GLOBS — `
62
+ + 'any goal scoped to it will deny with empty_scope/unknown_scope');
63
+
64
+ // The mirror-image rot, reported as a WARNING rather than a failure: a roster glob that
65
+ // names an exact FILE which no longer exists. It cannot wall anything off, so it is a
66
+ // silent hole in a scope rather than a build breakage — and unlike a missing key it has
67
+ // no runtime symptom at all. Only exact file paths are checked; a directory glob may
68
+ // legitimately point at an absent optional module (see SUBSET above).
69
+ const warnings = [];
70
+ for (const [key, list] of Object.entries(globs || {})) {
71
+ for (const g of list || []) {
72
+ if (g.endsWith('/') || g.includes('*')) continue;
73
+ if (!exists(g)) warnings.push(`${SCOPE_ROSTER_REL}: scope '${key}' lists '${g}', which does not exist — dead entry, walls nothing`);
74
+ }
75
+ }
76
+ return { violations, warnings };
77
+ }
78
+
79
+ // REPO_ROOT and discoveredModuleKeys come from fitness.js, which owns module discovery
80
+ // (it unions across MODULE_ROOTS per ADR 0108). Passed in rather than re-derived so the
81
+ // two can never disagree about what a module is.
82
+ function checkScopeRosterCoversModules({ repoRoot, discoveredModuleKeys }) {
83
+ let res;
84
+ try {
85
+ const scopeMap = require(path.join(repoRoot, SCOPE_ROSTER_REL));
86
+ res = scanScopeRosterCoverage({
87
+ discovered: discoveredModuleKeys(),
88
+ rostered: scopeMap.moduleKeys(),
89
+ globs: scopeMap.MODULE_GLOBS,
90
+ exists: (g) => fs.existsSync(path.join(repoRoot, g)),
91
+ });
92
+ } catch (e) {
93
+ // FAILS OPEN, deliberately and narrowly. This catch fires only if requiring the scope
94
+ // map throws — i.e. the file is syntactically broken — and that is not this check's
95
+ // job to report: src/bongos/module-scope-map.js has its own unit coverage, and every
96
+ // sibling scope check (checkScopeMapImportsProtected, the ownership checks) would red
97
+ // on the same input. A coverage lint that hard-fails on someone else's syntax error
98
+ // adds a second confusing message to a build that is already failing for the real
99
+ // reason. The skip is surfaced as a warning rather than swallowed.
100
+ return {
101
+ name: 'every discovered module has a scope-roster key (task 1002900)',
102
+ ok: true, hardFail: false, violations: [], warnings: [`skipped: ${e.message}`],
103
+ note: `could not load ${SCOPE_ROSTER_REL} — check skipped.`,
104
+ };
105
+ }
106
+ return {
107
+ name: 'every discovered module has a scope-roster key (task 1002900)',
108
+ ok: res.violations.length === 0,
109
+ hardFail: res.violations.length > 0,
110
+ violations: res.violations,
111
+ warnings: res.warnings,
112
+ note: `every modules/<key>/ carrying module.json has a MODULE_GLOBS entry, so a goal can be `
113
+ + `scoped to it. Subset, not equality: a rostered key with no module is legitimate `
114
+ + `(non-module scopes like kernel/methodology/devsecops, and modules absent from this instance).`,
115
+ };
116
+ }
117
+
118
+ module.exports = { checkScopeRosterCoversModules, scanScopeRosterCoverage, SCOPE_ROSTER_REL };