@bongos/core 1.20.65 → 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.
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.20.65",
6
- "core_contract": "1.20.65",
7
- "source_commit": "d83ddd9aee9444dee8d60cf40c8dcb9b3d32e296",
5
+ "core_version": "1.20.66",
6
+ "core_contract": "1.20.66",
7
+ "source_commit": "b20b1cd9652722eebc2cc8a17369c7bc29c7284a",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-10-01T11:03:38.854Z",
9
+ "built_at": "2026-10-01T11:16:57.096Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 572,
13
13
  "agent_docs_stubbed": 27,
14
- "functional_verbatim": 2790,
14
+ "functional_verbatim": 2793,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 3390,
20
- "tree_sha256": "ede972bceb550b8da7d974492895636275f2f66415178c7632b00d1d85e3dbe0",
19
+ "file_count": 3393,
20
+ "tree_sha256": "fe92e9b1d448a099b9733514cd16f02219f90b86e1af2612331edbb45aed7a2a",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -317,12 +317,12 @@
317
317
  {
318
318
  "path": "clients/bongos-client/README.md",
319
319
  "mode": "0000644",
320
- "sha256": "87352a8f884ec57b9263ac7596f8c66a5f353b633658304f2b531d31b9cf9b44"
320
+ "sha256": "3350163ed447189b523028f711e3ef1a400ea375ebe7123f2515add8b6a89de1"
321
321
  },
322
322
  {
323
323
  "path": "clients/bongos-client/bongos-client.global.js",
324
324
  "mode": "0000644",
325
- "sha256": "0a2e5d5157aeecb5ba74d68533e8267bc733f06eff2a72025c2fefce196c4c4e"
325
+ "sha256": "a863d3db1a112c490648e0f2b3eaddf3f2e8c9ebe107ec298ea5ff0c663a5e6a"
326
326
  },
