@bongos/core 1.19.1032 → 1.19.1034

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 (151) hide show
  1. package/.bongos-core.json +322 -117
  2. package/.claude/skills/builder-start/SKILL.md +1 -1
  3. package/.claude/skills/grade-audit/SKILL.md +1 -1
  4. package/.claude/skills/grade-recover/SKILL.md +2 -1
  5. package/.claude/skills/grader-health/SKILL.md +5 -1
  6. package/.claude/skills/merge-mode/SKILL.md +1 -1
  7. package/docs/adr/0089-modular-multi-model-adversarial-grader.md +15 -0
  8. package/docs/adr/0175-the-board-room-full-idea-ratification.md +3 -2
  9. package/docs/adr/0178-the-copy-desk-flag-and-queue.md +3 -3
  10. package/docs/adr/0188-patrol-runs-on-ci-not-the-server.md +2 -2
  11. package/docs/adr/0189-baseline-floors-and-the-freeze-detector.md +1 -1
  12. package/docs/adr/0233-a-copy-proposal-is-a-task-carrying-a-patch.md +1 -1
  13. package/docs/adr/0241-the-artist-gate-is-a-per-project-deploy-gate-that-reads-a-state.md +11 -3
  14. package/docs/adr/README.md +4 -4
  15. package/docs/api/openapi.json +1 -1
  16. package/docs/architecture.md +4 -3
  17. package/docs/canonical-permissions.md +2 -2
  18. package/docs/copy-inventory.md +34 -30
  19. package/docs/copy-registry.json +69 -33
  20. package/docs/file-map.md +4 -3
  21. package/docs/module-api-changelog.md +4 -0
  22. package/docs/recipes/autobongos-windows-host.md +3 -1
  23. package/docs/recipes/local-dev.md +45 -3
  24. package/docs/recipes/packaging-the-core.md +3 -1
  25. package/docs/recipes/redteam-patrol.md +2 -2
  26. package/docs/recipes/upgrading-the-core.md +1 -1
  27. package/modules/autonomy/cadence.js +52 -0
  28. package/modules/autonomy/gauge.js +57 -9
  29. package/modules/government/protected-surfaces.json +1 -0
  30. package/modules/grading/grade-engine.js +267 -0
  31. package/modules/grading/grader-subagent.js +4 -3
  32. package/modules/grading/grader-workers/index.js +67 -11
  33. package/modules/grading/grader-workers/worker-verdict.js +32 -2
  34. package/modules/grading/grader.js +109 -88
  35. package/modules/hall-ui/public/deploy.js +35 -0
  36. package/modules/hall-ui/public/project-settings.css +7 -0
  37. package/modules/hall-ui/records/builders-roster.md +10 -0
  38. package/modules/hall-ui/records/catalog.md +10 -0
  39. package/modules/hall-ui/records/collaboration.md +14 -0
  40. package/modules/hall-ui/records/goal-dependency-forest.md +11 -0
  41. package/modules/hall-ui/records/oversight-family.md +19 -0
  42. package/modules/hall-ui/records/panel-family.md +19 -0
  43. package/modules/hall-ui/records/reading-rooms.md +12 -0
  44. package/modules/hall-ui/records/record-family.md +13 -0
  45. package/modules/hall-ui/records/reference-room.md +9 -0
  46. package/modules/hall-ui/records/roster-and-ledger.md +9 -0
  47. package/modules/hall-ui/records/sky.md +23 -0
  48. package/modules/lifecycle/dependency-advisory.js +2 -1
  49. package/modules/lifecycle/routes/tasks.js +19 -4
  50. package/modules/lifecycle/ship-preflight.js +91 -0
  51. package/modules/provisioning/control-plane-guard.js +14 -13
  52. package/modules/provisioning/core-upgrade.js +30 -17
  53. package/modules/provisioning/public-refusal.js +44 -0
  54. package/modules/provisioning/routes/disconnect.js +2 -1
  55. package/modules/provisioning/routes/env-manifest.js +5 -4
  56. package/modules/provisioning/routes/provisioning.js +16 -15
  57. package/modules/provisioning/routes/repo-private.js +2 -1
  58. package/package-lock.json +2 -2
  59. package/package.json +1 -1
  60. package/release-notes.json +7481 -0
  61. package/scripts/gds/address-park-sweep.sh +3 -1
  62. package/scripts/gds/autobongos-run.js +91 -3
  63. package/scripts/gds/backfill-cost.js +20 -9
  64. package/scripts/gds/baseline-staleness.js +1 -1
  65. package/scripts/gds/claim.js +2 -2
  66. package/scripts/gds/credential-presence.js +281 -0
  67. package/scripts/gds/db-backup-nightly.sh +9 -4
  68. package/scripts/gds/db-backup-offsite.sh +4 -2
  69. package/scripts/gds/deploy-main-audit-gate.sh +11 -4
  70. package/scripts/gds/dev-lib.js +212 -5
  71. package/scripts/gds/dev.js +102 -50
  72. package/scripts/gds/doctor.js +3 -2
  73. package/scripts/gds/grade-correlation-audit.js +280 -0
  74. package/scripts/gds/grade-replay.js +237 -0
  75. package/scripts/gds/hooks/pre-push.js +7 -3
  76. package/scripts/gds/main-audit.js +19 -10
  77. package/scripts/gds/memory-map.js +3 -1
  78. package/scripts/gds/onboarding-config.js +3 -2
  79. package/scripts/gds/package-core.js +72 -1
  80. package/scripts/gds/plain-cards.js +4 -0
  81. package/scripts/gds/preflight.js +12 -4
  82. package/scripts/gds/provision-core-upgrade.js +282 -80
  83. package/scripts/gds/provision-repo-auth.js +15 -0
  84. package/scripts/gds/provision.js +1 -1
  85. package/scripts/gds/publish-credential-check.js +3 -0
  86. package/scripts/gds/push-main.js +14 -3
  87. package/scripts/gds/push-path-brief.js +3 -2
  88. package/scripts/gds/redteam-patrol.js +4 -4
  89. package/scripts/gds/release-notes.js +271 -0
  90. package/scripts/gds/run-routine.js +18 -5
  91. package/scripts/gds/run-unit-tests.js +14 -3
  92. package/scripts/gds/seed-board-room-tasks.js +1 -1
  93. package/scripts/gds/session-cost.js +12 -3
  94. package/scripts/gds/ship-deploy-target.js +11 -6
  95. package/scripts/gds/ship-flow.js +5 -3
  96. package/scripts/gds/ship-grade-steps.js +33 -0
  97. package/scripts/gds/ship-land.js +241 -16
  98. package/scripts/gds/ship-merge.js +3 -2
  99. package/scripts/gds/ship-preflight-steps.js +86 -11
  100. package/scripts/gds/ship.js +1 -1
  101. package/scripts/gds/stray-unit-sweep.sh +3 -1
  102. package/scripts/gds/upgrade-migrate.js +67 -0
  103. package/scripts/gds/upgrade.js +9 -7
  104. package/scripts/hall-preview/README.md +20 -3
  105. package/scripts/hall-preview/live-identity.js +1 -1
  106. package/scripts/hall-preview/refresh-fixtures.js +71 -11
  107. package/scripts/hall-preview/scrub-identity.js +296 -0
  108. package/src/bongos/routes/security.js +3 -3
  109. package/src/build-info.js +4 -2
  110. package/src/module-api.js +1 -1
  111. package/tests/adr_0089_scope.mjs +70 -0
  112. package/tests/adr_0162_citation.mjs +184 -0
  113. package/tests/autobongos_cadence.mjs +74 -0
  114. package/tests/autobongos_loop.mjs +114 -0
  115. package/tests/autonomy_gauge.mjs +56 -0
  116. package/tests/consumer_layout_boot.mjs +363 -31
  117. package/tests/core_upgrade_door.mjs +40 -6
  118. package/tests/core_upgrade_runner.mjs +329 -5
  119. package/tests/credential_presence.mjs +153 -0
  120. package/tests/db_backup_nightly.mjs +21 -3
  121. package/tests/db_backup_offsite.mjs +81 -0
  122. package/tests/deploy_audit_gate.mjs +5 -2
  123. package/tests/deploy_page_projects.mjs +63 -2
  124. package/tests/dev_lib.mjs +2 -1
  125. package/tests/dev_windows.mjs +324 -0
  126. package/tests/env_knobs_neutral_prefix.mjs +203 -0
  127. package/tests/fixtures/grade-replay/recorded-panel-grades.json +27 -0
  128. package/tests/government_protected_surfaces.mjs +10 -0
  129. package/tests/grade_correlation_audit.mjs +159 -0
  130. package/tests/grade_engine.mjs +249 -0
  131. package/tests/grade_engine_replay.mjs +99 -0
  132. package/tests/grader_prompt_window_bound.mjs +132 -0
  133. package/tests/grader_unbacked_score.mjs +194 -0
  134. package/tests/hall_preview_scrub.mjs +214 -0
  135. package/tests/hall_records.mjs +85 -0
  136. package/tests/helpers.mjs +25 -0
  137. package/tests/instance_db_migrated_on_code_move.mjs +8 -1
  138. package/tests/integration_parked_ratchet.mjs +24 -1
  139. package/tests/plain_cards.mjs +1 -1
  140. package/tests/provisioning_public_refusal.mjs +195 -0
  141. package/tests/publish_status_branch.mjs +128 -0
  142. package/tests/redteam_patrol.mjs +1 -1
  143. package/tests/release_notes.mjs +327 -0
  144. package/tests/shell_stat_portability.mjs +49 -0
  145. package/tests/ship_land_conflict.mjs +181 -0
  146. package/tests/ship_land_red_pr.mjs +236 -0
  147. package/tests/ship_preflight.mjs +226 -3
  148. package/tests/start_env_guard.mjs +16 -0
  149. package/tests/sweep_app_account.mjs +15 -2
  150. package/tests/upgrade.mjs +20 -1
  151. package/tests/upgrade_migrate.mjs +195 -0
