@bongos/core 1.19.555 → 1.19.556

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.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.555",
6
- "core_contract": "1.19.555",
7
- "source_commit": "24fee783e3642be3f89811525120bcc2714f4fe7",
5
+ "core_version": "1.19.556",
6
+ "core_contract": "1.19.556",
7
+ "source_commit": "630915928321f40355bf907504db7d3808103819",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-06T16:20:21.615Z",
9
+ "built_at": "2026-09-06T16:24:47.828Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 443,
13
13
  "agent_docs_stubbed": 24,
14
- "functional_verbatim": 2019,
14
+ "functional_verbatim": 2020,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2486,
20
- "tree_sha256": "d57c07e4504ac8b16360e27f874cfc39373a7dfc066c854f1da2e7cb84443a6f",
19
+ "file_count": 2487,
20
+ "tree_sha256": "8afebde5b8e0633b5f7bab9c710cf9377d7723223debe626b5d25141306945b8",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -2657,7 +2657,7 @@
2657
2657
  {
2658
2658
  "path": "docs/module-api-changelog.md",
2659
2659
  "mode": "0000644",
2660
- "sha256": "4fbdf9b8a0c6ed21e403472dcec14bfc4cad53d58e43dd717e6bea8531a8cb47"
2660
+ "sha256": "f11f56a37c620d20df67c30b4b52a4028f716ec76ab81f6089365a0177191095"
2661
2661
  },
