@bongos/core 1.19.673 → 1.19.674

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.673",
6
- "core_contract": "1.19.673",
7
- "source_commit": "9798ad8423cf89ef3bad579911f1f5ca8505e800",
5
+ "core_version": "1.19.674",
6
+ "core_contract": "1.19.674",
7
+ "source_commit": "0da6434caa66f679eeb8c34bc2be3826f2a1bfe8",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-11T18:16:55.232Z",
9
+ "built_at": "2026-09-11T18:40:06.671Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 474,
13
13
  "agent_docs_stubbed": 24,
14
- "functional_verbatim": 2121,
14
+ "functional_verbatim": 2122,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2619,
20
- "tree_sha256": "3be80c1f6787508e21f11f1b391ce342ad0c0e3b570a6360bd00d3e5b5cfa417",
19
+ "file_count": 2620,
20
+ "tree_sha256": "984de9fdd6b42c1d4cf8728faad1a458ab08d8491957851e2874992610d4ae01",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -387,7 +387,7 @@
387
387
  {
388
388
  "path": "clients/bongos-client/index.d.ts",
389
389
  "mode": "0000644",
390
- "sha256": "e1d2929cc1373aca2d24879b9fbbbbfcd1ef77c850565aca7dba0130ff1ac43b"
390
+ "sha256": "d692cd6b80b1c286f6990a0ebcb3c109d51213b6cdc58cc7dc97fa52ff6fbe46"
391
391
  },