@@ -21,7 +21,8 @@
21
21
  // `node scripts/gds/audit-deps.js --min high` as one more housekeeping
22
22
  // step (idea 1000276's half — see .claude/skills/goal-review/SKILL.md).
23
23
  //
24
- // HARD CONSTRAINT, same as goal-advisory.js and ADR 0162: advisory only. This
24
+ // HARD CONSTRAINT, same as goal-advisory.js: advisory only (a finding addressed
25
+ // to a human never acts by itself — ADR 0158 §2). This
25
26
  // must never refuse a POST, and `buildDependencyAdvisory` swallows its own
26
27
  // failures for the same reason goal-advisory.js's does — the task row is
27
28
  // ALREADY COMMITTED by the time this runs, so a throw here would turn a
@@ -53,6 +53,11 @@ const githubPush = require('../github-push');
53
53
  const prAssign = require('../pr-assign');
54
54
  const shipAssurance = require('../ship-assurance');
55
55
  const RECONCILE_PROOF = Object.freeze({ kind: 'proof', reason: 'publish-status reconcile: land proven' });
56
+ // task 1003764: publish-status with no ?branch= and no PR titled for the task — the shape
57
+ // getPublishStatus gives a branch with no PR, plus how to ask about one. Never a 400.
58
+ const NO_PR_STATUS = Object.freeze({
59
+ pr_state: 'none', merged: false, merge_sha: null, deploy_status: 'none', pr_url: null, pr_number: null, pr_title: null, pr_body: null,
60
+ branch_hint: 'No pull request titled for this task is among the 100 most recent. Pass ?branch=<name> to ask about a specific branch.' });
56
61
  const seams = api;