2662
2662
  {
2663
2663
  "path": "docs/modules-contract.md",
@@ -5647,7 +5647,7 @@
5647
5647
  {
5648
5648
  "path": "modules/lifecycle/routes/goal-route-authz.js",
5649
5649
  "mode": "0000644",
5650
- "sha256": "be82985479547bd602dbad544b5e71a3c491cea3c7b0273394c136f1df675b37"
5650
+ "sha256": "909686de2d34b184e9676370782e4b07179b784c72eb6db4af22399051b15642"
5651
5651
  },
5652
5652
  {
5653
5653
  "path": "modules/lifecycle/routes/goals.js",
@@ -5697,7 +5697,7 @@
5697
5697
  {
5698
5698
  "path": "modules/lifecycle/routes/task-write-routes.js",
5699
5699
  "mode": "0000644",
5700
- "sha256": "0a086a473b3e0267b4ad250a98a6551c952370da5bfdf5232738e04b0eaf59d6"
5700
+ "sha256": "f8fb76f7aaf035974472c011a379b450ee001adc33c01c56ed8a38a3dc4a8fc8"
5701
5701
  },
5702
5702
  {
5703
5703
  "path": "modules/lifecycle/routes/tasks.js",
@@ -7477,12 +7477,12 @@
7477
7477
  {
7478
7478
  "path": "package-lock.json",
7479
7479
  "mode": "0000644",
7480
- "sha256": "04c6a38f5cf60c8912b9254235861cc431f7af898d7ae34924248f34c9f62ac2"
7480
+ "sha256": "9572c814d88a40b50276ab717eda1552f5e5083f619aca169a1f769ab85605e1"
7481
7481
  },
7482
7482
  {
7483
7483
  "path": "package.json",
7484
7484
  "mode": "0000644",
7485
- "sha256": "057cb53bcef464cfe8d189bbc80b9d9860a32c19b20e33a6ae58ce64b1aeb775"
7485
+ "sha256": "6ce32885b125422348ef232ab9dc1aed9b542e40331f9c34d84b985e9dc69608"
7486
7486
  },
7487
7487
  {
7488
7488
  "path": "public-docs/index.html",
@@ -9177,7 +9177,7 @@
9177
9177
  {
9178
9178
  "path": "src/module-api.js",
9179
9179
  "mode": "0000644",
9180
- "sha256": "aa7e5a37bf8cdacca8bd176ac732662b81d868df50e21930a23517c040882534"
9180
+ "sha256": "07e66085dcd49d32044edf2caca55c4a3f5ec8d9f150281ed0e97c5859a6cb6e"
9181
9181
  },
9182
9182
  {
9183
9183
  "path": "src/module-loader/catalog.js",
@@ -11322,7 +11322,7 @@
11322
11322
  {
11323
11323
  "path": "tests/patch_gate_ordering.mjs",
11324
11324
  "mode": "0000644",
11325
- "sha256": "0412f6874d6046b7f74025b2b14c90cf345e904769502c2679365cd6d1fb8b11"
11325
+ "sha256": "b0690cf28c30bfa4ef1ecc4ceffa7d4be1666dbb09a6605ceb02cc057d2a9cc4"
11326
11326
  },
11327
11327
  {
11328
11328
  "path": "tests/path_match.mjs",
@@ -12244,6 +12244,11 @@
12244
12244
  "mode": "0000644",
12245
12245
  "sha256": "514be815c0466e4e2865ad85cb7a2ca206e5b46abc7b345da2c36278667c8bb2"
12246
12246
  },
12247
+ {
12248
+ "path": "tests/task_goal_open_gate.mjs",
12249
+ "mode": "0000644",
12250
+ "sha256": "93a5d1e91bfea7af8487b409e60cd609ea25a690df9099d278ebd35f253abcb8"
12251
+ },
12247
12252
  {
12248
12253
  "path": "tests/task_lib.mjs",
12249
12254
  "mode": "0000644",
@@ -1559,5 +1559,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
1559
1559
  landed since 1.19.553 with no explicit bump. run 34044540046. (task 1002620)
1560
1560
  1.19.555 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1561
1561
  landed since 1.19.554 with no explicit bump. run 34045075186. (task 1002620)
1562
+ 1.19.556 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1563
+ landed since 1.19.555 with no explicit bump. run 34045323629. (task 1002620)
1562
1564
  ---------------------------------------------------------------------------
1563
1565
  ```
@@ -44,14 +44,36 @@ async function isMemberOrArchon(builder, goalId) {
44
44
  // (grader permission/route-rank pre-passes + serverSideGuards, gate-review
45
45
  // ESCALATE floor, post-push main-audit — ADR 0086 §7). Returns { ok:true } or
46
46
  // { ok:false, status, body } (the exact HTTP status + JSON the route sends).
47
+ // goalNotOpenRefusal — the ONE definition of "this goal takes no new work", so the
48
+ // three vectors that can put a task into a goal cannot drift apart (BV1.R05, task
49
+ // 1003590, goal 1000086). Returns null when the goal is open, or the refusal's
50
+ // parts when it is not; each caller renders them in its own idiom (this module's
51
+ // { ok, status, body }, or res.fail in task-write-routes.js). Assumes the caller
52
+ // has already established the goal EXISTS — a missing goal is a 404, a different
53
+ // answer to a different question.
54
+ //
55
+ // Only 'open' takes new work: 'achieved' and 'archived' are closures, and a task
56
+ // added to either is exactly how a goal that reported done silently reacquires
57
+ // unfinished work.
58
+ function goalNotOpenRefusal(goal) {
59
+ if (goal && goal.status === 'open') return null;
60
+ return {
61
+ status: 409,
62
+ error: 'goal_not_open',
63
+ goalStatus: goal ? goal.status : null,
64
+ message: 'Tasks can only be authored under an open goal.',
65
+ };
66
+ }
67
+
47
68
  function authorizeGoalTaskCreate({ goal, version, isMember, builderRank, touches }) {
48
69
  if (!goal) {
49
70
  return { ok: false, status: 404, body: { error: 'goal_not_found' } };
50
71
  }
51
- if (goal.status !== 'open') {
52
- return { ok: false, status: 409, body: {
53
- error: 'goal_not_open', status: goal.status,
54
- message: 'Tasks can only be authored under an open goal.',
72
+ const notOpen = goalNotOpenRefusal(goal);
73
+ if (notOpen) {
74
+ return { ok: false, status: notOpen.status, body: {
75
+ error: notOpen.error, status: notOpen.goalStatus,
76
+ message: notOpen.message,
55
77
  } };
56
78
  }
57
79
  if (!isMember) {
@@ -445,6 +467,7 @@ module.exports = {
445
467
  authorizeRescindInvitation,
446
468
  authorizeWithdrawJoinRequest,
447
469
  decorateGoalScope,
470
+ goalNotOpenRefusal,
448
471
  isMemberOrArchon,
449
472
  protectedScopeAdmissionBlock,
450
473
  };
@@ -26,6 +26,7 @@ const { classifyTaskKind, VALID_KINDS, VALID_DISCIPLINES, VOTABLE_TASK_STATUSES,
26
26
  const { isModuleKey, moduleKeys } = api.moduleScopeMap;
27
27
  const { validateOrRespond, LIMITS, parseId } = api;
28
28
  const { badKind, detectsMigration } = require('./task-route-helpers.js');
29
+ const { goalNotOpenRefusal } = require('./goal-route-authz.js');
29
30
  // task 1003208: structured logging (pino via the doorway) — was console.*.
30
31
  const log = api.logger('lifecycle');
31
32
 
@@ -169,6 +170,16 @@ module.exports = function registerTaskWriteRoutes(router) {
169
170
  if (!goal || String(goal.version_id) !== String(body.version_id)) {
170
171
  return res.fail('bad_goal_id', { status: 400, message: `goal_id ${body.goal_id} does not exist on version '${body.version_id}'.` });
171
172
  }
173
+ // BV1.R05 (task 1003590, goal 1000086): a task may only be authored under
174
+ // an OPEN goal. `POST /goals/:id/tasks` has always refused this via
175
+ // authorizeGoalTaskCreate; this vector validated only existence + version,
176
+ // so the same task refused on the goal-scoped route landed in an achieved
177
+ // or archived goal when created here. Same named 409, deliberately, so a
178
+ // caller handles ONE code rather than learning which door it came through.
179
+ const notOpen = goalNotOpenRefusal(goal);
180
+ if (notOpen) {
181
+ return res.fail(notOpen.error, { status: notOpen.status, message: notOpen.message, details: { goal_status: notOpen.goalStatus } });
182
+ }
172
183
  }
173
184
  try {
174
185
  const task = await db.createTask({
@@ -401,6 +412,13 @@ module.exports = function registerTaskWriteRoutes(router) {
401
412
  if (String(goal.version_id) !== String(task.version_id)) {
402
413
  return res.fail('goal_version_mismatch', { status: 409, message: `Goal #${goalId} is in version '${goal.version_id}' but task #${id} is in '${task.version_id}'. A task can only join a goal in its own version.` });
403
414
  }
415
+ // BV1.R05 (task 1003590, goal 1000086): the same open-goal rule as the
416
+ // create vectors. Moving a task INTO a closed goal is how a goal that
417
+ // reported achieved silently reacquires unfinished work.
418
+ const notOpen = goalNotOpenRefusal(goal);
419
+ if (notOpen) {
420
+ return res.fail(notOpen.error, { status: notOpen.status, message: notOpen.message, details: { goal_status: notOpen.goalStatus } });
421
+ }
404
422
  } catch (err) {
405
423
  log.error('[gds] PATCH /tasks/:id goal_id', err);
406
424
  return res.fail('patch_failed', { status: 500, message: 'internal error' });
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.555",
3
+ "version": "1.19.556",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.555",
9
+ "version": "1.19.556",
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.555",
3
+ "version": "1.19.556",
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",
package/src/module-api.js CHANGED
@@ -49,7 +49,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
49
49
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
50
50
  // the entry to that file. Look for a version's history there, not here.
51
51
  // ---------------------------------------------------------------------------
52
- const CORE_VERSION = '1.19.555'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
52
+ const CORE_VERSION = '1.19.556'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
53
53
 
54
54
  // A namespaced logger so a module's log lines are attributable + consistent.
55
55
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -42,6 +42,9 @@ const REFUSALS = [
42
42
  "'bad_goal_id'",
43
43
  "'goal_not_found'",
44
44
  "'goal_version_mismatch'",
45
+ // BV1.R05 (task 1003590): the open-goal refusal joins the list — it is
46
+ // rendered from goalNotOpenRefusal, so the pin looks for the call.
47
+ 'goalNotOpenRefusal(goal)',
45
48
  "'bad_requires_rank'",
46
49
  'badKind(res)',
47
50
  "'bad_discipline'",
@@ -0,0 +1,141 @@
1
+ // tests/task_goal_open_gate.mjs — a task may only be authored under an OPEN goal,
2
+ // on every vector that can put one there (BV1.R05, task 1003590, goal 1000086).
3
+ //
4
+ // THE ASYMMETRY THIS CLOSES. `POST /goals/:id/tasks` has always refused a closed
5
+ // goal — it runs the ordered gate `authorizeGoalTaskCreate`, whose second check is
6
+ // `goal_not_open`. The other two vectors did not: `POST /tasks` validated only that
7
+ // the goal EXISTS and belongs to the version, and `PATCH /tasks/:id` only that the
8
+ // versions match. So the very same task, refused at the goal-scoped door, landed in
9
+ // an `achieved` or `archived` goal when created through the general one — which is
10
+ // how a goal that reported done silently reacquires unfinished work, the failure
11
+ // goal 1000086 exists to make impossible.
12
+ //
13
+ // The refusal is now ONE predicate, `goalNotOpenRefusal`, consumed by all three, so
14
+ // a caller handles a single named 409 rather than learning which door it came
15
+ // through — and the three cannot drift apart later. This file pins the predicate's
16
+ // behaviour, that each vector actually consumes it, and that the shared 409 stays
17
+ // named the same on the goal-scoped route.
18
+ //
19
+ // Run: node --test tests/task_goal_open_gate.mjs
20
+
21
+ import assert from 'node:assert/strict';
22
+ import { test } from 'node:test';
23
+ import { readFileSync } from 'node:fs';
24
+ import { createRequire } from 'node:module';
25
+
26
+ process.env.NODE_ENV = 'test';
27
+ const require = createRequire(import.meta.url);
28
+ const authz = require('../modules/lifecycle/routes/goal-route-authz.js');
29
+ const { goalNotOpenRefusal, authorizeGoalTaskCreate } = authz;
30
+
31
+ const src = (rel) => readFileSync(new URL('../' + rel, import.meta.url), 'utf8');
32
+ const WRITE_ROUTES = src('modules/lifecycle/routes/task-write-routes.js');
33
+
34
+ // The handler slices, so "the vector consumes the predicate" is asserted about the
35
+ // right handler rather than anywhere in a 1,000-line file.
36
+ function slice(source, start, end) {
37
+ const a = source.indexOf(start);
38
+ assert.notEqual(a, -1, 'handler not found: ' + start);
39
+ // `end` absent means the handler runs to the end of the file — PATCH /tasks/:id
40
+ // is currently the last route declared here, and a slice that silently came back
41
+ // empty would make every assertion below pass vacuously.
42
+ const b = end ? source.indexOf(end, a + 1) : source.length;
43
+ assert.notEqual(b, -1, 'no route follows: ' + start);
44
+ const out = source.slice(a, b);
45
+ assert.ok(out.length > 500, 'the slice must actually contain the handler');
46
+ return out;
47
+ }
48
+ const POST_TASKS = slice(WRITE_ROUTES, "router.post('/tasks'", "router.patch('/tasks/:id'");
49
+ const PATCH_TASK = slice(WRITE_ROUTES, "router.patch('/tasks/:id'", null);
50
+
51
+ // ---- the predicate ----------------------------------------------------------
52
+
53
+ test('open takes new work; achieved and archived do not', () => {
54
+ assert.equal(goalNotOpenRefusal({ status: 'open' }), null,
55
+ 'an open goal is the only one that takes new work');
56
+ for (const status of ['achieved', 'archived']) {
57
+ const r = goalNotOpenRefusal({ status });
58
+ assert.ok(r, status + ' must refuse');
59
+ assert.equal(r.status, 409);
60
+ assert.equal(r.error, 'goal_not_open');
61
+ assert.equal(r.goalStatus, status, 'the caller can say WHICH closure refused');
62
+ }
63
+ });
64
+
65
+ test('an unknown or absent status refuses — the predicate is fail-closed', () => {
66
+ // Not a hypothetical: a future goal status nobody taught this function about
67
+ // must not become a hole. Anything that is not exactly 'open' is refused.
68
+ assert.ok(goalNotOpenRefusal({ status: 'paused' }), 'an unknown status is not open');
69
+ assert.ok(goalNotOpenRefusal({}), 'a goal row with no status is not open');
70
+ assert.ok(goalNotOpenRefusal(null), 'a nullish goal is not open');
71
+ assert.equal(goalNotOpenRefusal(null).goalStatus, null,
72
+ 'and it reports no status rather than throwing');
73
+ });
74
+
75
+ // ---- the three vectors ------------------------------------------------------
76
+
77
+ test('POST /goals/:id/tasks — the ordered gate still renders the same 409', () => {
78
+ const out = authorizeGoalTaskCreate({
79
+ goal: { id: 1, status: 'archived', version_id: 'V1' },
80
+ version: { id: 'V1', status: 'building' },
81
+ isMember: true, builderRank: 'metic', touches: [],
82
+ });
83
+ assert.equal(out.ok, false);
84
+ assert.equal(out.status, 409);
85
+ assert.equal(out.body.error, 'goal_not_open');
86
+ assert.equal(out.body.status, 'archived',
87
+ "the goal-scoped route's body shape is unchanged by the extraction");
88
+ });
89
+
90
+ test('POST /tasks consumes the shared predicate and refuses with its code', () => {
91
+ assert.match(POST_TASKS, /const notOpen = goalNotOpenRefusal\(goal\);/,
92
+ 'the general create vector must ask the same question');
93
+ assert.match(POST_TASKS, /res\.fail\(notOpen\.error, \{ status: notOpen\.status/,
94
+ 'and render that answer, rather than a second hand-typed 409');
95
+ });
96
+
97
+ test('PATCH /tasks/:id consumes it too — moving INTO a closed goal is the same act', () => {
98
+ assert.match(PATCH_TASK, /const notOpen = goalNotOpenRefusal\(goal\);/);
99
+ assert.match(PATCH_TASK, /res\.fail\(notOpen\.error, \{ status: notOpen\.status/);
100
+ });
101
+
102
+ test('no task-authoring vector re-types the refusal behind the predicate', () => {
103
+ // The point of the extraction: three copies of `goal.status !== 'open'` is three
104
+ // things to forget to update. Scoped to the vectors that author a TASK — the
105
+ // other `goal_not_open` refusals in goal-route-authz.js (membership change,
106
+ // ownership transfer, join request, invitation accept) share the code but answer
107
+ // different questions with their own messages, and folding them into this
108
+ // predicate would flatten four distinct explanations into one.
109
+ const AUTHZ = src('modules/lifecycle/routes/goal-route-authz.js');
110
+ const gateStart = AUTHZ.indexOf('function authorizeGoalTaskCreate');
111
+ assert.notEqual(gateStart, -1);
112
+ const taskAuthoring = [
113
+ ['task-write-routes.js', WRITE_ROUTES],
114
+ ['authorizeGoalTaskCreate', AUTHZ.slice(gateStart, AUTHZ.indexOf('\nfunction ', gateStart + 1))],
115
+ ];
116
+ for (const [name, body] of taskAuthoring) {
117
+ const hand = body.match(/goal\.status !== 'open'/g) || [];
118
+ assert.equal(hand.length, 0,
119
+ name + ' must call goalNotOpenRefusal, not compare goal.status itself');
120
+ }
121
+ assert.match(AUTHZ, /goal && goal\.status === 'open'/,
122
+ 'the one comparison lives inside the predicate');
123
+ });
124
+
125
+ // ---- the refusal is reachable, i.e. it precedes the write -------------------
126
+
127
+ test('POST /tasks settles the refusal BEFORE it creates anything', () => {
128
+ const gate = POST_TASKS.indexOf('goalNotOpenRefusal');
129
+ const write = POST_TASKS.indexOf('await db.createTask(');
130
+ assert.notEqual(write, -1, 'the create call must be in this slice');
131
+ assert.ok(gate !== -1 && gate < write,
132
+ 'a refusal after the insert is not a refusal — it is a row plus an error');
133
+ });
134
+
135
+ test('PATCH /tasks/:id settles it before the first update write (the G-22 rule)', () => {
136
+ const gate = PATCH_TASK.indexOf('goalNotOpenRefusal');
137
+ const write = PATCH_TASK.search(/await db\.updateTask\w+\(/);
138
+ assert.notEqual(write, -1, 'an updateTask* write must be in this slice');
139
+ assert.ok(gate !== -1 && gate < write,
140
+ 'task 1002609 G-22: authorization settles before any field is written');
141
+ });