@bongos/core 1.20.64 → 1.20.66

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.
@@ -0,0 +1,182 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/goal-move.js — move a goal, with everything it holds, to
4
+ // another version (task 1004485).
5
+ //
6
+ // WHY. A goal's version was write-once. PATCH /goals/:id edits prose, never
7
+ // version_id, and PATCH /tasks/:id refuses `goal_version_mismatch` — so the only
8
+ // way to re-home a goal that did not make its version (the 2026-10-01 case:
9
+ // goals 1000122 and 1000123 deferred from BONGOS-V2 to BONGOS-V3) was to recreate
10
+ // it and its tasks under new ids, or a hand-written prod UPDATE.
11
+ //
12
+ // THE GOAL MOVES WHOLE. The goal row, every done-when criterion parented to it,
13
+ // and every task in it — terminal ones included — change version in ONE
14
+ // transaction. Two half-moves are the failure shapes this exists to avoid:
15
+ // • tasks left behind would sit in a goal of another version, which PATCH
16
+ // /tasks/:id refuses for exactly that reason (a task joins a goal in its own
17
+ // version only);
18
+ // • a task moved by goal_id but not version_id is the carryBugsForward lesson
19
+ // (db-goals.js, BV1.R18c): `version_progress` joins on tasks.version_id, so a
20
+ // half-moved task keeps counting against the version it left.
21
+ //
22
+ // WHAT IS REFUSED, and why:
23
+ // • a maintenance goal — only carryBugsForward moves those, at version close;
24
+ // • a goal that is not open — achieved/archived goals are history;
25
+ // • a goal on a SHIPPED version — moving it would rewrite closed scope-truth;
26
+ // • the same version — a no-op dressed as a write;
27
+ // • a target that would refuse a new goal — the SAME gate POST /goals uses
28
+ // (authorizeGoalCreate), so "may this version take this goal" has one answer.
29
+ // A building target therefore needs the Archon `admit_reason` override, and
30
+ // the admission is recorded exactly as a create's is;
31
+ // • a criterion link that crosses the goal's edge (a task inside linked to a
32
+ // criterion outside, or the reverse) — after the move it would join two
33
+ // versions, and /status would count it against the wrong one. The caller
34
+ // unlinks it first; the refusal names every pair.
35
+ // An ACTIVE CLAIM is not a refusal: re-pointing the version takes no work from
36
+ // anyone (the claim references the task, untouched) — carryBugsForward's
37
+ // reasoning, for the same act.
38
+ //
39
+ // Lives in its own file because routes/goals.js sits at the fitness oversize
40
+ // line (1,500) and db-goals.js near it; a move is a cohesive slice, the
41
+ // goal-edits.js precedent.
42
+
43
+ const api = require('../../src/module-api');
44
+ const { executorFor } = require('./db-executor.js');
45
+ const { authorizeGoalCreate } = require('./routes/goal-route-authz.js');
46
+ const { pool } = api;
47
+
48
+ // authorizeGoalMove — PURE, FAIL-CLOSED. May this goal move to this version?
49
+ // The route gathers the facts; this decides; the route renders the verdict.
50
+ function authorizeGoalMove({ goal, source, target, admitReason = null }) {
51
+ if (!goal) {
52
+ return { ok: false, status: 404, body: { error: 'goal_not_found', message: 'No such goal.' } };
53
+ }
54
+ if (goal.is_maintenance) {
55
+ return { ok: false, status: 409, body: {
56
+ error: 'maintenance_goal',
57
+ message: `Goal #${goal.id} is its version's maintenance goal — it moves only with the version close (carry-forward), never by hand.`,
58
+ } };
59
+ }
60
+ if (goal.status !== 'open') {
61
+ return { ok: false, status: 409, body: {
62
+ error: 'goal_not_open', goal_status: goal.status,
63
+ message: `Goal #${goal.id} is '${goal.status}' — only an open goal can move to another version.`,
64
+ } };
65
+ }
66
+ if (source && source.status === 'shipped') {
67
+ return { ok: false, status: 409, body: {
68
+ error: 'source_version_shipped',
69
+ message: `Goal #${goal.id} belongs to '${source.id}', which has shipped — its goals are closed history.`,
70
+ } };
71
+ }
72
+ if (target && String(target.id) === String(goal.version_id)) {
73
+ return { ok: false, status: 409, body: {
74
+ error: 'same_version', message: `Goal #${goal.id} is already on '${target.id}'.`,
75
+ } };
76
+ }
77
+ const decision = authorizeGoalCreate({ version: target, override: admitReason !== null });
78
+ if (!decision.ok) return decision;
79
+ return { ok: true, overrideApplied: decision.overrideApplied };
80
+ }
81
+
82
+ // moveGoalToVersion — the write, in ONE transaction. Returns
83
+ // { fromVersionId, toVersionId, taskIds, criterionIds }. Throws a coded error the
84
+ // route translates:
85
+ // GOAL_NOT_FOUND, GOAL_CHANGED (status/version moved since the route checked),
86
+ // TARGET_CHANGED (the target's status moved since the route checked),
87
+ // CRITERION_LINKS_CROSS_GOAL (with .links), CRITERION_ID_COLLISION (a criterion
88
+ // slug the target version already uses — UNIQUE (version_id, criterion_id)).
89
+ async function moveGoalToVersion({ goalId, fromVersionId, toVersionId, targetStatus, admission = null }, deps = {}) {
90
+ const client = await executorFor(deps, pool).connect();
91
+ try {
92
+ await client.query('BEGIN');
93
+
94
+ const { rows: g } = await client.query(
95
+ `SELECT id, version_id, status FROM goals WHERE id = $1 FOR UPDATE`, [goalId]
96
+ );
97
+ if (!g[0]) throw Object.assign(new Error('goal not found'), { code: 'GOAL_NOT_FOUND' });
98
+ if (String(g[0].version_id) !== String(fromVersionId) || g[0].status !== 'open') {
99
+ throw Object.assign(new Error('goal changed'), { code: 'GOAL_CHANGED' });
100
+ }
101
+ // The target's status was what the gate judged; a close or promotion in
102
+ // between would let a goal into a version that now refuses it.
103
+ const { rows: v } = await client.query(
104
+ `SELECT status FROM versions WHERE id = $1 FOR SHARE`, [toVersionId]
105
+ );
106
+ if (!v[0] || v[0].status !== targetStatus) {
107
+ throw Object.assign(new Error('target changed'), { code: 'TARGET_CHANGED' });
108
+ }
109
+
110
+ // Exactly one side of the link inside this goal = the link crosses its edge.
111
+ const { rows: links } = await client.query(
112
+ `SELECT tc.task_id::text AS task_id, tc.criterion_id::text AS criterion_id
113
+ FROM task_criteria tc
114
+ JOIN tasks t ON t.id = tc.task_id
115
+ JOIN done_when_criteria c ON c.id = tc.criterion_id
116
+ WHERE (t.goal_id IS NOT DISTINCT FROM $1) <> (c.goal_id IS NOT DISTINCT FROM $1)
117
+ AND (t.goal_id = $1 OR c.goal_id = $1)
118
+ ORDER BY tc.task_id, tc.criterion_id
119
+ LIMIT 50`,
120
+ [goalId]
121
+ );
122
+ if (links.length > 0) {
123
+ throw Object.assign(new Error('criterion links cross the goal'), { code: 'CRITERION_LINKS_CROSS_GOAL', links });
124
+ }
125
+
126
+ // Appended to the end of the target's goal order rather than keeping a
127
+ // sort_order that meant something only among the old version's goals.
128
+ await client.query(
129
+ `UPDATE goals
130
+ SET version_id = $2,
131
+ sort_order = (SELECT COALESCE(MAX(sort_order), 0) + 10 FROM goals WHERE version_id = $2),
132
+ updated_at = now()
133
+ WHERE id = $1`,
134
+ [goalId, toVersionId]
135
+ );
136
+ let criterionIds;
137
+ try {
138
+ const { rows } = await client.query(
139
+ `UPDATE done_when_criteria SET version_id = $2 WHERE goal_id = $1 RETURNING id::text`,
140
+ [goalId, toVersionId]
141
+ );
142
+ criterionIds = rows.map((r) => r.id);
143
+ } catch (err) {
144
+ if (err && err.code === '23505') {
145
+ throw Object.assign(new Error('criterion id collision'), { code: 'CRITERION_ID_COLLISION' });
146
+ }
147
+ throw err;
148
+ }
149
+ const { rows: tasks } = await client.query(
150
+ `UPDATE tasks SET version_id = $2, updated_at = now() WHERE goal_id = $1 RETURNING id::text`,
151
+ [goalId, toVersionId]
152
+ );
153
+ // A building target was opened by the Archon override: recorded like a
154
+ // create's admission. goal_id is the table's key, so a goal admitted before
155
+ // carries its LATEST admission — the one that explains where it is now.
156
+ if (admission) {
157
+ await client.query(
158
+ `INSERT INTO lifecycle_goal_version_admissions (goal_id, version_id, admitted_by, reason)
159
+ VALUES ($1, $2, $3, $4)
160
+ ON CONFLICT (goal_id) DO UPDATE
161
+ SET version_id = EXCLUDED.version_id, admitted_by = EXCLUDED.admitted_by,
162
+ reason = EXCLUDED.reason, created_at = now()`,
163
+ [goalId, toVersionId, admission.admittedBy, admission.reason]
164
+ );
165
+ }
166
+
167
+ await client.query('COMMIT');
168
+ return {
169
+ fromVersionId: String(fromVersionId),
170
+ toVersionId: String(toVersionId),
171
+ taskIds: tasks.map((r) => r.id).sort((a, b) => Number(a) - Number(b)),
172
+ criterionIds: criterionIds.sort((a, b) => Number(a) - Number(b)),
173
+ };
174
+ } catch (err) {
175
+ await client.query('ROLLBACK').catch(() => {});
176
+ throw err;
177
+ } finally {
178
+ if (typeof client.release === 'function') client.release();
179
+ }
180
+ }
181
+
182
+ module.exports = { authorizeGoalMove, moveGoalToVersion };
@@ -16,6 +16,7 @@
16
16
  "done-when",