57
62
  const { classifyTaskKind, VALID_KINDS, VALID_DISCIPLINES, VOTABLE_TASK_STATUSES, TASK_FIELD_BOUNDS } = require('../task-classifier');
58
63
  const { isModuleKey, moduleKeys } = api.moduleScopeMap;
@@ -1250,15 +1255,25 @@ module.exports = function buildTasksRouter() {
1250
1255
  // GET /tasks/:id/publish-status?branch=<branch> — poll the PR + prod-deploy
1251
1256
  // state for a server-mediated publish so ship.js can confirm the land WITHOUT
1252
1257
  // gh on the box. Ownership-gated like the POST. Derives state live from GitHub.
1258
+ // `branch` is optional (task 1003764): without it the server finds the task's own PR
1259
+ // (both publish paths title it "task <id>: …") and the answer names the `branch` it
1260
+ // used and its `branch_source`, so the check agents are sent to instead of git probes
1261
+ // never refuses a live publish for want of a branch name.
1253
1262
  router.get('/tasks/:id/publish-status', auth.requireBuilder, async (req, res) => {
1254
1263
  const id = parseId(req, res, { code: 'bad_task_id' });
1255
1264
  if (id === null) return;
1256
1265
  if (!(await gateTaskOwnership(req, res, id))) return;
1257
1266
  if (!githubPush.isConfigured()) return res.fail('push_unconfigured', { status: 503, message: PUSH_UNCONFIGURED_MSG });
1258
- const branch = req.query.branch;
1259
- if (!githubPush.isSafeBranch(branch)) return res.fail('bad_branch', 400);
1267
+ // A fully-qualified ref names the same branch, so it is accepted rather than refused.
1268
+ const asked = typeof req.query.branch === 'string' ? req.query.branch.replace(/^refs\/heads\//, '') : req.query.branch;
1269
+ if (asked != null && asked !== '' && !githubPush.isSafeBranch(asked)) {
1270
+ return res.fail('bad_branch', { status: 400, message: 'branch must be a plain branch name such as task-123, or left out so the server finds the task\'s own PR.' });
1271
+ }
1260
1272
  try {
1261
- const status = await githubPush.getPublishStatus({ branch });
1273
+ const found = asked ? null : await githubPush.findTaskPullRequest({ taskId: id });
1274
+ const branch = asked || (found && found.branch) || null;
1275
+ const branch_source = asked ? 'query' : (branch ? 'task_pr' : null);
1276
+ const status = branch ? await githubPush.getPublishStatus({ branch }) : NO_PR_STATUS;
1262
1277
  // task 1049 (B — server-side reconcile-on-read): when GitHub shows the PR
1263
1278
  // MERGED and deploy-prod SUCCEEDED, finish the task right here so a
1264
1279
  // confirmed-but-unshipped task never strands waiting on a human to re-poll
@@ -1318,7 +1333,7 @@ module.exports = function buildTasksRouter() {
1318
1333
  log.error('[gds] publish-status merge-driver (non-blocking):', e && e.message);
1319
1334
  }
1320
1335
  }
1321
- return res.json({ ok: true, ...status, reconciled, reconcile_blocked, merge_driver });
1336
+ return res.json({ ok: true, ...status, branch, branch_source, reconciled, reconcile_blocked, merge_driver });
1322
1337
  } catch (e) {
1323
1338
  if (e.code === 'NO_REPO_INFO') return res.fail('no_repo_info', 500);
1324
1339
  if (e.code === 'APP_NOT_INSTALLED') return res.fail('push_unconfigured', { status: 503, message: appNotInstalledMessage(e) });
@@ -15,6 +15,9 @@
15
15
  // touches[] was empty, leaving empty-touches tasks unprotected — with this
16
16
  // plain clean-tree check. Strictly stronger; untracked / .gitignored files
17
17
  // are ignored so autonomous sessions with scratch files aren't refused.)
18
+ // Ignored, but no longer UNMENTIONED: untrackedLeftBehind +
19
+ // untrackedWarningLines (task 1001436) name the untracked files a ship
20
+ // with committed work is about to leave behind, as a warning only.
18
21
  //
19
22
  // 2. cleanupMacFinderDupRefs — find + delete `.git/refs/.../FOO 2` files
20
23
  // that macOS Finder / iCloud creates when it duplicates a regular file
@@ -96,6 +99,91 @@ function classifyEmptyShip(porcelainOut, { allowEmpty = false } = {}) {
96
99
  return { refuse: true, reason: 'nothing-to-ship', uncommitted: [] };
97
100
  }
98
101
 
102
+ // ---- The RESIDUAL phantom ship (task 1001436) --------------------------------
103
+ //
104
+ // classifyEmptyShip closes the case where NOTHING is committed and the
105
+ // deliverable sits uncommitted. It cannot see the weaker case beside it: the
106
+ // builder commits real work, so the branch is ahead of main and the ship goes
107
+ // ahead, but one more NEW file was never `git add`ed. The merge carries only
108
+ // committed history, so that file silently does not ship — the task reads
109
+ // "shipped" with part of its artifact missing, and nothing said so.
110
+ //
111
+ // The answer is a loud WARNING, never a refusal. trackedDirty ignores untracked
112
+ // files on purpose: autonomous sessions leave scratch in the tree, and refusing
113
+ // on it would false-refuse real ships. So this only names the files — before the
114
+ // grade and the merge, while stopping is still free.
115
+ //
116
+ // SCOPE. The filing suggested narrowing the list to touches[] or to docs/ src/
117
+ // scripts/. Neither is used, because each hides exactly the file this exists to
118
+ // catch: touches[] is advisory and often empty, and a forgotten file is by
119
+ // definition one nobody thought to declare; a directory allowlist misses tests/,
120
+ // modules/ and migrations/, where a forgotten new file most often lives (a new
121
+ // test beside a committed fix). The one SYSTEMATIC source of noise is the ship's
122
+ // own input — the builder-ship skill writes notes.md + summary.txt into the tree
123
+ // and passes them as --notes-file / --summary-file — so those are excluded
124
+ // exactly, by path, via `inputs`. Surveyed before choosing (2026-09-25, 16 live
125
+ // worktrees): 12 untracked entries, none of them root-level scratch, and 5 under
126
+ // tests/ or modules/ — where a docs/ src/ scripts/ allowlist would have hidden them.
127
+
128
+ // Pure: the untracked ('??') paths in raw `git status --porcelain` output, in
129
+ // output order. .gitignored files never appear without --ignored, so this is
130
+ // exactly "untracked and not ignored". Quoted paths are unquoted the same way
131
+ // parsePorcelain does it.
132
+ function untrackedPaths(porcelainOut) {
133
+ if (!porcelainOut) return [];
134
+ const files = [];
135
+ for (const raw of porcelainOut.split('\n')) {
136
+ if (raw.slice(0, 2) !== '??') continue;
137
+ const file = raw.slice(3).replace(/^"|"$/g, '');
138
+ if (file) files.push(file);
139
+ }
140
+ return files;
141
+ }
142
+
143
+ // One spelling for a repo-relative path, so an input given as `./notes.md` or
144
+ // with Windows separators still matches porcelain's `notes.md`.
145
+ function normalizeRelPath(p) {
146
+ return String(p || '').replace(/\\/g, '/').replace(/^(\.\/)+/, '');
147
+ }
148
+
149
+ // Pure: which untracked files will be LEFT BEHIND by this ship.
150
+ // committedFileCount — files in the branch's committed diff vs the grader
151
+ // baseline. Zero returns [] on purpose: with nothing committed,
152
+ // classifyEmptyShip already REFUSES and lists these same files, and a second
153
+ // message about them would only compete with the refusal.
154
+ // inputs — repo-relative paths the ship itself was handed (notes, summary,
155
+ // response, visual). They are inputs to the ship, not deliverables.
156
+ function untrackedLeftBehind(porcelainOut, { committedFileCount = 0, inputs = [] } = {}) {
157
+ if (!(Number(committedFileCount) > 0)) return [];
158
+ const skip = new Set((inputs || []).map(normalizeRelPath).filter(Boolean));
159
+ return untrackedPaths(porcelainOut).filter((f) => !skip.has(normalizeRelPath(f)));
160
+ }
161
+
162
+ // Pure: the warning block for untrackedLeftBehind's result; [] when there is
163
+ // nothing to say. `cap` bounds the listing so a tree full of scratch cannot bury
164
+ // the rest of the ship output. The wording holds on all three preflight callers
165
+ // (first ship, --regrade, the confirmed-task resume): each runs this before it
166
+ // resolves, grades or merges anything.
167
+ function untrackedWarningLines(files, { taskId = null, cap = 15 } = {}) {
168
+ if (!files || files.length === 0) return [];
169
+ const n = files.length;
170
+ const task = taskId != null && taskId !== '' ? ` with task ${taskId}` : '';
171
+ const lines = [
172
+ '',
173
+ `⚠ Untracked-file warning: ${n} untracked file(s) in this worktree will NOT ship${task}:`,
174
+ ];
175
+ for (const f of files.slice(0, cap)) lines.push(` - ${f}`);
176
+ if (n > cap) lines.push(` … and ${n - cap} more`);
177
+ lines.push(
178
+ ' The merge carries committed work only, so these stay behind.',
179
+ ' If one of them belongs to this task: stop now (Ctrl-C — nothing has been resolved at this step),',
180
+ ' commit it, and re-run. If they are scratch, ignore this.',
181
+ ' Warning only — not blocking; the ship proceeds.',
182
+ '',
183
+ );
184
+ return lines;
185
+ }
186
+
99
187
  function gitDirFor(cwd) {
100
188
  // Resolve the *actual* refs directory for a given worktree. In a linked
101
189
  // worktree, `.git` is a file containing `gitdir: <path>` that points at
@@ -218,6 +306,9 @@ module.exports = {
218
306
  parsePorcelain,
219
307
  trackedDirty,
220
308
  classifyEmptyShip,
309
+ untrackedPaths,
310
+ untrackedLeftBehind,
311
+ untrackedWarningLines,
221
312
  cleanupMacFinderDupRefs,
222
313
  gitDirFor,
223
314
  findMainWorktree,
@@ -24,6 +24,8 @@
24
24
  // that file sits on the oversized-file ratchet; a guard whose whole value is being
25
25
  // read before it is edited should not be the thing that reds the build either.
26
26
 
27
+ const { PublicRefusal } = require('./public-refusal');
28
+
27
29
  // The only acts that cannot destroy the box they run on.
28
30
  //
29
31
  // core-upgrade — moves the pin, health-gated, auto-rollback on failure. This is
@@ -86,25 +88,24 @@ function intentAllowedForShape(action, hostingShape) {
86
88
  * @param {{query: Function}} db
87
89
  * @param {number|string} instanceId
88
90
  * @param {string} action an INTENT_ACTIONS member
89
- * @throws {Error} code 'control_plane_protected'
91
+ * @throws {PublicRefusal} code 'control_plane_protected', status 409
90
92
  */
91
93
  async function assertIntentAllowed(db, instanceId, action) {
92
94
  const { rows } = await db.query(
93
95
  'SELECT hosting_shape FROM provisioning_instances WHERE id = $1', [instanceId]);
94
96
  const shape = rows[0] && rows[0].hosting_shape;
95
97
  if (!shape || intentAllowedForShape(action, shape)) return;
96
- // It carries the { code, status, message } shape credential-preflight.js established,
97
- // so a route that wants to answer 409 with the sentence can, without re-deriving it.
98
- // NO ROUTE MAPS IT TODAY, and that is the honest state rather than an oversight: every
99
- // action that can reach this throw has its own earlier refusal, so arriving here means
100
- // a route forgot one — a bug in us, not a thing the caller did. A 500 plus a logged
101
- // stack is the right loudness for that, and the fields are here so the day a route
102
- // legitimately needs to say it to a person, the wording is already written.
103
- const err = new Error(`'${action}' cannot be aimed at the control plane — only ${CONTROL_PLANE_ACTIONS.join(' and ')} may run against the box the platform itself runs on`);
104
- err.code = 'control_plane_protected';
105
- err.status = 409;
106
- err.publicMessage = `That action cannot be aimed at the machine the platform itself runs on. Only ${CONTROL_PLANE_ACTIONS.join(' and ')} are allowed there.`;
107
- throw err;
98
+ // A PublicRefusal, so every route answers it as a 409 in the owner's words (task
99
+ // 1004126). This used to rest on "every action that can reach this throw has its own
100
+ // earlier refusal", and that did not hold: teardown, force-teardown and disconnect reach
101
+ // it with none, and the owner read "internal error". Each route still logs the stack, so
102
+ // a route that forgot its own check stays as visible to us as it was.
103
+ throw new PublicRefusal({
104
+ code: 'control_plane_protected',
105
+ status: 409,
106
+ publicMessage: `That action cannot be aimed at the machine the platform itself runs on. Only ${CONTROL_PLANE_ACTIONS.join(' and ')} are allowed there.`,
107
+ message: `'${action}' cannot be aimed at the control plane — only ${CONTROL_PLANE_ACTIONS.join(' and ')} may run against the box the platform itself runs on`,
108
+ });
108
109
  }
109
110
 
110
111
  module.exports = { CONTROL_PLANE_ACTIONS, intentAllowedForShape, assertIntentAllowed };
@@ -29,6 +29,7 @@
29
29
  // AT the 1500-line fitness ratchet, and at that ceiling a new function costs more
30
30
  // than the build will give back.
31
31
 
32
+ const { failFrom } = require('./public-refusal');
32
33
 
33
34
  // Exactly the states in which there is a running instance whose pin can move.
34
35
  // Narrower than RESTART_FROM on purpose: an instance mid-standup has no settled pin
@@ -209,18 +210,10 @@ function makeCoreUpgradeHandler({ provisioning, pool, parseId, validateOrRespond
209
210
  });
210
211
  } catch (err) {
211
212
  log.error('[provisioning] POST /provisioning/instances/:id/core-upgrade', err);
212
- // A REFUSAL IS NOT AN OUTAGE. The control-plane guard throws an error carrying
213
- // `code`, `status` and a `publicMessage` it wrote for exactly this moment — and
214
- // this catch used to discard all three, so every guard refusal reached the owner
215
- // as a 500 "internal error" while the real sentence sat in the journal. Nothing
216
- // in the repo honoured `publicMessage`; it was assigned once and read never
217
- // (task 1004125). Only an error that has already declared itself public is
218
- // forwarded — anything else still gets the opaque 500, because an unexpected
219
- // throw may carry anything.
220
- if (err && err.publicMessage && err.code && err.status) {
221
- return res.fail(err.code, { status: err.status, message: err.publicMessage });
222
- }
223
- return res.fail('core_upgrade_failed', { status: 500, message: 'internal error' });
213
+ // A REFUSAL IS NOT AN OUTAGE (task 1004125): a guard refusal reaches the owner as
214
+ // its own sentence, and an unexpected throw, which may carry anything, stays the
215
+ // opaque 500. The one definition of "public" is public-refusal.js (task 1004126).
216
+ return failFrom(res, err, 'core_upgrade_failed');
224
217
  }
225
218
  };
226
219
  }
@@ -256,6 +249,23 @@ function lastMoveSuperseded(target, served, recommended) {
256
249
  return !!(recommended && String(recommended) !== String(target));
257
250
  }
258
251
 
252
+ /**
253
+ * The note a DONE move carries when the upgrade landed but its pin never reached the
254
+ * project's GitHub repo (task 1004065) — `{ code, text }`, or null. PURE.
255
+ *
256
+ * The runner writes it into the intent's last_error as `repo_not_updated:<code>: <text>`
257
+ * (scripts/gds/provision-core-upgrade.js repoNote; re-stated here because a module may
258
+ * not require a script, and round-tripped by tests/core_upgrade_runner.mjs). ONLY that
259
+ * shape is read: any other text on a done row is not turned into a warning, because
260
+ * inventing one would tell an owner their repo is stale on no evidence.
261
+ */
262
+ const REPO_NOTE_RE = /^repo_not_updated:([a-z_]+): ([\s\S]+)$/;
263
+ function parseRepoNote(text) {
264
+ const m = REPO_NOTE_RE.exec(String(text || ''));
265
+ // Bounded because it reaches a DOM, like the error beside it.
266
+ return m ? { code: m[1], text: m[2].slice(0, 600) } : null;
267
+ }
268
+
259
269
  /**
260
270
  * The cached dry-run verdict, IF it is about the move now on offer. PURE (task 1004173).
261
271
  *
@@ -344,8 +354,11 @@ function previewFromSnapshot(inst, snap, openIntent, lastMove) {
344
354
  // versions, and this is a version question wearing a different hat.
345
355
  superseded: lastMoveSuperseded(lastMove.target_version, served, recommended),
346
356
  // Truncated because it reaches a DOM and upgrade.js's refusals can carry a whole
347
- // `git status` (the dirty-tree one lists every file).
348
- error: lastMove.last_error ? String(lastMove.last_error).slice(0, 600) : null,
357
+ // `git status` (the dirty-tree one lists every file). Never set on a DONE move:
358
+ // a done row's last_error is the note below, and a move that worked is not an error.
359
+ error: lastMove.state !== 'done' && lastMove.last_error ? String(lastMove.last_error).slice(0, 600) : null,
360
+ // It LANDED, but GitHub did not get the new pin (task 1004065) — parseRepoNote.
361
+ warning: lastMove.state === 'done' ? parseRepoNote(lastMove.last_error) : null,
349
362
  } : null,
350
363
  };
351
364
  }
@@ -379,7 +392,7 @@ function makeCoreUpgradePreviewHandler({ provisioning, pool, parseId, log }) {
379
392
  return res.json({ ok: true, preview: await loadPreview(pool, provisioning, inst) });
380
393
  } catch (err) {
381
394
  log.error('[provisioning] GET /provisioning/instances/:id/core-upgrade', err);
382
- return res.fail('core_upgrade_preview_failed', { status: 500, message: 'internal error' });
395
+ return failFrom(res, err, 'core_upgrade_preview_failed');
383
396
  }
384
397
  };
385
398
  }
@@ -410,10 +423,10 @@ function makeControlPlaneHandler({ provisioning, pool, log }) {
410
423
  return res.json({ ok: true, preview: await loadPreview(pool, provisioning, rows[0]) });
411
424
  } catch (err) {
412
425
  log.error('[provisioning] GET /provisioning/control-plane', err);
413
- return res.fail('control_plane_read_failed', { status: 500, message: 'internal error' });
426
+ return failFrom(res, err, 'control_plane_read_failed');
414
427
  }
415
428
  };
416
429
  }
417
430
 
418
- module.exports = { CORE_UPGRADE_FROM, lastCoreMove, lastMoveSuperseded, preflightForOffer, UPGRADE_SHAPES, VERSION_RE, isCoreVersion, coreUpgradeFault, enqueueCoreUpgrade, makeCoreUpgradeHandler, previewFromSnapshot, makeCoreUpgradePreviewHandler, makeControlPlaneHandler };
431
+ module.exports = { CORE_UPGRADE_FROM, lastCoreMove, lastMoveSuperseded, parseRepoNote, preflightForOffer, UPGRADE_SHAPES, VERSION_RE, isCoreVersion, coreUpgradeFault, enqueueCoreUpgrade, makeCoreUpgradeHandler, previewFromSnapshot, makeCoreUpgradePreviewHandler, makeControlPlaneHandler };
419
432
 
@@ -0,0 +1,44 @@
1
+ 'use strict';
2
+ // modules/provisioning/public-refusal.js — the one definition of an error written for the
3
+ // person who asked, and the one way a catch in this module answers (task 1004126).
4
+ //
5
+ // WHY. A refusal such as the control-plane guard's carries a code, a 4xx and a sentence
6
+ // written for the owner. Every catch in this module used to answer a blanket 500
7
+ // "internal error" instead, so the owner never read that sentence; it went only to the
8
+ // journal. Forwarding every thrown error would be worse, because a Postgres error names
9
+ // tables. So an error has to DECLARE itself public by being a PublicRefusal, and
10
+ // everything else still gets today's opaque 500.
11
+ //
12
+ // A CLASS, NOT A DUCK TYPE. Task 1004125 fixed one catch by testing
13
+ // `code && status && publicMessage`, which anything can carry by accident. The class is
14
+ // the single definition of "public"; failFrom is the single place that honours it.
15
+
16
+ // A refusal a route may repeat to the person verbatim. `publicMessage` is theirs;
17
+ // `message` stays in the journal (it defaults to the public sentence). A refusal is never
18
+ // an outage, so the status must be a 4xx.
19
+ class PublicRefusal extends Error {
20
+ constructor({ code, status, publicMessage, message } = {}) {
21
+ if (!(Number.isInteger(status) && status >= 400 && status < 500)) {
22
+ throw new TypeError(`a PublicRefusal is a 4xx, got ${status}: an outage is not a refusal`);
23
+ }
24
+ if (!code || !publicMessage) throw new TypeError('a PublicRefusal needs a code and a publicMessage');
25
+ super(message || publicMessage);
26
+ this.name = 'PublicRefusal';
27
+ this.code = code;
28
+ this.status = status;
29
+ this.publicMessage = publicMessage;
30
+ }
31
+ }
32
+
33
+ function isPublicRefusal(err) {
34
+ return err instanceof PublicRefusal;
35
+ }
36
+
37
+ // failFrom — answer a caught error: a PublicRefusal as its own code, status and public
38
+ // sentence; anything else as the opaque 500 under the route's own fallbackCode.
39
+ function failFrom(res, err, fallbackCode) {
40
+ if (isPublicRefusal(err)) return res.fail(err.code, { status: err.status, message: err.publicMessage });
41
+ return res.fail(fallbackCode, { status: 500, message: 'internal error' });
42
+ }
43
+
44
+ module.exports = { PublicRefusal, isPublicRefusal, failFrom };
@@ -11,6 +11,7 @@ const api = require('../../../src/module-api');
11
11
  const { pool, validateOrRespond, parseId } = api;
12
12
  const provisioning = require('../provisioning');
13
13
  const disconnect = require('../disconnect');
14
+ const { failFrom } = require('../public-refusal');
14
15
 
15
16
  const log = api.logger('provisioning');
16
17
 
@@ -55,7 +56,7 @@ module.exports = function buildDisconnectRouter() {
55
56
  : 'A disconnect is already queued for this project.' });
56
57
  } catch (err) {
57
58
  log.error('[provisioning] POST /provisioning/instances/:id/disconnect', err);
58
- res.fail('disconnect_failed', { status: 500, message: 'internal error' });
59
+ failFrom(res, err, 'disconnect_failed');
59
60
  }
60
61
  });
61
62
 
@@ -24,6 +24,7 @@ const api = require('../../../src/module-api');
24
24
  const { pool, validateOrRespond, parseId } = api;
25
25
  const provisioning = require('../provisioning');
26
26
  const envManifest = require('../env-manifest');
27
+ const { failFrom } = require('../public-refusal');
27
28
 
28
29
  const log = api.logger('provisioning');
29
30
 
@@ -119,7 +120,7 @@ module.exports = function buildEnvManifestRouter() {
119
120
  });
120
121
  } catch (err) {
121
122
  log.error('[provisioning] GET /provisioning/instances/:id/env-manifest', err);
122
- res.fail('env_manifest_failed', { status: 500, message: 'internal error' });
123
+ failFrom(res, err, 'env_manifest_failed');
123
124
  }
124
125
  });
125
126
 
@@ -147,7 +148,7 @@ module.exports = function buildEnvManifestRouter() {
147
148
  res.status(created ? 201 : 200).json({ ok: true, created, env_var: row, values_held: false });
148
149
  } catch (err) {
149
150
  log.error('[provisioning] POST /provisioning/instances/:id/env-manifest', err);
150
- res.fail('env_manifest_failed', { status: 500, message: 'internal error' });
151
+ failFrom(res, err, 'env_manifest_failed');
151
152
  }
152
153
  });
153
154
 
@@ -174,7 +175,7 @@ module.exports = function buildEnvManifestRouter() {
174
175
  res.json({ ok: true, env_var: row, values_held: false });
175
176
  } catch (err) {
176
177
  log.error('[provisioning] PATCH /provisioning/instances/:id/env-manifest/:varId', err);
177
- res.fail('env_manifest_failed', { status: 500, message: 'internal error' });
178
+ failFrom(res, err, 'env_manifest_failed');
178
179
  }
179
180
  });
180
181
 
@@ -193,7 +194,7 @@ module.exports = function buildEnvManifestRouter() {
193
194
  message: 'Declaration removed. The variable\'s value, wherever you keep it, is untouched — this platform never had it.' });
194
195
  } catch (err) {
195
196
  log.error('[provisioning] DELETE /provisioning/instances/:id/env-manifest/:varId', err);
196
- res.fail('env_manifest_failed', { status: 500, message: 'internal error' });
197
+ failFrom(res, err, 'env_manifest_failed');
197
198
  }
198
199
  });
199
200
 
@@ -62,6 +62,7 @@ const capacity = require('../capacity'); // the box's slot ceiling (ADR 0285,
62
62
  const { callbackPage } = require('./callback-page');
63
63
  // the LOUD body validators (task 1003504) — lifted out when this file hit the size ratchet
64
64
  const { badProjectDetail, badModuleSelection, badProjectType } = require('./body-validators');
65
+ const { failFrom } = require('../public-refusal'); // every catch: a declared refusal reaches the owner, anything else stays opaque (task 1004126)
65
66
  // the kernel ports this module provides (moved out at the size ratchet, task 1003578)
66
67
  const { registerProvisioningSeams } = require('../seams');
67
68
  // task 1003208: structured logging (pino via the doorway) — was console.*.
@@ -211,7 +212,7 @@ module.exports = function provisioningRoutes() {
211
212
  res.json({ instances: rows.map(fleetInstance), capacity: await capacity.fleetCapacity(pool).catch(() => null) });
212
213
  } catch (err) {
213
214
  log.error('[provisioning] GET /provisioning/fleet', err);
214
- res.fail('fleet_failed', { status: 500, message: 'internal error' });
215
+ failFrom(res, err, 'fleet_failed');
215
216
  }
216
217
  });
217
218
 
@@ -226,7 +227,7 @@ module.exports = function provisioningRoutes() {
226
227
  res.json(out);
227
228
  } catch (err) {
228
229
  log.error('[provisioning] GET /provisioning/fleet/cost-ledger', err);
229
- res.fail('cost_ledger_failed', { status: 500, message: 'internal error' });
230
+ failFrom(res, err, 'cost_ledger_failed');
230
231
  }
231
232
  });
232
233
 
@@ -279,7 +280,7 @@ module.exports = function provisioningRoutes() {
279
280
  message: r.created ? 'The instance will be torn down shortly.' : 'A teardown is already queued for that instance.' });
280
281
  } catch (err) {
281
282
  log.error('[provisioning] POST /provisioning/instances/:id/force-teardown', err);
282
- res.fail('teardown_failed', { status: 500, message: 'internal error' });
283
+ failFrom(res, err, 'teardown_failed');
283
284
  }
284
285
  });
285
286
 
@@ -518,7 +519,7 @@ module.exports = function provisioningRoutes() {
518
519
  // Another instance took the address between the check above and the INSERT (task 1002901).
519
520
  if (provisioning.isDomainTaken(err)) return res.fail('domain_taken', { status: 409, message: 'That address is already held by another instance.' });
520
521
  log.error('[provisioning] POST /provisioning/instances', err);
521
- res.fail('request_failed', { status: 500, message: 'internal error' });
522
+ failFrom(res, err, 'request_failed');
522
523
  }
523
524
  });
524
525
 
@@ -566,7 +567,7 @@ module.exports = function provisioningRoutes() {
566
567
  res.json({ slug, available: reason === null, reason, address: address || null });
567
568
  } catch (err) {
568
569
  log.error('[provisioning] GET /provisioning/slug-available', err);
569
- res.fail('check_failed', { status: 500, message: 'internal error' });
570
+ failFrom(res, err, 'check_failed');
570
571
  }
571
572
  });
572
573
 
@@ -586,7 +587,7 @@ module.exports = function provisioningRoutes() {
586
587
  res.json({ instances });
587
588
  } catch (err) {
588
589
  log.error('[provisioning] GET /provisioning/instances', err);
589
- res.fail('list_failed', { status: 500, message: 'internal error' });
590
+ failFrom(res, err, 'list_failed');
590
591
  }
591
592
  });
592
593
 
@@ -630,7 +631,7 @@ module.exports = function provisioningRoutes() {
630
631
  });
631
632
  } catch (err) {
632
633
  log.error('[provisioning] GET /provisioning/instances/:id', err);
633
- res.fail('read_failed', { status: 500, message: 'internal error' });
634
+ failFrom(res, err, 'read_failed');
634
635
  }
635
636
  });
636
637
 
@@ -750,7 +751,7 @@ module.exports = function provisioningRoutes() {
750
751
  });
751
752
  } catch (err) {
752
753
  log.error('[provisioning] PATCH /provisioning/instances/:id/detail', err);
753
- res.fail('detail_update_failed', { status: 500, message: 'internal error' });
754
+ failFrom(res, err, 'detail_update_failed');
754
755
  }
755
756
  });
756
757
 
@@ -805,7 +806,7 @@ module.exports = function provisioningRoutes() {
805
806
  res.json({ suggested, seeking: invites.seeking, reason: invites.reason });
806
807
  } catch (err) {
807
808
  log.error('[provisioning] GET /provisioning/instances/:id/invite-suggestions', err);
808
- res.fail('invite_suggestions_failed', { status: 500, message: 'internal error' });
809
+ failFrom(res, err, 'invite_suggestions_failed');
809
810
  }
810
811
  });
811
812
 
@@ -830,7 +831,7 @@ module.exports = function provisioningRoutes() {
830
831
  options: { ...provisioning.SETTINGS_VOCAB, ...provisioning.PLANET_VOCAB } });
831
832
  } catch (err) {
832
833
  log.error('[provisioning] GET /provisioning/instances/:id/settings', err);
833
- res.fail('settings_read_failed', { status: 500, message: 'internal error' });
834
+ failFrom(res, err, 'settings_read_failed');
834
835
  }
835
836
  });
836
837
 
@@ -983,7 +984,7 @@ module.exports = function provisioningRoutes() {
983
984
  res.json({ ok: true, saved: provisioning.effectiveSettings(updated), planet: provisioning.planetChoice(updated), push });
984
985
  } catch (err) {
985
986
  log.error('[provisioning] PATCH /provisioning/instances/:id/settings', err);
986
- res.fail('settings_update_failed', { status: 500, message: 'internal error' });
987
+ failFrom(res, err, 'settings_update_failed');
987
988
  }
988
989
  });
989
990
 
@@ -1020,7 +1021,7 @@ module.exports = function provisioningRoutes() {
1020
1021
  message: r.created ? 'Your instance will be torn down shortly.' : 'A teardown is already queued for that instance.' });
1021
1022
  } catch (err) {
1022
1023
  log.error('[provisioning] POST /provisioning/instances/:id/teardown', err);
1023
- res.fail('teardown_failed', { status: 500, message: 'internal error' });
1024
+ failFrom(res, err, 'teardown_failed');
1024
1025
  }
1025
1026
  });
1026
1027
 
@@ -1079,7 +1080,7 @@ module.exports = function provisioningRoutes() {
1079
1080
  : 'A restart is already under way for this project.' });
1080
1081
  } catch (err) {
1081
1082
  log.error('[provisioning] POST /provisioning/instances/:id/restart', err);
1082
- res.fail('restart_failed', { status: 500, message: 'internal error' });
1083
+ failFrom(res, err, 'restart_failed');
1083
1084
  }
1084
1085
  });
1085
1086
 
@@ -1224,7 +1225,7 @@ module.exports = function provisioningRoutes() {
1224
1225
  // Same race on the attach side: the index refuses the UPDATE, so nothing is recorded and nothing is enqueued.
1225
1226
  if (provisioning.isDomainTaken(err)) return res.fail('domain_taken', { status: 409, message: 'That address is already in use by another instance.' });
1226
1227
  log.error('[provisioning] POST /provisioning/instances/:id/domain', err);
1227
- res.fail('domain_attach_failed', { status: 500, message: 'internal error' });
1228
+ failFrom(res, err, 'domain_attach_failed');
1228
1229
  }
1229
1230
  });
1230
1231
 
@@ -1282,7 +1283,7 @@ module.exports = function provisioningRoutes() {
1282
1283
  });
1283
1284
  } catch (err) {
1284
1285
  log.error('[provisioning] POST /provisioning/instances/:id/github-app', err);
1285
- res.fail('github_app_manifest_failed', { status: 500, message: 'internal error' });
1286
+ failFrom(res, err, 'github_app_manifest_failed');
1286
1287
  }
1287
1288
  });
1288
1289
 
@@ -16,6 +16,7 @@ const api = require('../../../src/module-api');
16
16
  const { pool, validateOrRespond, parseId } = api;
17
17
  const provisioning = require('../provisioning');
18
18
  const repoPrivate = require('../repo-private');
19
+ const { failFrom } = require('../public-refusal');
19
20
 
20
21
  const log = api.logger('provisioning');
21
22
 
@@ -74,7 +75,7 @@ module.exports = function buildRepoPrivateRouter() {
74
75
  : 'Making the repository private is already under way for this project.' });
75
76
  } catch (err) {
76
77
  log.error('[provisioning] POST /provisioning/instances/:id/repo-private', err);
77
- res.fail('repo_private_failed', { status: 500, message: 'internal error' });
78
+ failFrom(res, err, 'repo_private_failed');
78
79
  }
79
80
  });
80
81
 
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1032",
3
+ "version": "1.19.1034",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.1032",
9
+ "version": "1.19.1034",
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.1032",
3
+ "version": "1.19.1034",
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",