392
392
  {
393
393
  "path": "clients/bongos-client/index.mjs",
@@ -1912,12 +1912,12 @@
1912
1912
  {
1913
1913
  "path": "docs/api-reference.md",
1914
1914
  "mode": "0000644",
1915
- "sha256": "93eeff755bd089165265cf0f2c7f21e36803ff9519266512f834891d1ff6dc60"
1915
+ "sha256": "4b396cff8505cdec44f4cb471c79c5e5af8e5446a04a3ecaad0cba5435677f9a"
1916
1916
  },
1917
1917
  {
1918
1918
  "path": "docs/api/openapi.json",
1919
1919
  "mode": "0000644",
1920
- "sha256": "0d47d92e4e20e0f7d2a9ea9a1a4c643ed2e22103cd6ef5081377b22540993670"
1920
+ "sha256": "8292a0f17190f803436bd3ae60f7ca36631d57b552c3553b6f5f2cdcd4eaffd9"
1921
1921
  },
1922
1922
  {
1923
1923
  "path": "docs/architecture.md",
@@ -2792,7 +2792,7 @@
2792
2792
  {
2793
2793
  "path": "docs/module-api-changelog.md",
2794
2794
  "mode": "0000644",
2795
- "sha256": "e1eb4883d1a25ac4b5b90ba00150f280c004569abeba0f9c0c4099982e587c6e"
2795
+ "sha256": "0e8b8ccaf7913c7d40a59f5662da9dac4cbd8ec419df1e7ada549a26d1824f67"
2796
2796
  },
2797
2797
  {
2798
2798
  "path": "docs/modules-contract.md",
@@ -5692,7 +5692,7 @@
5692
5692
  {
5693
5693
  "path": "modules/lifecycle/db-tasks.js",
5694
5694
  "mode": "0000644",
5695
- "sha256": "fb6503bdaf013a13752968844693cbf957484f033b9df27a66f93eacb91c2cfa"
5695
+ "sha256": "5b2df46e54dbeb0694a805d9743df5da6fde90b73a76989e917257b7f78f8ed9"
5696
5696
  },
5697
5697
  {
5698
5698
  "path": "modules/lifecycle/db-versions.js",
@@ -5747,7 +5747,7 @@
5747
5747
  {
5748
5748
  "path": "modules/lifecycle/goal-edits.js",
5749
5749
  "mode": "0000644",
5750
- "sha256": "09ca2631bfa979f5948b6b669cfe478cc67db3d4e1fbacd4c6f2bbd5ce0ec618"
5750
+ "sha256": "f00d1bb6bd57624679295f33d55ad1d81a9479789a78d4325f13948b9559f9f5"
5751
5751
  },
5752
5752
  {
5753
5753
  "path": "modules/lifecycle/goal-rollup.js",
@@ -5872,7 +5872,7 @@
5872
5872
  {
5873
5873
  "path": "modules/lifecycle/routes/done-when.js",
5874
5874
  "mode": "0000644",
5875
- "sha256": "25b18aa82f5b2951f3a9560fa5285dc2793556303ed68ae0aa9ea781ec2d45c9"
5875
+ "sha256": "9370fd1fc8a4a1ff864c1e85321b05a74bb45150ef8eb92cefdbbd85524bbe27"
5876
5876
  },
5877
5877
  {
5878
5878
  "path": "modules/lifecycle/routes/gate-approvals.js",
@@ -7767,12 +7767,12 @@
7767
7767
  {
7768
7768
  "path": "package-lock.json",
7769
7769
  "mode": "0000644",
7770
- "sha256": "dfc25c3b8365e868e5119aff5e8447d41b1983dbb9db9c2669e13f9259726629"
7770
+ "sha256": "747c1f7d390c913bc71f93fa96ecc76f67aea4409a3265fdf6555ca91aac5c11"
7771
7771
  },
7772
7772
  {
7773
7773
  "path": "package.json",
7774
7774
  "mode": "0000644",
7775
- "sha256": "98edbd2fab2c33a96eebabd87c3c0c4dbd2f563fda40ca37bc737b186726fade"
7775
+ "sha256": "59c1c75a1f396810212ad4654ac2dfd0f100e4c63fd3b2b7f5e11be22ce1a6fc"
7776
7776
  },
7777
7777
  {
7778
7778
  "path": "public-docs/index.html",
@@ -9532,7 +9532,7 @@
9532
9532
  {
9533
9533
  "path": "src/module-api.js",
9534
9534
  "mode": "0000644",
9535
- "sha256": "6aaa55e88d207f52ea930ba86d7d292c06326cdcb7c08823dd225df4bff15e2f"
9535
+ "sha256": "b2adf8fa055095fb344dad2ce76e3e0710ba0c23e3b5ccd68105b5daeded0390"
9536
9536
  },
9537
9537
  {
9538
9538
  "path": "src/module-loader/catalog.js",
@@ -10284,6 +10284,11 @@
10284
10284
  "mode": "0000644",
10285
10285
  "sha256": "970db80cf43dba855bf9bd331434add458ab16e719f06710487094a3a578ec53"
10286
10286
  },
10287
+ {
10288
+ "path": "tests/criterion_goal_attach.mjs",
10289
+ "mode": "0000644",
10290
+ "sha256": "979589fd08dc41da3c837513452353aa87f7653620fad22c8a9115d4d7d13b90"
10291
+ },
10287
10292
  {
10288
10293
  "path": "tests/criterion_progress.mjs",
10289
10294
  "mode": "0000644",
@@ -150,8 +150,8 @@ export interface PatchBuildersIdBudgetResponse { builder: unknown }
150
150
  export interface PatchBuildersIdRankRequest { rank: string }
151
151
  export interface PatchBuildersIdRankResponse { builder: unknown; previous_rank: unknown; direction: unknown; credit_delta: unknown; broadcast: unknown }
152
152
  export interface PatchBuildersIdStatusRequest { status: "active" | "inactive"; reason?: string }
153
- export interface PatchDoneWhenCriterionIdRequest { criterion_md: string }
154
- export interface PatchDoneWhenCriterionIdResponse { ok: boolean; criterion: unknown; deltas: unknown; unchanged: boolean }
153
+ export interface PatchDoneWhenCriterionIdRequest { criterion_md?: string; goal_id?: number }
154
+ export interface PatchDoneWhenCriterionIdResponse { ok: boolean; criterion: unknown; deltas: unknown; unchanged: unknown; achieved_goals: unknown }
155
155
  export interface PatchGoalsIdMembersBuilderIdRequest { membership_kind: string }
156
156
  export interface PatchGoalsIdMembersBuilderIdResponse { ok: boolean; member: unknown; changed: boolean }
157
157
  export interface PatchHelpRequestsIdRequest { status: "answered" | "withdrawn" }
@@ -650,7 +650,7 @@ export interface BongosClient {
650
650
  };
651
651
  "doneWhen": {
652
652
  /** PATCH /done-when/{criterionId} — rank: metic+archon */
653
- patchDoneWhenCriterionId(args: RequestArgs & { body: PatchDoneWhenCriterionIdRequest }): Promise<PatchDoneWhenCriterionIdResponse>;
653
+ patchDoneWhenCriterionId(args?: RequestArgs & { body?: PatchDoneWhenCriterionIdRequest }): Promise<PatchDoneWhenCriterionIdResponse>;
654
654
  /** POST /done-when/{criterionId}/satisfy — rank: metic+archon */
655
655
  postDoneWhenCriterionIdSatisfy(args?: RequestArgs & { body?: PostDoneWhenCriterionIdSatisfyRequest }): Promise<ApiResponse>;
656
656
  /** POST /done-when/{criterionId}/unsatisfy — rank: metic+archon */
@@ -4386,7 +4386,7 @@
4386
4386
  "done-when"
4387
4387
  ],
4388
4388
  "summary": "PATCH /done-when/:criterionId",
4389
- "description": "PATCH /done-when/:criterionId — correct a criterion's TEXT (task 1003102). Was write-once: this file exposed create, satisfy and unsatisfy and nothing else, so wrong text had no API repair path. The incident that forced it: a shell-quoting error stripped backtick-wrapped literals out of three criteria on goal 1000073 at CREATION time — the intent was recorded, the text was wrong, and the only channels were prod SQL or archiving the goal (worse: criteria are version-level rows, so an archived goal's criteria linger in the version rollup). A SATISFIED criterion is REFUSED, by name — see prose-edits.criterionProseRefusal for why that is a decision and not a limitation: a satisfied criterion is a claim someone verified, and re-wording it re-points that verification at text nobody checked. Unsatisfy → edit → re-satisfy makes the undo a recorded act. Gated like criterion CREATION — BOTH HALVES. The permission `criterion.create` is only the RANK floor; the sibling POST /versions/:id/done-when also runs authorizeCriterionCreate, the per-goal owner|manager wall (ADR 0154). Copying just the rank half would let any Metic rewrite criteria on a goal they neither own, manage, nor belong to — reusing the SAME pure gate is the only way the claim \"gated like creation\" is true. (Caught by the grader on the first cut of task 1003102: the comment said it, the code did half of it.) rank: metic+archon — matches POST /versions/:id/done-when.\n\n**Rank:** `metic+archon` — Metic or Archon rank (review/triage powers).\n\n**Permissions:** `criterion.create` (all required).",
4389
+ "description": "PATCH /done-when/:criterionId — amend a criterion's TEXT (task 1003102) and/or MOVE it between goals (task 1003509). Was write-once: this file exposed create, satisfy and unsatisfy and nothing else, so wrong text had no API repair path. The incident that forced it: a shell-quoting error stripped backtick-wrapped literals out of three criteria on goal 1000073 at CREATION time — the intent was recorded, the text was wrong, and the only channels were prod SQL or archiving the goal (worse: criteria are version-level rows, so an archived goal's criteria linger in the version rollup). A SATISFIED criterion is REFUSED, by name — see prose-edits.criterionProseRefusal for why that is a decision and not a limitation: a satisfied criterion is a claim someone verified, and re-wording it re-points that verification at text nobody checked. Unsatisfy → edit → re-satisfy makes the undo a recorded act. Gated like criterion CREATION — BOTH HALVES. The permission `criterion.create` is only the RANK floor; the sibling POST /versions/:id/done-when also runs authorizeCriterionCreate, the per-goal owner|manager wall (ADR 0154). Copying just the rank half would let any Metic rewrite criteria on a goal they neither own, manage, nor belong to — reusing the SAME pure gate is the only way the claim \"gated like creation\" is true. (Caught by the grader on the first cut of task 1003102: the comment said it, the code did half of it.) rank: metic+archon — matches POST /versions/:id/done-when. THE GOAL MOVE (task 1003509 — walk finding W5). `goal_id` used to be writable only by createCriterion's INSERT, so a criterion was born linked and stayed that way for life. The runner seeds a fresh instance's V1 criterion with NO goal — at standup there is no goal yet to name (provision-repo.js seedFirstVersionCmd) — so the owner's first goal could never adopt it: /progress showed the criterion \"(ungrouped)\" forever beside the goal it obviously belonged to, reporting `criteria: []`. Amending it took SQL, and a hub-provisioned owner has no SQL access at all. BOTH ENDS OF A MOVE ARE GATED, not just the goal it leaves. A move is a criterion write on the DESTINATION every bit as much as on the source, and gating only the source would let a goal's own owner push their criterion into a goal they have no authority over — a criterion being, per the CREATE route above, a thing that permanently blocks version close and goal auto-achieve with no delete route to undo it. So authorizeCriterionCreate runs against both and both must pass. DETACH (`goal_id: null`) has no destination, so it lands on that gate's goal-less branch and is Archon-only — the same wall that makes version-level criteria Archon-only at creation (ADR 0154), reached without a rule of its own. A SATISFIED criterion may still MOVE. criterionProseRefusal freezes the text of a verified claim, and that reasoning is about re-wording: moving one between goals changes how it is grouped, never what was checked. It is refused here only when the prose is what is changing. WHO CAN ADOPT THE SEEDED CRITERION. Because a goal-LESS criterion is version-level, the source gate's own goal-less branch makes ANY write to one Archon-only — which predates this task (a non-Archon could not edit an ungrouped criterion's prose either). That is not a wall in front of W5: the founder of a wizard-born instance IS its Archon, so the one criterion this was filed for is theirs to place. A Metic is unaffected where it matters — regrouping between two goals they already hold needs no escalation.\n\n**Rank:** `metic+archon` — Metic or Archon rank (review/triage powers).\n\n**Permissions:** `criterion.create` (all required).",
4390
4390
  "x-rank": "metic+archon",
4391
4391
  "x-source": "modules/lifecycle/routes/done-when.js",
4392
4392
  "x-permissions": [
@@ -4404,7 +4404,7 @@
4404
4404
  }
4405
4405
  ],
4406
4406
  "requestBody": {
4407
- "required": true,
4407
+ "required": false,
4408
4408
  "content": {
4409
4409
  "application/json": {
4410
4410
  "schema": {
@@ -18447,11 +18447,12 @@
18447
18447
  "type": "string",
18448
18448
  "minLength": 1,
18449
18449
  "maxLength": 10000
18450
+ },
18451
+ "goal_id": {
18452
+ "type": "integer",
18453
+ "minimum": 1
18450
18454
  }
18451
18455
  },
18452
- "required": [
18453
- "criterion_md"
18454
- ],
18455
18456
  "additionalProperties": false
18456
18457
  },
18457
18458
  "PatchDoneWhenCriterionIdResponse": {
@@ -18462,15 +18463,15 @@
18462
18463
  },
18463
18464
  "criterion": {},
18464
18465
  "deltas": {},
18465
- "unchanged": {
18466
- "type": "boolean"
18467
- }
18466
+ "unchanged": {},
18467
+ "achieved_goals": {}
18468
18468
  },
18469
18469
  "required": [
18470
18470
  "ok",
18471
18471
  "criterion",
18472
18472
  "deltas",
18473
- "unchanged"
18473
+ "unchanged",
18474
+ "achieved_goals"
18474
18475
  ]
18475
18476
  },
18476
18477
  "PatchGoalsIdMembersBuilderIdRequest": {
@@ -202,7 +202,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
202
202
 
203
203
  | Method | Path | Rank | Body | Description |
204
204
  |---|---|---|---|---|
205
- | PATCH | `/api/bongos/done-when/:criterionId` | `metic+archon` | `criterion_md` | PATCH /done-when/:criterionId — correct a criterion's TEXT (task 1003102). |
205
+ | PATCH | `/api/bongos/done-when/:criterionId` | `metic+archon` | `criterion_md`, `goal_id` | PATCH /done-when/:criterionId — amend a criterion's TEXT (task 1003102) and/or MOVE it between goals (task 1003509). |
206
206
  | POST | `/api/bongos/done-when/:criterionId/satisfy` | `metic+archon` | `satisfied_by_task_id` | Auth (Metic+): confirm a criterion — flip it to satisfied. |
207
207
  | POST | `/api/bongos/done-when/:criterionId/unsatisfy` | `metic+archon` | — | Auth: flip a criterion back to unsatisfied (rare; for corrections). |
208
208
  | GET | `/api/bongos/done-when/pending-review` | `metic+archon` | — | Auth (Metic+): the criterion review queue — every criterion auto-flagged "met — pending review" (≥1 linked task, all shipped, not yet con… |
@@ -1805,5 +1805,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
1805
1805
  landed since 1.19.671 with no explicit bump. run 34629971417. (task 1002620)
1806
1806
  1.19.673 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1807
1807
  landed since 1.19.672 with no explicit bump. run 34632328471. (task 1002620)
1808
+ 1.19.674 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1809
+ landed since 1.19.673 with no explicit bump. run 34634515595. (task 1002620)
1808
1810
  ---------------------------------------------------------------------------
1809
1811
  ```
@@ -352,6 +352,27 @@ async function createCriterion({ versionId, criterionId, criterionMd, goalId = n
352
352
  return rows[0];
353
353
  }
354
354
 
355
+ // updateCriterionGoal — move an EXISTING criterion into a goal, or out of one
356
+ // (task 1003509). Criteria used to be born linked and stay that way: goal_id was
357
+ // writable only in createCriterion's INSERT, so the runner-seeded V1 criterion a
358
+ // fresh instance starts with could never join the goal it obviously belongs to,
359
+ // and /progress showed it "(ungrouped)" forever beside a goal reporting
360
+ // criteria: []. Amending it needed SQL, which a hub-provisioned owner has no
361
+ // access to at all (walk finding W5).
362
+ //
363
+ // goalId null DETACHES (back to a version-level criterion). The caller owns every
364
+ // question this raises — that the goal exists and sits on the criterion's own
365
+ // version, who may write it, and whether the move completes the goal; this is the
366
+ // UPDATE and nothing else, exactly as updateTaskGoal below is for tasks.
367
+ async function updateCriterionGoal(id, goalId) {
368
+ const { rows } = await pool.query(
369
+ `UPDATE done_when_criteria SET goal_id = $1 WHERE id = $2
370
+ RETURNING id, version_id, criterion_id, criterion_md, goal_id, sort_order, satisfied`,
371
+ [goalId, id]
372
+ );
373
+ return rows[0] ?? null;
374
+ }
375
+
355
376
  async function updateTaskGoal(id, goalId) {
356
377
  const { rows } = await pool.query(
357
378
  `UPDATE tasks SET goal_id = $1, updated_at = now() WHERE id = $2 RETURNING *`,
@@ -1034,6 +1055,7 @@ module.exports = {
1034
1055
  setCreditsReward,
1035
1056
  suggestCreditsReward,
1036
1057
  taskTouchesProtectedPath,
1058
+ updateCriterionGoal,
1037
1059
  updateTaskAutomationTag,
1038
1060
  updateTaskDescription,
1039
1061
  updateTaskTitle,
@@ -187,6 +187,39 @@ async function recordProseEdit(kind, id, req, deltas, deps = {}) {
187
187
  }
188
188
  }
189
189
 
190
+ // recordCriterionGoalMove — the audit row for moving a criterion between goals
191
+ // (task 1003509). Same reason recordProseEdit exists: the global write-audit
192
+ // middleware records the request body, which carries the DESTINATION and nothing
193
+ // else, so on its own the trail cannot say which goal the criterion left. For a
194
+ // regrouping that is the half that matters — "criterion 12 joined goal 5" is not
195
+ // recoverable without "…and left goal 3".
196
+ //
197
+ // A no-op move (same goal_id in and out) writes nothing, mirroring the empty-delta
198
+ // short-circuit above. And an audit failure is logged, never thrown: the move has
199
+ // already committed, so throwing here would report a failure that did not happen.
200
+ async function recordCriterionGoalMove({ criterionId, fromGoalId, toGoalId }, req, deps = {}) {
201
+ const log = deps.log || ((m) => proseLog().info(m));
202
+ const from = fromGoalId ?? null;
203
+ const to = toGoalId ?? null;
204
+ if (from === to) return;
205
+ const builderId = req.builder && req.builder.id;
206
+ log(`criterion ${criterionId} moved by builder ${builderId}: goal ${from ?? '(none)'} -> ${to ?? '(none)'}`);
207
+ const insert = deps.insertAuditLog || api.insertAuditLog;
208
+ try {
209
+ await insert({
210
+ builderId,
211
+ route: `criterion:${criterionId}:goal-move`,
212
+ method: 'PATCH',
213
+ requestBodyRedacted: JSON.stringify({ from_goal_id: from, to_goal_id: to }),
214
+ responseStatus: 200,
215
+ ip: req.ip || null,
216
+ userAgent: (req.get && req.get('user-agent')) || null,
217
+ });
218
+ } catch (e) {
219
+ log(`[gds] AUDIT GAP: criterion ${criterionId} goal move landed but its audit row failed: ${e && e.message}`);
220
+ }
221
+ }
222
+
190
223
  // ---------------------------------------------------------------------------
191
224
  // WHY PATCH /goals/:id looks the way it does (kept here, not in the route:
192
225
  // routes/goals.js is over the fitness oversize threshold, so prose there is
@@ -332,6 +365,7 @@ module.exports = {
332
365
  criterionProseRefusal,
333
366
  goalAcceptingRequestsHandler,
334
367
  logProseEdit,
368
+ recordCriterionGoalMove,
335
369
  recordProseEdit,
336
370
  goalProseHandler,
337
371
  goalVisibilityHandler,
@@ -224,7 +224,7 @@ module.exports = function buildDoneWhenRouter() {
224
224
  });
225
225
  }, { errorCode: 'satisfy_failed' }));
226
226
 
227
- // PATCH /done-when/:criterionId — correct a criterion's TEXT (task 1003102).
227
+ // PATCH /done-when/:criterionId — amend a criterion's TEXT (task 1003102) and/or MOVE it between goals (task 1003509).
228
228
  // Was write-once: this file exposed create, satisfy and unsatisfy and nothing
229
229
  // else, so wrong text had no API repair path. The incident that forced it: a
230
230
  // shell-quoting error stripped backtick-wrapped literals out of three criteria
@@ -246,31 +246,128 @@ module.exports = function buildDoneWhenRouter() {
246
246
  // claim "gated like creation" is true. (Caught by the grader on the first cut of
247
247
  // task 1003102: the comment said it, the code did half of it.)
248
248
  // rank: metic+archon — matches POST /versions/:id/done-when.
249
+ //
250
+ // THE GOAL MOVE (task 1003509 — walk finding W5). `goal_id` used to be writable
251
+ // only by createCriterion's INSERT, so a criterion was born linked and stayed
252
+ // that way for life. The runner seeds a fresh instance's V1 criterion with NO
253
+ // goal — at standup there is no goal yet to name (provision-repo.js
254
+ // seedFirstVersionCmd) — so the owner's first goal could never adopt it:
255
+ // /progress showed the criterion "(ungrouped)" forever beside the goal it
256
+ // obviously belonged to, reporting `criteria: []`. Amending it took SQL, and a
257
+ // hub-provisioned owner has no SQL access at all.
258
+ //
259
+ // BOTH ENDS OF A MOVE ARE GATED, not just the goal it leaves. A move is a
260
+ // criterion write on the DESTINATION every bit as much as on the source, and
261
+ // gating only the source would let a goal's own owner push their criterion into
262
+ // a goal they have no authority over — a criterion being, per the CREATE route
263
+ // above, a thing that permanently blocks version close and goal auto-achieve
264
+ // with no delete route to undo it. So authorizeCriterionCreate runs against both
265
+ // and both must pass. DETACH (`goal_id: null`) has no destination, so it lands
266
+ // on that gate's goal-less branch and is Archon-only — the same wall that makes
267
+ // version-level criteria Archon-only at creation (ADR 0154), reached without a
268
+ // rule of its own.
269
+ //
270
+ // A SATISFIED criterion may still MOVE. criterionProseRefusal freezes the text
271
+ // of a verified claim, and that reasoning is about re-wording: moving one
272
+ // between goals changes how it is grouped, never what was checked. It is
273
+ // refused here only when the prose is what is changing.
274
+ //
275
+ // WHO CAN ADOPT THE SEEDED CRITERION. Because a goal-LESS criterion is
276
+ // version-level, the source gate's own goal-less branch makes ANY write to one
277
+ // Archon-only — which predates this task (a non-Archon could not edit an
278
+ // ungrouped criterion's prose either). That is not a wall in front of W5: the
279
+ // founder of a wizard-born instance IS its Archon, so the one criterion this
280
+ // was filed for is theirs to place. A Metic is unaffected where it matters —
281
+ // regrouping between two goals they already hold needs no escalation.
249
282
  router.patch('/done-when/:criterionId', auth.requireBuilder, auth.requirePermission('criterion.create'), asyncHandler('PATCH /done-when/:criterionId', async (req, res) => {
250
283
  if (validateOrRespond(req, res, {
251
- criterion_md: { required: true, type: 'string', minLength: 1, maxLength: LIMITS.NOTES },
284
+ criterion_md: { type: 'string', minLength: 1, maxLength: LIMITS.NOTES },
285
+ goal_id: { type: 'integer', min: 1 },
252
286
  })) return;
287
+ // `goal_id: null` is a DETACH and has to be told apart from an absent field, so
288
+ // the presence of the KEY decides, never its value. (The validator treats an
289
+ // explicit null as absent, which is why it validates at all.)
290
+ const movingGoal = Object.prototype.hasOwnProperty.call(req.body || {}, 'goal_id');
291
+ const changingProse = req.body.criterion_md != null;
292
+ if (!movingGoal && !changingProse) {
293
+ return res.fail('nothing_to_update', { status: 400, message: 'Send criterion_md, goal_id, or both.' });
294
+ }
253
295
  const id = parseId(req, res, { param: 'criterionId', code: 'bad_criterion_id' });
254
296
  if (id === null) return;
255
297
  const existing = await doneWhen.getCriterion(id);
256
298
  if (!existing) return res.fail('criterion_not_found', 404);
257
- // The ownership wall, resolved exactly as the CREATE route resolves it: the
258
- // criterion's OWN goal + version, and membership read only for a non-Archon.
259
- const goal = existing.goal_id != null ? await db.getGoal(existing.goal_id) : null;
260
299
  const version = await db.getVersion(existing.version_id);
261
- const actorMembership = (goal && req.builder.rank !== 'archon')
262
- ? await db.getGoalMember({ goalId: goal.id, builderId: req.builder.id })
263
- : null;
264
- const authority = authorizeCriterionCreate({
265
- goal, version, actorId: req.builder.id, actorRank: req.builder.rank, actorMembership,
266
- });
300
+ // The ownership wall, resolved exactly as the CREATE route resolves it: the
301
+ // goal + version, and membership read only for a non-Archon. One closure, so
302
+ // both ends of a move are decided by the same code rather than a copy.
303
+ const authorizeFor = async (goal) => {
304
+ const actorMembership = (goal && req.builder.rank !== 'archon')
305
+ ? await db.getGoalMember({ goalId: goal.id, builderId: req.builder.id })
306
+ : null;
307
+ return authorizeCriterionCreate({
308
+ goal, version, actorId: req.builder.id, actorRank: req.builder.rank, actorMembership,
309
+ });
310
+ };
311
+ const currentGoal = existing.goal_id != null ? await db.getGoal(existing.goal_id) : null;
312
+ const authority = await authorizeFor(currentGoal);
267
313
  if (!authority.ok) return res.status(authority.status).json(authority.body);
268
- const refusal = goalEdits.criterionProseRefusal(existing);
269
- if (refusal) return res.fail(refusal.code, refusal.status, refusal.message ? { message: refusal.message } : undefined);
270
- const result = await goalEdits.updateCriterionProse({ criterionId: id, criterionMd: req.body.criterion_md });
271
- if (!result) return res.fail('criterion_not_found', 404);
272
- await goalEdits.recordProseEdit('criterion', id, req, result.deltas);
273
- return res.json({ ok: true, criterion: result.criterion, deltas: result.deltas, unchanged: !!result.unchanged });
314
+
315
+ const targetGoalId = movingGoal ? (req.body.goal_id ?? null) : null;
316
+ if (movingGoal) {
317
+ let targetGoal = null;
318
+ if (targetGoalId !== null) {
319
+ targetGoal = await db.getGoal(targetGoalId);
320
+ // Same refusal the CREATE route gives, for the same reason: a criterion may
321
+ // never cross versions, which would put it in one version's rollup and the
322
+ // other's goal at once.
323
+ if (!targetGoal || String(targetGoal.version_id) !== String(existing.version_id)) {
324
+ return res.fail('bad_goal_id', { status: 400, message: `goal_id ${targetGoalId} does not exist on version '${existing.version_id}'.` });
325
+ }
326
+ }
327
+ const destination = await authorizeFor(targetGoal);
328
+ if (!destination.ok) return res.status(destination.status).json(destination.body);
329
+ }
330
+
331
+ let criterion = existing;
332
+ let deltas = [];
333
+ let unchanged = false;
334
+ if (changingProse) {
335
+ const refusal = goalEdits.criterionProseRefusal(existing);
336
+ if (refusal) return res.fail(refusal.code, refusal.status, refusal.message ? { message: refusal.message } : undefined);
337
+ const result = await goalEdits.updateCriterionProse({ criterionId: id, criterionMd: req.body.criterion_md });
338
+ if (!result) return res.fail('criterion_not_found', 404);
339
+ await goalEdits.recordProseEdit('criterion', id, req, result.deltas);
340
+ criterion = result.criterion;
341
+ deltas = result.deltas;
342
+ unchanged = !!result.unchanged;
343
+ }
344
+ const achievedGoals = [];
345
+ const fromGoalId = existing.goal_id ?? null;
346
+ // A move to where it already is writes nothing at all — not the row, not the
347
+ // audit, not a re-derive. Nothing about the world changed.
348
+ if (movingGoal && fromGoalId !== targetGoalId) {
349
+ const moved = await db.updateCriterionGoal(id, targetGoalId);
350
+ if (!moved) return res.fail('criterion_not_found', 404);
351
+ criterion = moved;
352
+ await goalEdits.recordCriterionGoalMove(
353
+ { criterionId: id, fromGoalId, toGoalId: targetGoalId }, req
354
+ );
355
+ // BOTH ENDS are re-derived, because a move changes what each of them still
356
+ // needs. The destination is the obvious half — adopting an already-satisfied
357
+ // criterion can be the thing that completes a goal. The SOURCE is the half
358
+ // that is easy to miss and just as real: taking an UNSATISFIED criterion out
359
+ // of a goal can leave every criterion that remains satisfied, which is that
360
+ // goal completing. Skipping it would leave a goal silently open that nothing
361
+ // will ever close again — no later ship re-checks it, because no task under
362
+ // it changed. achieveGoalIfComplete is conservative and idempotent (open
363
+ // goals only, and it never un-achieves), so asking twice is two reads.
364
+ for (const goalId of [fromGoalId, targetGoalId]) {
365
+ if (goalId === null) continue;
366
+ const outcome = await db.achieveGoalIfComplete({ goalId });
367
+ if (outcome && outcome.achieved) achievedGoals.push(goalId);
368
+ }
369
+ }
370
+ return res.json({ ok: true, criterion, deltas, unchanged, achieved_goals: achievedGoals });
274
371
  }, { errorCode: 'criterion_update_failed' }));
275
372
 
276
373
  // Auth: flip a criterion back to unsatisfied (rare; for corrections).
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.673",
3
+ "version": "1.19.674",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.673",
9
+ "version": "1.19.674",
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.673",
3
+ "version": "1.19.674",
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
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.673'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.674'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,347 @@
1
+ // tests/criterion_goal_attach.mjs
2
+ //
3
+ // Route-level proof that an EXISTING criterion can join a goal, leave one, and
4
+ // that BOTH ends of the move are walled (task 1003509 — walk finding W5).
5
+ //
6
+ // WHY THE ROUTE AND NOT THE PREDICATE. tests/criterion_authoring_gate.mjs already
7
+ // pins authorizeCriterionCreate as a pure decision. The defect this file exists to
8
+ // catch is one layer up and invisible there: a move is a criterion write on the
9
+ // DESTINATION goal as much as on the source, so gating only the goal it leaves
10
+ // would let a goal's own owner push their criterion into a goal they hold no
11
+ // authority over. That failure passes every pure-gate test — the gate is correct,
12
+ // it is simply not asked twice. This route's own history records the same shape
13
+ // once already ("the comment said it, the code did half of it", task 1003102).
14
+ //
15
+ // So this runs the REAL Express router with the data layer and the auth gate
16
+ // stubbed at the module boundary — the tests/task_visual_route_gate.mjs pattern.
17
+ // No DB, no network, no session.
18
+ //
19
+ // Run: node tests/criterion_goal_attach.mjs
20
+
21
+ import { strict as assert } from 'node:assert';
22
+ import { createRequire } from 'node:module';
23
+ import http from 'node:http';
24
+
25
+ process.env.NODE_ENV = 'test';
26
+ const require = createRequire(import.meta.url);
27
+
28
+ const express = require('express');
29
+ const api = require('../src/module-api.js');
30
+
31
+ // The REAL updateCriterionGoal SQL runs here, against a recording pool. That
32
+ // matters: db-tasks.js destructures `pool` off the doorway at REQUIRE time and
33
+ // takes no deps seam (its siblings createCriterion/updateTaskGoal take none
34
+ // either, and growing one just for a test would make this function the odd one
35
+ // out), so the pool has to be replaced BEFORE the first require below. Stubbing
36
+ // db.updateCriterionGoal instead would leave its SQL with no coverage at all —
37
+ // a mutation that made the UPDATE write nothing stayed green until this landed.
38
+ const sqlLog = [];
39
+ Object.defineProperty(api, 'pool', {
40
+ configurable: true,
41
+ value: {
42
+ query: async (sql, params) => {
43
+ sqlLog.push({ sql, params });
44
+ if (/UPDATE done_when_criteria/.test(sql)) {
45
+ const [goalId, id] = params;
46
+ criterion = { ...criterion, id: Number(id), goal_id: goalId };
47
+ return { rows: [{ ...criterion }] };
48
+ }
49
+ throw new Error(`unexpected SQL reached the pool: ${sql}`);
50
+ },
51
+ },
52
+ });
53
+
54
+ const db = require('../modules/lifecycle/db.js');
55
+ const doneWhen = require('../modules/lifecycle/done-when.js');
56
+ const goalEdits = require('../modules/lifecycle/goal-edits.js');
57
+
58
+ // ---- the world the route sees ---------------------------------------------
59
+ const V1 = 'INST-V1';
60
+ const V2 = 'INST-V2';
61
+ const ARCHON = { id: 900, rank: 'archon' };
62
+ const METIC = { id: 700, rank: 'metic' };
63
+
64
+ let actor = METIC;
65
+ let criterion = null;
66
+ const goals = new Map();
67
+ let calls = {};
68
+ let completable = new Set();
69
+
70
+ function resetWorld() {
71
+ actor = METIC;
72
+ completable = new Set([3]);
73
+ sqlLog.length = 0;
74
+ calls = { achieve: [], auditMove: [], prose: [] };
75
+ // Derived from the SQL the real updateCriterionGoal actually sent, as
76
+ // [criterionId, goalId] — so "nothing was written" means no UPDATE reached the
77
+ // pool, not merely that a stub went uncalled.
78
+ Object.defineProperty(calls, 'updateGoal', {
79
+ get: () => sqlLog
80
+ .filter((q) => /UPDATE done_when_criteria/.test(q.sql))
81
+ .map((q) => [Number(q.params[1]), q.params[0]]),
82
+ });
83
+ // The shape a wizard-born instance actually starts with: seeded under V1 by
84
+ // provision-repo.js, belonging to no goal because none existed at standup.
85
+ criterion = { id: 55, version_id: V1, criterion_id: 'first-goal', criterion_md: 'ship something', goal_id: null, satisfied: false, sort_order: 10 };
86
+ goals.clear();
87
+ // id version status created_by (owner)
88
+ goals.set(3, { id: 3, version_id: V1, status: 'open', created_by: METIC.id });
89
+ goals.set(4, { id: 4, version_id: V1, status: 'open', created_by: 999 }); // someone else's
90
+ goals.set(9, { id: 9, version_id: V2, status: 'open', created_by: METIC.id }); // another version
91
+ }
92
+
93
+ // ---- stubs at the module boundary -----------------------------------------
94
+ api.requireBuilder = (req, _res, next) => { req.builder = actor; next(); };
95
+ api.requirePermission = () => (_req, _res, next) => next();
96
+
97
+ doneWhen.getCriterion = async (id) => (criterion && criterion.id === Number(id) ? { ...criterion } : null);
98
+ db.getVersion = async (id) => ({ id, status: 'building' });
99
+ db.getGoal = async (id) => (goals.has(Number(id)) ? { ...goals.get(Number(id)) } : null);
100
+ db.getGoalMember = async () => null; // membership is never the path under test; ownership is
101
+ // NOT stubbed — db.updateCriterionGoal is the real one, running its real SQL
102
+ // against the pool above. `calls.updateGoal` is derived from what that SQL sent.
103
+ db.achieveGoalIfComplete = async ({ goalId }) => {
104
+ calls.achieve.push(goalId);
105
+ // Which goals a re-derive would actually complete is per-test — BOTH ends of a
106
+ // move can be completed by it, and which one matters is the point.
107
+ return { achieved: completable.has(Number(goalId)), goal: goals.get(Number(goalId)) ?? null };
108
+ };
109
+ goalEdits.recordCriterionGoalMove = async (move) => { calls.auditMove.push(move); };
110
+ goalEdits.updateCriterionProse = async ({ criterionId, criterionMd }) => {
111
+ calls.prose.push([criterionId, criterionMd]);
112
+ criterion = { ...criterion, criterion_md: criterionMd };
113
+ return { criterion: { ...criterion }, deltas: [{ field: 'criterion_md' }], unchanged: false };
114
+ };
115
+ goalEdits.recordProseEdit = async () => {};
116
+
117
+ const buildRouter = require('../modules/lifecycle/routes/done-when.js');
118
+
119
+ const app = express();
120
+ app.use(express.json());
121
+ app.use((req, res, next) => {
122
+ res.fail = (code, status, extra) => {
123
+ const s = typeof status === 'number' ? status : (status && status.status) || 500;
124
+ return res.status(s).json({ error: code, ...(extra && typeof extra === 'object' ? extra : {}) });
125
+ };
126
+ next();
127
+ });
128
+ app.use(buildRouter());
129
+
130
+ const server = http.createServer(app);
131
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
132
+ const base = `http://127.0.0.1:${server.address().port}`;
133
+
134
+ async function patch(body) {
135
+ const res = await fetch(`${base}/done-when/55`, {
136
+ method: 'PATCH',
137
+ headers: { 'content-type': 'application/json' },
138
+ body: JSON.stringify(body),
139
+ });
140
+ return { status: res.status, body: await res.json().catch(() => ({})) };
141
+ }
142
+
143
+ let passed = 0, failed = 0;
144
+ async function test(name, fn) {
145
+ resetWorld();
146
+ try { await fn(); console.log(` ok ${name}`); passed++; }
147
+ catch (err) { console.error(` FAIL ${name}\n ${err.message}`); failed++; }
148
+ }
149
+
150
+ // ---------------------------------------------------------------------------
151
+
152
+ await test('the ungrouped V1 criterion joins a goal — the W5 fix', async () => {
153
+ // The founder of a wizard-born instance IS its Archon, which is what makes the
154
+ // W5 case reachable: a goal-LESS criterion is version-level, and ADR 0154 keeps
155
+ // version-level criteria Archon-only. That predates this task — the same gate
156
+ // already refused a non-Archon PROSE edit on an ungrouped criterion.
157
+ actor = ARCHON;
158
+ const r = await patch({ goal_id: 3 });
159
+ assert.equal(r.status, 200, JSON.stringify(r.body));
160
+ assert.equal(r.body.criterion.goal_id, 3);
161
+ assert.deepEqual(calls.updateGoal, [[55, 3]]);
162
+ });
163
+
164
+ await test('BOTH ends are gated: owning the source does not license the destination', async () => {
165
+ // The criterion already sits in goal 3, which METIC owns. Goal 4 is someone
166
+ // else's. Gating only the source would let this through — that is the defect.
167
+ criterion.goal_id = 3;
168
+ const r = await patch({ goal_id: 4 });
169
+ assert.equal(r.status, 403, `expected the destination wall, got ${r.status} ${JSON.stringify(r.body)}`);
170
+ assert.equal(r.body.error, 'goal_not_yours');
171
+ assert.deepEqual(calls.updateGoal, [], 'nothing was written');
172
+ });
173
+
174
+ await test('a METIC moves a criterion between two goals they own — no Archon needed', async () => {
175
+ // The other half of the W5 fix: regrouping WITHIN a workspace someone already
176
+ // holds needs no escalation. Goal 7 is a second goal owned by the same Metic.
177
+ goals.set(7, { id: 7, version_id: V1, status: 'open', created_by: METIC.id });
178
+ criterion.goal_id = 3;
179
+ const r = await patch({ goal_id: 7 });
180
+ assert.equal(r.status, 200, JSON.stringify(r.body));
181
+ assert.equal(r.body.criterion.goal_id, 7);
182
+ assert.deepEqual(calls.auditMove, [{ criterionId: 55, fromGoalId: 3, toGoalId: 7 }]);
183
+ });
184
+
185
+ await test('a criterion may never cross versions', async () => {
186
+ actor = ARCHON;
187
+ const r = await patch({ goal_id: 9 }); // goal 9 lives on V2, the criterion on V1
188
+ assert.equal(r.status, 400);
189
+ assert.equal(r.body.error, 'bad_goal_id');
190
+ assert.deepEqual(calls.updateGoal, []);
191
+ });
192
+
193
+ await test('an absent goal is the same refusal as a foreign-version one', async () => {
194
+ actor = ARCHON;
195
+ const r = await patch({ goal_id: 4242 });
196
+ assert.equal(r.status, 400);
197
+ assert.equal(r.body.error, 'bad_goal_id');
198
+ });
199
+
200
+ await test('DETACH is Archon-only — it lands on the gate\'s goal-less branch (ADR 0154)', async () => {
201
+ criterion.goal_id = 3;
202
+ const denied = await patch({ goal_id: null });
203
+ assert.equal(denied.status, 403);
204
+ assert.equal(denied.body.error, 'goal_id_required');
205
+ assert.deepEqual(calls.updateGoal, []);
206
+
207
+ actor = ARCHON;
208
+ const allowed = await patch({ goal_id: null });
209
+ assert.equal(allowed.status, 200, JSON.stringify(allowed.body));
210
+ assert.equal(allowed.body.criterion.goal_id, null);
211
+ assert.deepEqual(calls.updateGoal, [[55, null]]);
212
+ });
213
+
214
+ await test('a SATISFIED criterion still moves — only its text is frozen', async () => {
215
+ actor = ARCHON;
216
+ criterion.satisfied = true;
217
+ const moved = await patch({ goal_id: 3 });
218
+ assert.equal(moved.status, 200, JSON.stringify(moved.body));
219
+ assert.equal(moved.body.criterion.goal_id, 3);
220
+
221
+ const reworded = await patch({ criterion_md: 'something else' });
222
+ assert.equal(reworded.status, 409);
223
+ assert.equal(reworded.body.error, 'criterion_satisfied');
224
+ assert.deepEqual(calls.prose, [], 'the frozen text was never written');
225
+ });
226
+
227
+ await test('adopting a criterion that completes the goal reports the achievement', async () => {
228
+ actor = ARCHON;
229
+ const r = await patch({ goal_id: 3 });
230
+ assert.equal(r.status, 200);
231
+ assert.deepEqual(calls.achieve, [3], 'the destination is re-derived, not assumed');
232
+ assert.equal(r.body.achieved_goals.includes(3), true);
233
+ });
234
+
235
+ await test('leaving a goal can COMPLETE it — the source is re-derived too', async () => {
236
+ // The half that is easy to miss: pulling an UNSATISFIED criterion out of goal 3
237
+ // can leave everything still in goal 3 satisfied. Nothing else would ever close
238
+ // it — no later ship re-checks that goal, because no task under it changed.
239
+ actor = ARCHON;
240
+ criterion.goal_id = 3;
241
+ completable = new Set([3]); // the goal it LEAVES is the one that completes
242
+ const r = await patch({ goal_id: 4 });
243
+ assert.equal(r.status, 200, JSON.stringify(r.body));
244
+ assert.deepEqual(calls.achieve, [3, 4], 'both ends re-derived, source first');
245
+ assert.deepEqual(r.body.achieved_goals, [3]);
246
+ });
247
+
248
+ await test('a DETACH still re-derives the goal it left', async () => {
249
+ actor = ARCHON;
250
+ criterion.goal_id = 3;
251
+ completable = new Set([3]);
252
+ const r = await patch({ goal_id: null });
253
+ assert.equal(r.status, 200, JSON.stringify(r.body));
254
+ assert.deepEqual(calls.achieve, [3], 'no destination, but the source still counts');
255
+ assert.deepEqual(r.body.achieved_goals, [3]);
256
+ });
257
+
258
+ await test('a move that completes BOTH ends reports both', async () => {
259
+ actor = ARCHON;
260
+ criterion.goal_id = 3;
261
+ completable = new Set([3, 4]);
262
+ const r = await patch({ goal_id: 4 });
263
+ assert.deepEqual(r.body.achieved_goals, [3, 4]);
264
+ });
265
+
266
+ await test('a no-op move writes nothing at all', async () => {
267
+ actor = ARCHON;
268
+ criterion.goal_id = 3;
269
+ const r = await patch({ goal_id: 3 });
270
+ assert.equal(r.status, 200, JSON.stringify(r.body));
271
+ assert.deepEqual(calls.updateGoal, [], 'no UPDATE reached the pool');
272
+ assert.deepEqual(calls.auditMove, [], 'nothing to audit');
273
+ assert.deepEqual(calls.achieve, [], 'nothing changed, so nothing to re-derive');
274
+ });
275
+
276
+ await test('a DETACH from a goal-less criterion is also a no-op', async () => {
277
+ actor = ARCHON;
278
+ const r = await patch({ goal_id: null });
279
+ assert.equal(r.status, 200);
280
+ assert.deepEqual(calls.updateGoal, []);
281
+ });
282
+
283
+ await test('a DETACH re-derives no DESTINATION — there is none to complete', async () => {
284
+ actor = ARCHON;
285
+ criterion.goal_id = 3;
286
+ completable = new Set(); // nothing completes; only the CALL shape is under test
287
+ const r = await patch({ goal_id: null });
288
+ assert.equal(r.status, 200);
289
+ assert.deepEqual(calls.achieve, [3], 'the source, and only the source');
290
+ assert.equal(r.body.achieved_goals.length, 0);
291
+ });
292
+
293
+ await test('the move is audited with the goal it LEFT, which the request body cannot show', async () => {
294
+ criterion.goal_id = 3;
295
+ actor = ARCHON;
296
+ await patch({ goal_id: 4 });
297
+ assert.deepEqual(calls.auditMove, [{ criterionId: 55, fromGoalId: 3, toGoalId: 4 }]);
298
+ });
299
+
300
+ await test('prose and goal move in one call: both land, and the response carries both', async () => {
301
+ actor = ARCHON;
302
+ const r = await patch({ criterion_md: 'reworded', goal_id: 3 });
303
+ assert.equal(r.status, 200, JSON.stringify(r.body));
304
+ assert.deepEqual(calls.prose, [[55, 'reworded']]);
305
+ assert.deepEqual(calls.updateGoal, [[55, 3]]);
306
+ // The goal move happens last, so the returned row must carry BOTH changes —
307
+ // returning the prose result here would report goal_id as it was before.
308
+ assert.equal(r.body.criterion.goal_id, 3);
309
+ assert.equal(r.body.criterion.criterion_md, 'reworded');
310
+ });
311
+
312
+ await test('an empty patch is refused rather than silently doing nothing', async () => {
313
+ const r = await patch({});
314
+ assert.equal(r.status, 400);
315
+ assert.equal(r.body.error, 'nothing_to_update');
316
+ });
317
+
318
+ await test('prose-only editing is unchanged (the pre-existing contract)', async () => {
319
+ actor = ARCHON;
320
+ const r = await patch({ criterion_md: 'clearer wording' });
321
+ assert.equal(r.status, 200, JSON.stringify(r.body));
322
+ assert.deepEqual(calls.prose, [[55, 'clearer wording']]);
323
+ assert.deepEqual(calls.updateGoal, [], 'an absent goal_id never touches the linkage');
324
+ assert.ok(Array.isArray(r.body.deltas) && r.body.deltas.length === 1);
325
+ });
326
+
327
+ await test('the UPDATE sets goal_id from the parameter, keyed by criterion id', async () => {
328
+ // What a recording pool CAN prove is the statement, not its execution — there is
329
+ // no Postgres in this lane. So pin the shape: a SET that writes the bound
330
+ // parameter (not the column back onto itself), keyed by id, RETURNING the row
331
+ // the route hands back. Without this, `SET goal_id = goal_id` is a silent no-op
332
+ // that every behavioural test above still passes, because they read the row this
333
+ // fake returns rather than one a database wrote.
334
+ actor = ARCHON;
335
+ await patch({ goal_id: 3 });
336
+ const update = sqlLog.find((q) => /UPDATE done_when_criteria/.test(q.sql));
337
+ assert.ok(update, 'the move reached the pool at all');
338
+ const sql = update.sql.replace(/\s+/g, ' ').trim();
339
+ assert.match(sql, /SET goal_id = \$1\b/, 'writes the bound parameter, not the column onto itself');
340
+ assert.match(sql, /WHERE id = \$2\b/, 'keyed by the criterion id');
341
+ assert.match(sql, /RETURNING\b[^;]*\bgoal_id\b/, 'returns the moved row, so the response cannot go stale');
342
+ assert.deepEqual(update.params, [3, 55]);
343
+ });
344
+
345
+ await new Promise((r) => server.close(r));
346
+ console.log(`\ncriterion_goal_attach: ${passed} passed, ${failed} failed`);
347
+ process.exit(failed === 0 ? 0 : 1);