17
17
  "criterion-uat",
18
18
  "goals",
19
+ "goal-move",
19
20
  "dependencies",
20
21
  "gate-approvals",
21
22
  "analytics",
@@ -0,0 +1,92 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/routes/goal-move.js — POST /goals/:id/move (task 1004485).
4
+ //
5
+ // Moves an open goal — its criteria and every task in it — to another version.
6
+ // The rules and the write live in ../goal-move.js; this file is the doorway and
7
+ // must never grow a query (the rot.js precedent).
8
+ //
9
+ // GATE: `version.create` (Archon floor). Re-homing a goal changes the scope of
10
+ // TWO versions at once, which is the same authority as authoring a version's
11
+ // scope in the first place — so it reuses that atom rather than minting a new
12
+ // one, and adds no new authority to the system. Pinned in route-rank-check.
13
+ //
14
+ // Body: { version_id, admit_reason? } — `admit_reason` is the Archon override
15
+ // that lets a goal into a version that is already building (the POST /goals R07
16
+ // admission); on a planning target it is accepted and changes nothing.
17
+ //
18
+ // MODULE BOUNDARY: reaches core only through `../../../src/module-api`.
19
+
20
+ const express = require('express');
21
+ const api = require('../../../src/module-api');
22
+ const db = require('../db');
23
+ const goalMove = require('../goal-move');
24
+ const { validateOrRespond, parseId } = api;
25
+
26
+ const log = api.logger('lifecycle');
27
+
28
+ module.exports = function buildGoalMoveRouter() {
29
+ const router = express.Router();
30
+
31
+ // rank: archon (perm:version.create) — re-homes a goal and its tasks across versions.
32
+ router.post('/goals/:id/move', api.requireBuilder, api.requirePermission('version.create'), async (req, res) => {
33
+ if (validateOrRespond(req, res, {
34
+ version_id: { required: true, type: 'string', minLength: 1, maxLength: 64 },
35
+ admit_reason: { type: 'string', maxLength: 2000 },
36
+ })) return;
37
+ const id = parseId(req, res, { code: 'bad_goal_id' });
38
+ if (id === null) return;
39
+ const admitReason = (req.body.admit_reason || '').trim() || null;
40
+ try {
41
+ const goal = await db.getGoal(id);
42
+ const [source, target] = goal
43
+ ? await Promise.all([db.getVersion(goal.version_id), db.getVersion(req.body.version_id)])
44
+ : [null, null];
45
+ const decision = goalMove.authorizeGoalMove({ goal, source, target, admitReason });
46
+ if (!decision.ok) return res.status(decision.status).json(decision.body);
47
+
48
+ const moved = await goalMove.moveGoalToVersion({
49
+ goalId: id,
50
+ fromVersionId: goal.version_id,
51
+ toVersionId: target.id,
52
+ targetStatus: target.status,
53
+ admission: decision.overrideApplied ? { admittedBy: req.builder.id, reason: admitReason } : null,
54
+ });
55
+ const updated = await db.getGoal(id);
56
+ return res.json({
57
+ ok: true,
58
+ goal: updated,
59
+ from_version_id: moved.fromVersionId,
60
+ to_version_id: moved.toVersionId,
61
+ moved_task_ids: moved.taskIds,
62
+ moved_criterion_ids: moved.criterionIds,
63
+ admitted: decision.overrideApplied,
64
+ });
65
+ } catch (err) {
66
+ if (err && err.code === 'CRITERION_LINKS_CROSS_GOAL') {
67
+ return res.fail('criterion_links_cross_goal', {
68
+ status: 409,
69
+ message: `Goal #${id} has criterion links that cross its edge — after the move they would join two versions. Unlink them (DELETE /tasks/:id/criteria/:criterionId) or move the task into the goal first. Nothing was changed.`,
70
+ details: { links: err.links },
71
+ });
72
+ }
73
+ if (err && err.code === 'CRITERION_ID_COLLISION') {
74
+ return res.fail('criterion_id_collision', {
75
+ status: 409,
76
+ message: `A criterion on goal #${id} uses a criterion_id the target version already has. Rename one of them first. Nothing was changed.`,
77
+ });
78
+ }
79
+ if (err && (err.code === 'GOAL_CHANGED' || err.code === 'TARGET_CHANGED')) {
80
+ return res.fail('changed_during_move', {
81
+ status: 409,
82
+ message: 'The goal or the target version changed while the move was running — nothing was changed. Re-read and retry.',
83
+ });
84
+ }
85
+ if (err && err.code === 'GOAL_NOT_FOUND') return res.fail('goal_not_found', 404);
86
+ log.error(`POST /goals/:id/move: ${err && err.message}`);
87
+ return res.fail('move_failed', { status: 500, message: 'internal error' });
88
+ }
89
+ });
90
+
91
+ return router;
92
+ };
@@ -13,6 +13,8 @@
13
13
  // is pointed at an empty scratch directory so a config file in the real home (the