327
327
  {
328
328
  "path": "clients/bongos-client/examples/hello-world.mjs",
@@ -332,17 +332,17 @@
332
332
  {
333
333
  "path": "clients/bongos-client/index.cjs",
334
334
  "mode": "0000644",
335
- "sha256": "1b62998873e5e55e5ddd827b48b4698e851859c667f2993d2552b79fae4e9009"
335
+ "sha256": "06bf39230e1c813494dc85130694754d1eb6b3fd55d3e93a684dd238bf6bc22b"
336
336
  },
337
337
  {
338
338
  "path": "clients/bongos-client/index.d.ts",
339
339
  "mode": "0000644",
340
- "sha256": "425cfa53b1e0bf7d7ff08675e7732312fe14e6b6bf84bb3d627beffbebbd73ad"
340
+ "sha256": "2345d96c47f3851443f8b1adc5dac4556705e7f63f9875a4c62252bf833f17d4"
341
341
  },
342
342
  {
343
343
  "path": "clients/bongos-client/index.mjs",
344
344
  "mode": "0000644",
345
- "sha256": "1a73be7803ba716ee5e03cdffdb807d2700d8cd398ce4f22a07a3fe809c3aa76"
345
+ "sha256": "313cfd428b7fd04a9fec6e56cdc1ddabb69af6ca144d7e09d2384a50d48b53df"
346
346
  },
347
347
  {
348
348
  "path": "clients/bongos-client/package.json",
@@ -2262,12 +2262,12 @@
2262
2262
  {
2263
2263
  "path": "docs/api-reference.md",
2264
2264
  "mode": "0000644",
2265
- "sha256": "630d2344ea6f8a380f99022dfab1d2c4a79b2c1804259f42f6954cb769536407"
2265
+ "sha256": "0abf904bcf32aef68bd70592d09cb986e4538cae62c2b9b268af8904d8a483fb"
2266
2266
  },
2267
2267
  {
2268
2268
  "path": "docs/api/openapi.json",
2269
2269
  "mode": "0000644",
2270
- "sha256": "bbfc9546a939b4472a734d121f4db659667ad2e647e005b882a7a7c76589a287"
2270
+ "sha256": "026ea9ba339051ada00869b8363718fae4fcded3be6eae98bcc60a37b9e844dc"
2271
2271
  },
2272
2272
  {
2273
2273
  "path": "docs/architecture.md",
@@ -2802,7 +2802,7 @@
2802
2802
  {
2803
2803
  "path": "docs/module-api-changelog.md",
2804
2804
  "mode": "0000644",
2805
- "sha256": "9754d3deec186136216507d8dfc84c40eaf82c4ca40c63f48ad60884527452bf"
2805
+ "sha256": "a6ca9dad8d9c0bd38f081d1852a161731a62e9b34a759f6740c890ee4b3f7170"
2806
2806
  },
2807
2807
  {
2808
2808
  "path": "docs/modules-contract.md",
@@ -4762,7 +4762,7 @@
4762
4762
  {
4763
4763
  "path": "modules/government/catalog.js",
4764
4764
  "mode": "0000644",
4765
- "sha256": "0559d78f54b4b8a89ac4e6924d6dad4e2693a7e54f943532e83e4ac966868b76"
4765
+ "sha256": "f3d82f60997e944eb7108c36bafa9d9be8469c5980b5460abfdf410ffa601dea"
4766
4766
  },
4767
4767
  {
4768
4768
  "path": "modules/government/charter.js",
@@ -6654,6 +6654,11 @@
6654
6654
  "mode": "0000644",
6655
6655
  "sha256": "593c5a96579f1df60f1fa232f6f31414541372a8a5277126e78b5046bcd990ac"
6656
6656
  },
6657
+ {
6658
+ "path": "modules/lifecycle/goal-move.js",
6659
+ "mode": "0000644",
6660
+ "sha256": "9f176232145d0e173d9a7fd08906186c127749c409469b7af1c02b071c9f3d23"
6661
+ },
6657
6662
  {
6658
6663
  "path": "modules/lifecycle/goal-rollup.js",
6659
6664
  "mode": "0000644",
@@ -6792,7 +6797,7 @@
6792
6797
  {
6793
6798
  "path": "modules/lifecycle/module.json",
6794
6799
  "mode": "0000644",
6795
- "sha256": "75b5ab14761c7c83b44219a0d4540b064548a38ecb1de20eab540c5cbebf162b"
6800
+ "sha256": "4eaad6ef027c1e2f3baa2d589a963e721d1f4c20201ed003e412953ea570ee8e"
6796
6801
  },
6797
6802
  {
6798
6803
  "path": "modules/lifecycle/page-tweak-approve.js",
@@ -6909,6 +6914,11 @@
6909
6914
  "mode": "0000644",
6910
6915
  "sha256": "474b9fa9e13a731f39ab15de74a75ee4878155eb2236d25b0f12011c1df99583"
6911
6916
  },
6917
+ {
6918
+ "path": "modules/lifecycle/routes/goal-move.js",
6919
+ "mode": "0000644",
6920
+ "sha256": "9b5ed11cf59a69105d861fc6bd01bddc5c7d3d29913b4d1e9d8daf73434ea083"
6921
+ },
6912
6922
  {
6913
6923
  "path": "modules/lifecycle/routes/goal-route-authz.js",
6914
6924
  "mode": "0000644",
@@ -9447,12 +9457,12 @@
9447
9457
  {
9448
9458
  "path": "package-lock.json",
9449
9459
  "mode": "0000644",
9450
- "sha256": "9c2fa6e82319388a9b4b7c68acb6aabf12cf447cc9df068c9351540e39e41f6e"
9460
+ "sha256": "3cab8f24d1505f76d3830cba2640a4d75a29b6f77cb288f3e0b73ac72528b829"
9451
9461
  },
9452
9462
  {
9453
9463
  "path": "package.json",
9454
9464
  "mode": "0000644",
9455
- "sha256": "253fd4502f205668e930fb1863b1dbc47f376462139e3a33809d84bb62157110"
9465
+ "sha256": "ae2d9d106c6a31eb87e19ab5d4da9d433a43bfa14d029773a33f8119236145e1"
9456
9466
  },
9457
9467
  {
9458
9468
  "path": "public-docs/index.html",
@@ -9472,7 +9482,7 @@
9472
9482
  {
9473
9483
  "path": "release-notes.json",
9474
9484
  "mode": "0000644",
9475
- "sha256": "e041a1335bad43a910cce4057eaf312ec70054008fef566a5b07f373fbddd8aa"
9485
+ "sha256": "1f738eb89f28f99d21310d526b289097059ddc1f660339a1282461396e06801c"
9476
9486
  },
9477
9487
  {
9478
9488
  "path": "scripts/bongos-mcp.js",
@@ -10157,7 +10167,7 @@
10157
10167
  {
10158
10168
  "path": "scripts/gds/goal.js",
10159
10169
  "mode": "0000644",
10160
- "sha256": "6cd8b2e7d23a4a8351f4018d2eec9403bb7eb16391e77fa2583a5f4c612e3046"
10170
+ "sha256": "320841b1e4df014966ea9b6b70645ecb7915b412f94b8fa0d9835dae5b9446c5"
10161
10171
  },
10162
10172
  {
10163
10173
  "path": "scripts/gds/grade-correlation-audit.js",
@@ -11492,7 +11502,7 @@
11492
11502
  {
11493
11503
  "path": "src/bongos/route-rank-check.js",
11494
11504
  "mode": "0000644",
11495
- "sha256": "3d7c62d9c3925bc1ebafeae7d90ff09fc22a09c75c2ec47382e12bcff8d017ad"
11505
+ "sha256": "c31e753d353b6c8c6b1f4e9af80a31afc995568aea9fd4ad8ab408e093a6f786"
11496
11506
  },
11497
11507
  {
11498
11508
  "path": "src/bongos/routes.js",
@@ -11652,7 +11662,7 @@
11652
11662
  {
11653
11663
  "path": "src/module-api.js",
11654
11664
  "mode": "0000644",
11655
- "sha256": "b8165107a23910ad35f528a86cd39ddff17a185fe7081f7cb8fb03f0c072a813"
11665
+ "sha256": "91a903030df7c3ff180de1a505f0de4dece1cfcbd9fc7131c22c667a2074c648"
11656
11666
  },
11657
11667
  {
11658
11668
  "path": "src/module-loader/catalog.js",
@@ -13349,6 +13359,11 @@
13349
13359
  "mode": "0000644",
13350
13360
  "sha256": "a465047f1e25c6cdeedf75f53337851224b7207c95d5135d1353504b3c660087"
13351
13361
  },
13362
+ {
13363
+ "path": "tests/goal_move.mjs",
13364
+ "mode": "0000644",
13365
+ "sha256": "7a7caded5d4d1f26b9f37a4c2c55636adc6f10ad2633f2b3260d7c4f31ebacd8"
13366
+ },
13352
13367
  {
13353
13368
  "path": "tests/goal_names.mjs",
13354
13369
  "mode": "0000644",
@@ -5,7 +5,7 @@ A **generated**, zero-dependency typed client for the Bongos API — produced fr
5
5
  by hand; it regenerates when the spec changes, so it can never drift from the routes.
6
6
 
7
7
  - API version: **v1** (served at `/api/bongos/v1`)
8
- - 484 operations across 73 resource groups
8
+ - 485 operations across 73 resource groups
9
9
 
10
10
  ## Use it from your project
11
11
 
@@ -449,6 +449,8 @@ function createClient(opts = {}) {
449
449
  postGoalsIdMembers: (args) => request("POST", "/goals/{id}/members", { hasBody: true }, args),
450
450
  // PATCH /goals/{id}/members/{builderId} — rank: any-builder — PATCH /goals/:id/members/:builderId
451
451
  patchGoalsIdMembersBuilderId: (args) => request("PATCH", "/goals/{id}/members/{builderId}", { hasBody: true }, args),
452
+ // POST /goals/{id}/move — rank: archon — POST /goals/:id/move
453
+ postGoalsIdMove: (args) => request("POST", "/goals/{id}/move", { hasBody: true }, args),
452
454
  // POST /goals/{id}/reopen — rank: metic+archon — POST /goals/:id/reopen
453
455
  postGoalsIdReopen: (args) => request("POST", "/goals/{id}/reopen", { hasBody: true }, args),
454
456
  // GET /goals/{id}/requests — rank: any-builder — GET /goals/:id/requests
@@ -448,6 +448,8 @@ function createClient(opts = {}) {
448
448
  postGoalsIdMembers: (args) => request("POST", "/goals/{id}/members", { hasBody: true }, args),
449
449
  // PATCH /goals/{id}/members/{builderId} — rank: any-builder — PATCH /goals/:id/members/:builderId
450
450
  patchGoalsIdMembersBuilderId: (args) => request("PATCH", "/goals/{id}/members/{builderId}", { hasBody: true }, args),
451
+ // POST /goals/{id}/move — rank: archon — POST /goals/:id/move
452
+ postGoalsIdMove: (args) => request("POST", "/goals/{id}/move", { hasBody: true }, args),
451
453
  // POST /goals/{id}/reopen — rank: metic+archon — POST /goals/:id/reopen
452
454
  postGoalsIdReopen: (args) => request("POST", "/goals/{id}/reopen", { hasBody: true }, args),
453
455
  // GET /goals/{id}/requests — rank: any-builder — GET /goals/:id/requests
@@ -320,6 +320,8 @@ export interface PostGoalsIdJoinResponse { ok: boolean; member: unknown }
320
320
  export interface PostGoalsIdLeaveResponse { ok: boolean }
321
321
  export interface PostGoalsIdMembersRequest { membership_kind?: string; builder_id?: StringifiableId }
322
322
  export interface PostGoalsIdMembersResponse { ok: boolean; member: unknown }
323
+ export interface PostGoalsIdMoveRequest { version_id: string; admit_reason?: string }
324
+ export interface PostGoalsIdMoveResponse { ok: boolean; goal: unknown; from_version_id: unknown; to_version_id: unknown; moved_task_ids: unknown; moved_criterion_ids: unknown; admitted: unknown }
323
325
  export interface PostGoalsIdReopenResponse { ok: boolean; goal: unknown }
324
326
  export interface PostGoalsIdRequestsReqIdRespondRequest { action: string }
325
327
  export interface PostGoalsIdRequestsReqIdRespondResponse { ok: boolean; action: unknown; request: unknown; member?: unknown }
@@ -893,6 +895,8 @@ export interface BongosClient {
893
895
  postGoalsIdMembers(args?: RequestArgs & { body?: PostGoalsIdMembersRequest }): Promise<PostGoalsIdMembersResponse>;
894
896
  /** PATCH /goals/{id}/members/{builderId} — rank: any-builder */
895
897
  patchGoalsIdMembersBuilderId(args: RequestArgs & { body: PatchGoalsIdMembersBuilderIdRequest }): Promise<PatchGoalsIdMembersBuilderIdResponse>;
898
+ /** POST /goals/{id}/move — rank: archon */
899
+ postGoalsIdMove(args: RequestArgs & { body: PostGoalsIdMoveRequest }): Promise<PostGoalsIdMoveResponse>;
896
900
  /** POST /goals/{id}/reopen — rank: metic+archon */
897
901
  postGoalsIdReopen(args?: RequestArgs): Promise<PostGoalsIdReopenResponse>;
898
902
  /** GET /goals/{id}/requests — rank: any-builder */
@@ -445,6 +445,8 @@ export function createClient(opts = {}) {
445
445
  postGoalsIdMembers: (args) => request("POST", "/goals/{id}/members", { hasBody: true }, args),
446
446
  // PATCH /goals/{id}/members/{builderId} — rank: any-builder — PATCH /goals/:id/members/:builderId
447
447
  patchGoalsIdMembersBuilderId: (args) => request("PATCH", "/goals/{id}/members/{builderId}", { hasBody: true }, args),
448
+ // POST /goals/{id}/move — rank: archon — POST /goals/:id/move
449
+ postGoalsIdMove: (args) => request("POST", "/goals/{id}/move", { hasBody: true }, args),
448
450
  // POST /goals/{id}/reopen — rank: metic+archon — POST /goals/:id/reopen
449
451
  postGoalsIdReopen: (args) => request("POST", "/goals/{id}/reopen", { hasBody: true }, args),
450
452
  // GET /goals/{id}/requests — rank: any-builder — GET /goals/:id/requests
@@ -7470,6 +7470,72 @@
7470
7470
  ]
7471
7471
  }
7472
7472
  },
7473
+ "/goals/{id}/move": {
7474
+ "post": {
7475
+ "operationId": "post_goals_id_move",
7476
+ "tags": [
7477
+ "goals"
7478
+ ],
7479
+ "summary": "POST /goals/:id/move",
7480
+ "description": "(perm:version.create) — re-homes a goal and its tasks across versions.\n\n**Rank:** `archon` — Archon only (rank and identity management + the escalation keys — the trust boundary).\n\n**Permissions:** `version.create` (all required).",
7481
+ "x-rank": "archon",
7482
+ "x-source": "modules/lifecycle/routes/goal-move.js",
7483
+ "x-permissions": [
7484
+ "version.create"
7485
+ ],
7486
+ "parameters": [
7487
+ {
7488
+ "name": "id",
7489
+ "in": "path",
7490
+ "required": true,
7491
+ "schema": {
7492
+ "type": "string"
7493
+ },
7494
+ "description": "Path parameter `id`."
7495
+ }
7496
+ ],
7497
+ "requestBody": {
7498
+ "required": true,
7499
+ "content": {
7500
+ "application/json": {
7501
+ "schema": {
7502
+ "$ref": "#/components/schemas/PostGoalsIdMoveRequest"
7503
+ }
7504
+ }
7505
+ },
7506
+ "x-validated": true
7507
+ },
7508
+ "responses": {
7509
+ "200": {
7510
+ "description": "Success.",
7511
+ "content": {
7512
+ "application/json": {
7513
+ "schema": {
7514
+ "$ref": "#/components/schemas/PostGoalsIdMoveResponse"
7515
+ }
7516
+ }
7517
+ }
7518
+ },
7519
+ "400": {
7520
+ "$ref": "#/components/responses/ValidationFailed"
7521
+ },
7522
+ "401": {
7523
+ "$ref": "#/components/responses/Unauthorized"
7524
+ },
7525
+ "403": {
7526
+ "$ref": "#/components/responses/Forbidden"
7527
+ },
7528
+ "404": {
7529
+ "$ref": "#/components/responses/NotFound"
7530
+ }
7531
+ },
7532
+ "security": [
7533
+ {
7534
+ "builderSession": []
7535
+ }
7536
+ ]
7537
+ }
7538
+ },
7473
7539
  "/goals/{id}/reopen": {
7474
7540
  "post": {
7475
7541
  "operationId": "post_goals_id_reopen",
@@ -27248,6 +27314,47 @@
27248
27314
  "member"
27249
27315
  ]
27250
27316
  },
27317
+ "PostGoalsIdMoveRequest": {
27318
+ "type": "object",
27319
+ "properties": {
27320
+ "version_id": {
27321
+ "type": "string",
27322
+ "minLength": 1,
27323
+ "maxLength": 64
27324
+ },
27325
+ "admit_reason": {
27326
+ "type": "string",
27327
+ "maxLength": 2000
27328
+ }
27329
+ },
27330
+ "required": [
27331
+ "version_id"
27332
+ ],
27333
+ "additionalProperties": false
27334
+ },
27335
+ "PostGoalsIdMoveResponse": {
27336
+ "type": "object",
27337
+ "properties": {
27338
+ "ok": {
27339
+ "type": "boolean"
27340
+ },
27341
+ "goal": {},
27342
+ "from_version_id": {},
27343
+ "to_version_id": {},
27344
+ "moved_task_ids": {},
27345
+ "moved_criterion_ids": {},
27346
+ "admitted": {}
27347
+ },
27348
+ "required": [
27349
+ "ok",
27350
+ "goal",
27351
+ "from_version_id",
27352
+ "to_version_id",
27353
+ "moved_task_ids",
27354
+ "moved_criterion_ids",
27355
+ "admitted"
27356
+ ]
27357
+ },
27251
27358
  "PostGoalsIdReopenResponse": {
27252
27359
  "type": "object",
27253
27360
  "properties": {
@@ -30890,9 +30997,9 @@
30890
30997
  "description": "A required dependency/feature is not configured or is temporarily down."
30891
30998
  }
30892
30999
  },
30893
- "x-endpoint-count": 484,
30894
- "x-schema-count": 511,
31000
+ "x-endpoint-count": 485,
31001
+ "x-schema-count": 513,
30895
31002
  "x-undocumented-bodies": 12,
30896
- "x-response-schemas": 342,
31003
+ "x-response-schemas": 343,
30897
31004
  "x-generated-by": "scripts/gds/gen-api-docs.js"
30898
31005
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Bongos API reference
4
4
 
5
- > **Generated from the live route files** — the route file is authoritative. 484 endpoints across 88 route files.
5
+ > **Generated from the live route files** — the route file is authoritative. 485 endpoints across 89 route files.
6
6
  > Machine-readable spec: [`docs/api/openapi.json`](api/openapi.json) (OpenAPI 3.1). Rendered docs site: **`/docs`** (e.g. `cloudbongos.com/docs`).
7
7
 
8
8
  Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/0016-trust-boundary-server-enforced-permissions.md)): `public` < `any-builder` < `metic+archon` < `archon`.
@@ -296,7 +296,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/0016-trust
296
296
  | POST | `/api/bongos/goal-task-relevance/:id/decide` | `metic+archon` | `decision`, `note` | task.promote — same atom as the read (task.promote's own gate). |
297
297
  | GET | `/api/bongos/goal-task-relevance/pending` | `metic+archon` | — | task.promote (metic+archon) — the reused review-queue atom. |
298
298
 
299
- ## `goals` (33)
299
+ ## `goals` (34)
300
300
 
301
301
  | Method | Path | Rank | Body | Description |
302
302
  |---|---|---|---|---|
@@ -317,6 +317,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/0016-trust
317
317
  | POST | `/api/bongos/goals/:id/leave` | `any-builder` | — | POST /goals/:id/leave — leave a goal you're in. |
318
318
  | POST | `/api/bongos/goals/:id/members` | `any-builder` | `membership_kind`, `builder_id` | POST /goals/:id/members — the goal's own authority (owner \| manager \| Archon) invites a builder to the goal (task 1762: widened from Arch… |
319
319
  | PATCH | `/api/bongos/goals/:id/members/:builderId` | `any-builder` | `membership_kind` | PATCH /goals/:id/members/:builderId — membership management by the goal's own authority (task 1735): the owner, a manager, or an Archon p… |
320
+ | POST | `/api/bongos/goals/:id/move` | `archon` | `version_id`, `admit_reason` | (perm:version.create) — re-homes a goal and its tasks across versions. |
320
321
  | POST | `/api/bongos/goals/:id/reopen` | `metic+archon` | — | POST /goals/:id/reopen — bring an archived goal back to open (ADR 0086 §6 / BV1.R64). |
321
322
  | GET | `/api/bongos/goals/:id/requests` | `any-builder` | — | GET /goals/:id/requests — the goal's own pending invite/join-request queue (task 1953): every pending row on THIS goal, both kinds — outs… |
322
323
  | POST | `/api/bongos/goals/:id/requests` | `any-builder` | `note` | POST /goals/:id/requests — a builder asks to join a (private or protected) goal (task 1952 / ADR 0112). |
@@ -2787,5 +2787,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2787
2787
  landed since 1.20.63 with no explicit bump. run 36851876428. (task 1002620)
2788
2788
  1.20.65 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2789
2789
  landed since 1.20.64 with no explicit bump. run 36852902728. (task 1002620)
2790
+ 1.20.66 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2791
+ landed since 1.20.65 with no explicit bump. run 36854308972. (task 1002620)
2790
2792
  ---------------------------------------------------------------------------
2791
2793
  ```
@@ -152,7 +152,7 @@ const PERMISSIONS = [
152
152
  // granted is not a gate" argument. That argument was heard and was not the deciding
153
153
  // one. The revisit trigger is a concrete one — the first time such a rank exists —
154
154
  // and tests/government_catalog.mjs pins the flag so the change cannot land silently.
155
- { key: 'version.create', system: false, floor: 'archon', guards: 'POST /versions — create a version + its done-when criteria (ADR 0250 §6 returns it to archon; ADR 0157 had lowered it to metic)' },
155
+ { key: 'version.create', system: false, floor: 'archon', guards: 'POST /versions — create a version + its done-when criteria (ADR 0250 §6 returns it to archon; ADR 0157 had lowered it to metic); POST /goals/:id/move — re-home a goal across versions (task 1004485)' },
156
156
  { key: 'task.confirm.grade_bypass', system: true, floor: 'archon', guards: 'POST /tasks/:id/confirm — ratify past a failed grade (env-relaxable, ADR 0041)' },
157
157
  { key: 'dependency.cross_tier.manage', system: true, floor: 'archon', guards: 'POST|DELETE /dependencies — cross-tier structural edges (ADR 0086)' },
158
158
  { key: 'goal.scope.manage', system: true, floor: 'archon', guards: 'cross-tier goal-scope edges + protected-scope admission (ADR 0086)' },
@@ -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
+ };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.65",
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.65",
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.65",
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",
@@ -8519,5 +8519,11 @@
8519
8519
  "id": "1004486",
8520
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
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
+ }
8522
8528
  ]
8523
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.65'; // 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');
@@ -0,0 +1,285 @@
1
+ // tests/goal_move.mjs — POST /goals/:id/move re-homes a goal, whole, on another
2
+ // version (task 1004485).
3
+ //
4
+ // Three layers, the goal_archive_disposition / goal_archive_route_e2e split in one
5
+ // file: the pure gate's truth table, the writer's transaction shape against a fake
6
+ // pool (every write inside ONE transaction, tasks moved by version_id AND goal,
7
+ // the cross-goal criterion refusal BEFORE any write), and the real router over a
8
+ // real socket with the db layer stubbed at the module boundary.
9
+ //
10
+ // Run: node --test tests/goal_move.mjs
11
+
12
+ import assert from 'node:assert/strict';
13
+ import { test, after } from 'node:test';
14
+ import { readFileSync } from 'node:fs';
15
+ import { createRequire } from 'node:module';
16
+ import { makeSqlAwareClient } from './helpers.mjs';
17
+
18
+ process.env.NODE_ENV = 'test';
19
+ const require = createRequire(import.meta.url);
20
+ const { authorizeGoalMove, moveGoalToVersion } = require('../modules/lifecycle/goal-move.js');
21
+
22
+ const OPEN_GOAL = { id: '7', version_id: 'V2', status: 'open', is_maintenance: false };
23
+ const V2 = { id: 'V2', status: 'building' };
24
+ const V3 = { id: 'V3', status: 'planning' };
25
+
26
+ // ---- the pure gate ----------------------------------------------------------
27
+
28
+ test('an open goal moves to the planning version', () => {
29
+ const d = authorizeGoalMove({ goal: OPEN_GOAL, source: V2, target: V3 });
30
+ assert.deepEqual(d, { ok: true, overrideApplied: false });
31
+ });
32
+
33
+ test('refusals, each by name', () => {
34
+ const cases = [
35
+ [{ goal: null, source: null, target: V3 }, 404, 'goal_not_found'],
36
+ [{ goal: { ...OPEN_GOAL, is_maintenance: true }, source: V2, target: V3 }, 409, 'maintenance_goal'],
37
+ [{ goal: { ...OPEN_GOAL, status: 'achieved' }, source: V2, target: V3 }, 409, 'goal_not_open'],
38
+ [{ goal: { ...OPEN_GOAL, status: 'archived' }, source: V2, target: V3 }, 409, 'goal_not_open'],
39
+ [{ goal: OPEN_GOAL, source: { id: 'V2', status: 'shipped' }, target: V3 }, 409, 'source_version_shipped'],
40
+ [{ goal: OPEN_GOAL, source: V2, target: V2 }, 409, 'same_version'],
41
+ [{ goal: OPEN_GOAL, source: V2, target: null }, 404, 'version_not_found'],
42
+ [{ goal: OPEN_GOAL, source: V2, target: { id: 'V1', status: 'shipped' } }, 409, 'version_closed'],
43
+ [{ goal: { ...OPEN_GOAL, version_id: 'V3' }, source: V3, target: V2 }, 409, 'version_not_planning'],
44
+ ];
45
+ for (const [input, status, error] of cases) {
46
+ const d = authorizeGoalMove(input);
47
+ assert.equal(d.ok, false, error);
48
+ assert.equal(d.status, status, error);
49
+ assert.equal(d.body.error, error);
50
+ }
51
+ });
52
+
53
+ test('a building target opens only with the admit_reason override, and says so', () => {
54
+ const goal = { ...OPEN_GOAL, version_id: 'V3' };
55
+ assert.deepEqual(authorizeGoalMove({ goal, source: V3, target: V2, admitReason: 'owner pulled it back' }),
56
+ { ok: true, overrideApplied: true });
57
+ // On a planning target the reason changes nothing — so nothing is recorded.
58
+ assert.deepEqual(authorizeGoalMove({ goal: OPEN_GOAL, source: V2, target: V3, admitReason: 'x' }),
59
+ { ok: true, overrideApplied: false });
60
+ });
61
+
62
+ // ---- the writer ---------------------------------------------------------------
63
+
64
+ const flat = (q) => String(q.sql).replace(/\s+/g, ' ').trim();
65
+ function txPool(handler) {
66
+ const c = makeSqlAwareClient(handler);
67
+ return { connect: c.connect, query: (sql, params) => c._client.query(sql, params), queries: c._client.queries };
68
+ }
69
+ function happyPool(over = {}) {
70
+ return txPool((q) => {
71
+ for (const [re, rows] of Object.entries(over)) if (new RegExp(re).test(q)) return { rows };
72
+ if (/FROM goals WHERE id = \$1 FOR UPDATE/.test(q)) return { rows: [{ id: '7', version_id: 'V2', status: 'open' }] };
73
+ if (/FROM versions WHERE id = \$1 FOR SHARE/.test(q)) return { rows: [{ status: 'planning' }] };
74
+ if (/FROM task_criteria/.test(q)) return { rows: [] };
75
+ if (/UPDATE done_when_criteria/.test(q)) return { rows: [{ id: '51' }] };
76
+ if (/UPDATE tasks/.test(q)) return { rows: [{ id: '12' }, { id: '3' }] };
77
+ return { rows: [] };
78
+ });
79
+ }
80
+ const ARGS = { goalId: 7, fromVersionId: 'V2', toVersionId: 'V3', targetStatus: 'planning' };
81
+
82
+ test('goal, criteria and every task move inside ONE transaction', async () => {
83
+ const pool = happyPool();
84
+ const out = await moveGoalToVersion(ARGS, { pool });
85
+ assert.deepEqual(out, { fromVersionId: 'V2', toVersionId: 'V3', taskIds: ['3', '12'], criterionIds: ['51'] });
86
+ const sqls = pool.queries.map(flat);
87
+ const at = (re) => sqls.findIndex((s) => re.test(s));
88
+ assert.equal(sqls.filter((s) => s === 'BEGIN').length, 1);
89
+ assert.equal(sqls.filter((s) => s === 'COMMIT').length, 1);
90
+ for (const re of [/^UPDATE goals SET version_id = \$2/, /^UPDATE done_when_criteria SET version_id = \$2 WHERE goal_id = \$1/, /^UPDATE tasks SET version_id = \$2/]) {
91
+ const i = at(re);
92
+ assert.ok(i > at(/^BEGIN$/) && i < at(/^COMMIT$/), `${re} is inside the transaction`);
93
+ }
94
+ // Every task in the goal — not only the open ones — or a shipped task would be
95
+ // left in a goal of another version.
96
+ const taskWrite = sqls[at(/^UPDATE tasks/)];
97
+ assert.match(taskWrite, /WHERE goal_id = \$1 RETURNING/);
98
+ assert.doesNotMatch(taskWrite, /status/);
99
+ // The goal is locked before anything is read or written.
100
+ assert.ok(at(/FROM goals WHERE id = \$1 FOR UPDATE/) < at(/^UPDATE/));
101
+ });
102
+
103
+ test('a criterion link crossing the goal edge refuses BEFORE any write, and names the pair', async () => {
104
+ const pool = happyPool({ 'FROM task_criteria': [{ task_id: '12', criterion_id: '900' }] });
105
+ await assert.rejects(moveGoalToVersion(ARGS, { pool }), (e) => {
106
+ assert.equal(e.code, 'CRITERION_LINKS_CROSS_GOAL');
107
+ assert.deepEqual(e.links, [{ task_id: '12', criterion_id: '900' }]);
108
+ return true;
109
+ });
110
+ const sqls = pool.queries.map(flat);
111
+ assert.equal(sqls.filter((s) => /^UPDATE|^INSERT/.test(s)).length, 0, 'nothing written');
112
+ assert.ok(sqls.includes('ROLLBACK'));
113
+ });
114
+
115
+ test('the crossing test asks "exactly one side inside the goal"', async () => {
116
+ const pool = happyPool();
117
+ await moveGoalToVersion(ARGS, { pool });
118
+ const q = pool.queries.map(flat).find((s) => /FROM task_criteria/.test(s));
119
+ assert.match(q, /\(t\.goal_id IS NOT DISTINCT FROM \$1\) <> \(c\.goal_id IS NOT DISTINCT FROM \$1\)/);
120
+ });
121
+
122
+ test('a goal or target that changed since the route checked rolls back', async () => {
123
+ for (const [over, code] of [
124
+ [{ 'FROM goals WHERE id = \\$1 FOR UPDATE': [{ id: '7', version_id: 'V9', status: 'open' }] }, 'GOAL_CHANGED'],
125
+ [{ 'FROM goals WHERE id = \\$1 FOR UPDATE': [{ id: '7', version_id: 'V2', status: 'archived' }] }, 'GOAL_CHANGED'],
126
+ [{ 'FROM versions WHERE id = \\$1 FOR SHARE': [{ status: 'building' }] }, 'TARGET_CHANGED'],
127
+ [{ 'FROM goals WHERE id = \\$1 FOR UPDATE': [] }, 'GOAL_NOT_FOUND'],
128
+ ]) {
129
+ const pool = happyPool(over);
130
+ await assert.rejects(moveGoalToVersion(ARGS, { pool }), (e) => e.code === code);
131
+ assert.equal(pool.queries.map(flat).filter((s) => /^UPDATE/.test(s)).length, 0, code);
132
+ }
133
+ });
134
+
135
+ test('a criterion slug the target already uses becomes CRITERION_ID_COLLISION', async () => {
136
+ const pool = txPool((q) => {
137
+ if (/FROM goals WHERE id = \$1 FOR UPDATE/.test(q)) return { rows: [{ id: '7', version_id: 'V2', status: 'open' }] };
138
+ if (/FROM versions WHERE id = \$1 FOR SHARE/.test(q)) return { rows: [{ status: 'planning' }] };
139
+ if (/UPDATE done_when_criteria/.test(q)) throw Object.assign(new Error('dup'), { code: '23505' });
140
+ return { rows: [] };
141
+ });
142
+ await assert.rejects(moveGoalToVersion(ARGS, { pool }), (e) => e.code === 'CRITERION_ID_COLLISION');
143
+ });
144
+
145
+ test('the admission is written only when the override opened a building target', async () => {
146
+ const plain = happyPool();
147
+ await moveGoalToVersion(ARGS, { pool: plain });
148
+ assert.equal(plain.queries.map(flat).filter((s) => /lifecycle_goal_version_admissions/.test(s)).length, 0);
149
+
150
+ const admitted = happyPool({ 'FROM versions WHERE id = \\$1 FOR SHARE': [{ status: 'building' }] });
151
+ await moveGoalToVersion({ ...ARGS, targetStatus: 'building', admission: { admittedBy: '3', reason: 'r' } }, { pool: admitted });
152
+ const ins = admitted.queries.find((q) => /lifecycle_goal_version_admissions/.test(flat(q)));
153
+ assert.ok(ins, 'admission recorded');
154
+ assert.deepEqual(ins.params, [7, 'V3', '3', 'r']);
155
+ });
156
+
157
+ // ---- the route, over a real socket ---------------------------------------------
158
+
159
+ const express = require('express');
160
+ const api = require('../src/module-api.js');
161
+ const db = require('../modules/lifecycle/db.js');
162
+ const goalMove = require('../modules/lifecycle/goal-move.js');
163
+
164
+ let permSeen = null;
165
+ api.requireBuilder = (req, _res, next) => { req.builder = { id: '3', rank: 'archon' }; next(); };
166
+ api.requirePermission = (...p) => { permSeen = p; return (_req, _res, next) => next(); };
167
+
168
+ const GOALS = { 7: { ...OPEN_GOAL }, 8: { id: '8', version_id: 'V2', status: 'open', is_maintenance: true } };
169
+ const VERSIONS = { V2, V3 };
170
+ let writes = [];
171
+ db.getGoal = async (id) => GOALS[Number(id)] ?? null;
172
+ db.getVersion = async (id) => VERSIONS[id] ?? null;
173
+ goalMove.moveGoalToVersion = async (args) => {
174
+ writes.push(args);
175
+ GOALS[args.goalId] = { ...GOALS[args.goalId], version_id: args.toVersionId };
176
+ return { fromVersionId: args.fromVersionId, toVersionId: args.toVersionId, taskIds: ['3', '12'], criterionIds: [] };
177
+ };
178
+
179
+ const app = express();
180
+ app.use(express.json());
181
+ app.use((req, res, next) => {
182
+ res.fail = (code, statusOrOpts, details) => {
183
+ const o = typeof statusOrOpts === 'number' ? { status: statusOrOpts } : (statusOrOpts || {});
184
+ return res.status(o.status || 400).json({ error: code, message: o.message, details: o.details ?? details });
185
+ };
186
+ next();
187
+ });
188
+ app.use('/api/gds', require('../modules/lifecycle/routes/goal-move.js')());
189
+ const server = app.listen(0);
190
+ await new Promise((r) => server.once('listening', r));
191
+ after(() => server.close());
192
+ const base = `http://127.0.0.1:${server.address().port}/api/gds`;
193
+ const post = async (p, body) => {
194
+ const r = await fetch(base + p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body ?? {}) });
195
+ return { status: r.status, body: await r.json().catch(() => null) };
196
+ };
197
+
198
+ test('the route is gated on the version-authoring atom', () => {
199
+ assert.deepEqual(permSeen, ['version.create']);
200
+ const pins = readFileSync(new URL('../src/bongos/route-rank-check.js', import.meta.url), 'utf8');
201
+ assert.match(pins, /'POST \/goals\/:id\/move': 'perm:version\.create'/);
202
+ });
203
+
204
+ test('a move returns the goal on its new version and the tasks that went with it', async () => {
205
+ writes = [];
206
+ const r = await post('/goals/7/move', { version_id: 'V3' });
207
+ assert.equal(r.status, 200, JSON.stringify(r.body));
208
+ assert.equal(r.body.goal.version_id, 'V3');
209
+ assert.equal(r.body.from_version_id, 'V2');
210
+ assert.deepEqual(r.body.moved_task_ids, ['3', '12']);
211
+ assert.equal(r.body.admitted, false);
212
+ assert.deepEqual(writes, [{ goalId: 7, fromVersionId: 'V2', toVersionId: 'V3', targetStatus: 'planning', admission: null }]);
213
+ });
214
+
215
+ test('a refused move writes nothing and renders the gate verbatim', async () => {
216
+ writes = [];
217
+ let r = await post('/goals/8/move', { version_id: 'V3' });
218
+ assert.equal(r.status, 409); assert.equal(r.body.error, 'maintenance_goal');
219
+ r = await post('/goals/404/move', { version_id: 'V3' });
220
+ assert.equal(r.status, 404); assert.equal(r.body.error, 'goal_not_found');
221
+ r = await post('/goals/8/move', {});
222
+ assert.equal(r.status, 400);
223
+ r = await post('/goals/8/move', { version_id: 'V3', versionid: 'typo' });
224
+ assert.equal(r.status, 400, 'an unknown field is rejected, not ignored');
225
+ assert.equal(writes.length, 0);
226
+ });
227
+
228
+ test('a crossing criterion link surfaces as 409 with the pairs', async () => {
229
+ GOALS[9] = { id: '9', version_id: 'V2', status: 'open', is_maintenance: false };
230
+ const real = goalMove.moveGoalToVersion;
231
+ goalMove.moveGoalToVersion = async () => { throw Object.assign(new Error('x'), { code: 'CRITERION_LINKS_CROSS_GOAL', links: [{ task_id: '1', criterion_id: '2' }] }); };
232
+ const r = await post('/goals/9/move', { version_id: 'V3' });
233
+ goalMove.moveGoalToVersion = real;
234
+ assert.equal(r.status, 409);
235
+ assert.equal(r.body.error, 'criterion_links_cross_goal');
236
+ assert.deepEqual(r.body.details.links, [{ task_id: '1', criterion_id: '2' }]);
237
+ });
238
+
239
+ // ---- the CLI wrapper ----------------------------------------------------------
240
+
241
+ test('goal.js move parses a real goal id and version id, and refuses junk', () => {
242
+ const { parseMoveArgs } = require('../scripts/gds/goal.js');
243
+ assert.deepEqual(parseMoveArgs(['1000122', '--to', 'BONGOS-V3']), { id: 1000122, body: { version_id: 'BONGOS-V3' } });
244
+ assert.deepEqual(parseMoveArgs(['7', '--to=V3.1', '--admit-reason', 'owner call']),
245
+ { id: 7, body: { version_id: 'V3.1', admit_reason: 'owner call' } });
246
+ for (const bad of [[], ['abc', '--to', 'V3'], ['7'], ['7', '--to'], ['7', '--to', 'V 3'], ['7', '--to', 'V3;rm']]) {
247
+ assert.equal(parseMoveArgs(bad), null, JSON.stringify(bad));
248
+ }
249
+ });
250
+
251
+ // ---- version progress follows the move --------------------------------------
252
+ //
253
+ // The live view counts a version's tasks by tasks.version_id — so the counts move
254
+ // with the goal exactly when the writer re-points every task's version_id, which
255
+ // the transaction test above pins. Both halves are asserted here together: if the
256
+ // view ever counted through goals instead, or the writer stopped touching
257
+ // tasks.version_id, V2's progress would keep counting moved work.
258
+
259
+ test('version_progress counts by tasks.version_id, and the move rewrites it for every task in the goal', async () => {
260
+ const { readdirSync } = await import('node:fs');
261
+ const files = readdirSync(new URL('../migrations/', import.meta.url)).filter((f) => f.endsWith('.sql')).sort();
262
+ const latest = files.filter((f) => /VIEW version_progress/.test(readFileSync(new URL('../migrations/' + f, import.meta.url), 'utf8'))).pop();
263
+ assert.ok(latest, 'version_progress view found');
264
+ const view = readFileSync(new URL('../migrations/' + latest, import.meta.url), 'utf8');
265
+ assert.match(view, /LEFT JOIN tasks t ON t\.version_id = v\.id/);
266
+
267
+ // Before/after against the writer's own statements: a goal holding tasks 3 and
268
+ // 12 on V2 — after the move both carry V3, so V2 counts 0 of them and V3 counts 2.
269
+ const tasks = [{ id: '3', goal_id: 7, version_id: 'V2' }, { id: '12', goal_id: 7, version_id: 'V2' }, { id: '99', goal_id: 8, version_id: 'V2' }];
270
+ const count = (v) => tasks.filter((t) => t.version_id === v).length;
271
+ assert.equal(count('V2'), 3); assert.equal(count('V3'), 0);
272
+ const pool = txPool((q, params) => {
273
+ if (/FROM goals WHERE id = \$1 FOR UPDATE/.test(q)) return { rows: [{ id: '7', version_id: 'V2', status: 'open' }] };
274
+ if (/FROM versions WHERE id = \$1 FOR SHARE/.test(q)) return { rows: [{ status: 'planning' }] };
275
+ if (/^UPDATE tasks SET version_id = \$2/.test(q)) {
276
+ const hit = tasks.filter((t) => t.goal_id === params[0]);
277
+ for (const t of hit) t.version_id = params[1];
278
+ return { rows: hit.map((t) => ({ id: t.id })) };
279
+ }
280
+ return { rows: [] };
281
+ });
282
+ await moveGoalToVersion(ARGS, { pool });
283
+ assert.equal(count('V2'), 1, 'only the other goal\'s task still counts on V2');
284
+ assert.equal(count('V3'), 2);
285
+ });