14
14
  // Discord webhook file among them) is not found.
15
15
 
16
+ const os = require('node:os');
17
+
16
18
  // The only names carried over from the live process.
17
19
  // PATH so node, and the child's own tooling, resolve
18
20
  // NODE_ENV production behaviour, as the live hall runs it
@@ -61,9 +63,10 @@ const MODULES_OFF = Object.freeze(PREFIXES.flatMap((pfx) => MODULE_KEYS.map((k)
61
63
  * @param {string} [o.envPrefix] the env prefix of the copied instance's branding pack, if any
62
64
  * @param {string} [o.instanceRoot] the scratch directory shaped like an instance root (config/,
63
65
  * migrations/instance/); the child reads its host content from it
66
+ * @param {Function} [o.osUser] () => the OS login name; PGUSER when none is carried
64
67
  * @returns {Record<string,string>} a fresh object, never `env` itself
65
68
  */
66
- function buildPreviewEnv({ env = process.env, port, scratchHome, instanceRoot, envPrefix } = {}) {
69
+ function buildPreviewEnv({ env = process.env, port, scratchHome, instanceRoot, envPrefix, osUser = () => os.userInfo().username } = {}) {
67
70
  if (!port) throw new TypeError('buildPreviewEnv: port is required');
68
71
  if (!scratchHome) throw new TypeError('buildPreviewEnv: scratchHome is required');
69
72
  const out = {};
@@ -71,6 +74,12 @@ function buildPreviewEnv({ env = process.env, port, scratchHome, instanceRoot, e
71
74
  if (env[name] !== undefined && env[name] !== '') out[name] = String(env[name]);
72
75
  }
73
76
  if (!out.NODE_ENV) out.NODE_ENV = 'production';
77
+ // THE LOGIN NAME IS STATED (task 1004388). With no PGUSER the database driver takes it from
78
+ // USER, which an environment built from nothing does not have, and every query fails with
79
+ // "no PostgreSQL user name specified". The OS user is exactly who peer auth checks.
80
+ if (!out.PGUSER) {
81
+ try { const u = osUser(); if (u) out.PGUSER = String(u); } catch { /* left unset: the driver's own default */ }
82
+ }
74
83
  out.PORT = String(port);
75
84
  out.HOST = '127.0.0.1';
76
85
  out.HOME = scratchHome;
@@ -217,6 +217,10 @@ function createSupervisor({
217
217
  await step('create-copy', cmds.createDbCommand());
218
218
  await step('dump', cmds.dumpCommand({ liveDb, dumpFile }));
219
219
  await step('restore', cmds.restoreCommand({ dumpFile }));
220
+ // The dump is a full copy of the live database, sessions included, and is not needed
221
+ // once restored. A restart of the live service cannot finish the stop that would have
222
+ // removed it, so it goes now (task 1004388).
223
+ try { fsImpl.rmSync(dumpFile, { force: true }); } catch { /* the stop's cleanup still removes workDir */ }
220
224
  await step('migrate', cmds.migrateCommand({ cacheRoot, version, env: childEnv, instanceDir }));
221
225
 
222
226
  set({ stage: 'launch' });
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.64",
3
+ "version": "1.20.66",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.20.64",
9
+ "version": "1.20.66",
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.20.64",
3
+ "version": "1.20.66",
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",
@@ -8513,5 +8513,17 @@
8513
8513
  "id": "1004390",
8514
8514
  "text": "When someone adds a prerequisite to a job that was already waiting in the \"available work\" list, the job used to stay on that list — a builder would pick it up, and only then be told they couldn't start it yet. The listing was"
8515
8515
  }
8516
+ ],
8517
+ "1.20.65": [
8518
+ {
8519
+ "id": "1004486",
8520
+ "text": "Previewing an older version from the deploy page now works on the real server: the preview could not log in to its copy of the database, so every page in it failed. It also no longer leaves a full copy of the live database lyi"
8521
+ }
8522
+ ],
8523
+ "1.20.66": [
8524
+ {
8525
+ "id": "1004485",
8526
+ "text": "Goals can now be moved from one version to another, taking their tasks and done-when criteria with them and keeping every task number. Used first to move the two goals that did not make V2 into V3."
8527
+ }
8516
8528
  ]
8517
8529
  }
@@ -4,8 +4,11 @@
4
4
  // Usage:
5
5
  // node scripts/gds/goal.js list [--version V] [--status open|achieved|archived|all]
6
6
  // node scripts/gds/goal.js show <id>
7
+ // node scripts/gds/goal.js move <id> --to <version> [--admit-reason "..."]
7
8
  //
8
- // Read-only. Default list filter is --status open.
9
+ // list/show are read-only; default list filter is --status open. `move` re-homes an
10
+ // open goal — its criteria and every task in it — on another version (Archon;
11
+ // POST /goals/:id/move, task 1004485).
9
12
 
10
13
  const { cliClient, requireSession, arg } = require('./cli-lib');
11
14
 
@@ -16,6 +19,7 @@ const LIST_STATUSES = ['open', 'achieved', 'archived', 'all'];
16
19
  function usageExit() {
17
20
  console.error('usage: node scripts/gds/goal.js list [--version V] [--status open|achieved|archived|all]');
18
21
  console.error(' node scripts/gds/goal.js show <id>');
22
+ console.error(' node scripts/gds/goal.js move <id> --to <version> [--admit-reason "..."]');
19
23
  process.exit(2);
20
24
  }
21
25
 
@@ -208,6 +212,38 @@ async function printTaskBuckets(api, tasks) {
208
212
  }
209
213
  }
210
214
 
215
+ // parseMoveArgs — the argv after `move`: `<id> --to <version> [--admit-reason "..."]`.
216
+ // Returns { id, body } or null (usage error). Pure, so the CLI's own validation
217
+ // is tested rather than trusted (task 1004485).
218
+ function parseMoveArgs(args) {
219
+ const id = args[0];
220
+ if (!/^\d+$/.test(id || '')) return null;
221
+ const rest = args.slice(1);
222
+ const to = arg('--to', rest);
223
+ if (!to || !/^[\w.-]+$/.test(to)) return null;
224
+ const body = { version_id: to };
225
+ const reason = arg('--admit-reason', rest);
226
+ if (reason) body.admit_reason = reason;
227
+ return { id: Number(id), body };
228
+ }
229
+
230
+ async function move(api, args) {
231
+ const parsed = parseMoveArgs(args);
232
+ if (!parsed) usageExit();
233
+ const { id, body } = parsed;
234
+ const r = await api.goals.postGoalsIdMove({ id, body });
235
+ if (!r.ok) {
236
+ console.error(`move refused (${r.status}):`, (r.data && (r.data.message || r.data.error)) || JSON.stringify(r.data));
237
+ if (r.data && r.data.details && r.data.details.links) console.error(' crossing links:', JSON.stringify(r.data.details.links));
238
+ process.exitCode = 1;
239
+ return;
240
+ }
241
+ const d = r.data;
242
+ console.log(`Goal #${d.goal.id} "${d.goal.title}" moved ${d.from_version_id} → ${d.to_version_id}${d.admitted ? ' (admitted into a building version)' : ''}.`);
243
+ console.log(` tasks moved: ${d.moved_task_ids.length}${d.moved_task_ids.length ? ' (' + d.moved_task_ids.join(', ') + ')' : ''}`);
244
+ console.log(` criteria moved: ${d.moved_criterion_ids.length}`);
245
+ }
246
+
211
247
  async function main() {
212
248
  await requireSession();
213
249
  const args = process.argv.slice(2);
@@ -219,6 +255,8 @@ async function main() {
219
255
  } else if (cmd === 'show') {
220
256
  if (!/^\d+$/.test(args[1] || '')) usageExit();
221
257
  await show(api, Number(args[1]));
258
+ } else if (cmd === 'move') {
259
+ await move(api, args.slice(1));
222
260
  } else {
223
261
  usageExit();
224
262
  }
@@ -231,4 +269,4 @@ if (require.main === module) {
231
269
  });
232
270
  }
233
271
 
234
- module.exports = { rollup, criterionGlyph, criterionState, bucketTasks, liveTasks };
272
+ module.exports = { rollup, criterionGlyph, criterionState, bucketTasks, liveTasks, parseMoveArgs };
@@ -346,6 +346,9 @@ const MODULE_EXPECTED_RANKS = {
346
346
  // builder archive/resurrect a shared workspace out from under its members.
347
347
  'POST /goals/:id/archive': 'metic+archon',
348
348
  'POST /goals/:id/reopen': 'metic+archon',
349
+ // goal re-home across versions (task 1004485) — changes two versions' scope at
350
+ // once, so it carries the version-authoring atom (Archon floor), not a goal one.
351
+ 'POST /goals/:id/move': 'perm:version.create',
349
352
  // The bounded task-authoring KEYSTONE + self-service membership (ADR 0086 §3,
350
353
  // BV1.R60/R61). These are DELIBERATELY 'any-builder': the wall is the IN-HANDLER
351
354
  // scope/protected/rank-floor gate (authorizeGoalTaskCreate + goal-scope-check),
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.20.64'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.66'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');