@bongos/core 1.19.589 → 1.19.591

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.589",
6
- "core_contract": "1.19.589",
7
- "source_commit": "29fe6277ac47bc629f56c68e20dca829ed9370f2",
5
+ "core_version": "1.19.591",
6
+ "core_contract": "1.19.591",
7
+ "source_commit": "1a5d5f0eb546f0430b57a3ca5446be6ebd135fe9",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-08T02:16:53.692Z",
9
+ "built_at": "2026-09-08T02:46:21.428Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 457,
13
13
  "agent_docs_stubbed": 24,
14
- "functional_verbatim": 2060,
14
+ "functional_verbatim": 2063,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2541,
20
- "tree_sha256": "d96aab84f10a639efe4d035824854aed5cb9bf2fcc7723f62a69f4b1a8074591",
19
+ "file_count": 2544,
20
+ "tree_sha256": "8c944ccd379b18b96ad8d347ecc09ae1d5a3d163d40ca3c446e9bce07f86a002",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -392,7 +392,7 @@
392
392
  {
393
393
  "path": "clients/bongos-client/index.d.ts",
394
394
  "mode": "0000644",
395
- "sha256": "e5cfa78c545e60771166ae4118e18f5c7bcf911db649c115c2831a028de3562b"
395
+ "sha256": "3865a914bd9e50df23960dbad5783bd1584157ff2b2bfcd2b8e597a620e81c01"
396
396
  },
397
397
  {
398
398
  "path": "clients/bongos-client/index.mjs",
@@ -1847,12 +1847,12 @@
1847
1847
  {
1848
1848
  "path": "docs/api-reference.md",
1849
1849
  "mode": "0000644",
1850
- "sha256": "450613589b6b480ba2d6789731003794749c0dd972fcd8636380fa5814d68807"
1850
+ "sha256": "5e2031bf695567cb4318ef792395150b7032604e0e4cd7cafee99f05e1d3cb01"
1851
1851
  },
1852
1852
  {
1853
1853
  "path": "docs/api/openapi.json",
1854
1854
  "mode": "0000644",
1855
- "sha256": "75ba055326939ec1c827d01cdf088f564e94f372d9906759483fd1bad2328375"
1855
+ "sha256": "597f489b67a88154ee771baa8b378bf681594534cf7690c2ffd9c109cb26092d"
1856
1856
  },
1857
1857
  {
1858
1858
  "path": "docs/architecture.md",
@@ -2727,7 +2727,7 @@
2727
2727
  {
2728
2728
  "path": "docs/module-api-changelog.md",
2729
2729
  "mode": "0000644",
2730
- "sha256": "0ca6c49c46b3bc4621613099885595eb387c784e86f9fb83d5600912c1ea4c38"
2730
+ "sha256": "32f8765fbbc0999a25b5abfc0f58a5dc5980db09c03ff4ee127b6774387c865a"
2731
2731
  },
2732
2732
  {
2733
2733
  "path": "docs/modules-contract.md",
@@ -5512,7 +5512,7 @@
5512
5512
  {
5513
5513
  "path": "modules/lifecycle/db-goals.js",
5514
5514
  "mode": "0000644",
5515
- "sha256": "f50a54a0e0f0cdeac5a2321b14757e52d9d9507417b1d1962aa09cdc6a222832"
5515
+ "sha256": "392970a48349421c000de9581cfcc63969152179feb3406f1057571d9b68c0ee"
5516
5516
  },
5517
5517
  {
5518
5518
  "path": "modules/lifecycle/db-grade.js",
@@ -5552,7 +5552,7 @@
5552
5552
  {
5553
5553
  "path": "modules/lifecycle/db.js",
5554
5554
  "mode": "0000644",
5555
- "sha256": "6d69634ad12510f81727eeed0a8fdde4eac618fedc23e43e2c1aa5835aaf0388"
5555
+ "sha256": "c3318a8473ccc9b41bff5eb94fe4096032c2d5c5a7819d0777c4d41db0e4d695"
5556
5556
  },
5557
5557
  {
5558
5558
  "path": "modules/lifecycle/dead-deps.js",
@@ -5732,12 +5732,12 @@
5732
5732
  {
5733
5733
  "path": "modules/lifecycle/routes/goal-route-authz.js",
5734
5734
  "mode": "0000644",
5735
- "sha256": "5022ec9cdd2ba5087bc0b4607691c21d361f4a4edbf029ff3dea37ade373e890"
5735
+ "sha256": "3b4fb0168b29fb0df1260c23c5b14d96576f00f587dd5c188fe17a712e3dffaa"
5736
5736
  },
5737
5737
  {
5738
5738
  "path": "modules/lifecycle/routes/goals.js",
5739
5739
  "mode": "0000644",
5740
- "sha256": "d408d89cf736a7d4cdf45ccff44c2038bbc64b13be42dd1b460198dfc8112535"
5740
+ "sha256": "28874a1b4d3e748866fa59d510c3c62656ad48039dae8ca7ce5b0ca5d47b357d"
5741
5741
  },
5742
5742
  {
5743
5743
  "path": "modules/lifecycle/routes/help-requests.js",
@@ -7602,12 +7602,12 @@
7602
7602
  {
7603
7603
  "path": "package-lock.json",
7604
7604
  "mode": "0000644",
7605
- "sha256": "57bf21d2c0b7e235c4628f7f1d7e52c8b65b30375d96adcce8558a5e1cce1b5a"
7605
+ "sha256": "b89e31e9693695643d5effcfe1386164ec4b8759e50fec6d86fe4edf19f09cb6"
7606
7606
  },
7607
7607
  {
7608
7608
  "path": "package.json",
7609
7609
  "mode": "0000644",
7610
- "sha256": "fc682e53b7902f41fb4a455c9f61d70a5b154159df150b6e7e6c696c596df4e4"
7610
+ "sha256": "09428614577361d939096da4a4a91a0f742a0504d97168cdea6d169ccb8931be"
7611
7611
  },
7612
7612
  {
7613
7613
  "path": "public-docs/index.html",
@@ -8087,7 +8087,7 @@
8087
8087
  {
8088
8088
  "path": "scripts/gds/gds-literal-scan.js",
8089
8089
  "mode": "0000644",
8090
- "sha256": "fe622c9abf848a668fe2672d699fef22141156f41bb2396523461d1484341ec1"
8090
+ "sha256": "3f873da74a012f73e30e659f070b3476eda81ac55f802913307494a645d8ee88"
8091
8091
  },
8092
8092
  {
8093
8093
  "path": "scripts/gds/gen-api-client.js",
@@ -8454,6 +8454,11 @@
8454
8454
  "mode": "0000644",
8455
8455
  "sha256": "4cbb7baf533e1aa2eced1c828f37bbd11f7183846a0f3862f9fb72b3f504be66"
8456
8456
  },
8457
+ {
8458
+ "path": "scripts/gds/rename-history-check.js",
8459
+ "mode": "0000644",
8460
+ "sha256": "06ad6a69304cfc66509f4ab283e7f76196ac7edcac97f8eba55ea3ab92c3eecb"
8461
+ },
8457
8462
  {
8458
8463
  "path": "scripts/gds/render-ideas.js",
8459
8464
  "mode": "0000644",
@@ -9317,7 +9322,7 @@
9317
9322
  {
9318
9323
  "path": "src/module-api.js",
9319
9324
  "mode": "0000644",
9320
- "sha256": "9264831ff874468719e9a6f73a3dfa39507f6ce033f25cdfc9d08e840d1a58fe"
9325
+ "sha256": "5c9d16a923a0cb30e18960d8714e1e8454940d1c4d6cef4d22fa8096a98237ab"
9321
9326
  },
9322
9327
  {
9323
9328
  "path": "src/module-loader/catalog.js",
@@ -10437,7 +10442,12 @@
10437
10442
  {
10438
10443
  "path": "tests/goal_archive_disposition.mjs",
10439
10444
  "mode": "0000644",
10440
- "sha256": "d2056a3ae84b79d64409e6e43c477f6b160f202903be61710b40bc4fd06c00c9"
10445
+ "sha256": "bf0baad459ee2e103079a53570ca4998bb3ba66451ad0257478dd236118c8ce6"
10446
+ },
10447
+ {
10448
+ "path": "tests/goal_archive_route_e2e.mjs",
10449
+ "mode": "0000644",
10450
+ "sha256": "ffefb97db455e2b6656f5c26933312572102e3a8aa37605545ae9b425d616be0"
10441
10451
  },
10442
10452
  {
10443
10453
  "path": "tests/goal_category.mjs",
@@ -11954,6 +11964,11 @@
11954
11964
  "mode": "0000644",
11955
11965
  "sha256": "a7a0a61260a4ee78f5452b76ee6d8ef732104cf9c32a3c05bfa25517bf883aeb"
11956
11966
  },
11967
+ {
11968
+ "path": "tests/rename_history_restraint.mjs",
11969
+ "mode": "0000644",
11970
+ "sha256": "2cbc4981c0f33a6597445f268055d10107b6696be4de42e5790e7efefc087edd"
11971
+ },
11957
11972
  {
11958
11973
  "path": "tests/render_prefs.mjs",
11959
11974
  "mode": "0000644",
@@ -252,7 +252,8 @@ export interface PostGithubRepoVisibilityRequest { repo: string; visibility: "pu
252
252
  export interface PostGithubRepoVisibilityResponse { ok: boolean; repo: unknown; private_deployable: boolean }
253
253
  export interface PostGithubReposRequest { name: string }
254
254
  export interface PostGithubReposResponse { repo: unknown }
255
- export interface PostGoalsIdArchiveResponse { ok: boolean; goal: unknown }
255
+ export interface PostGoalsIdArchiveRequest { reason?: string; dispositions?: Record<string, unknown> }
256
+ export interface PostGoalsIdArchiveResponse { ok: boolean; goal: unknown; applied: unknown }
256
257
  export interface PostGoalsIdConflictsResolveRequest { action: string; note?: string; touches?: string[]; first_task_id?: StringifiableId; then_task_id?: StringifiableId; task_id?: StringifiableId }
257
258
  export interface PostGoalsIdConflictsResolveResponse { ok: boolean; action: unknown; first_task_id?: unknown; then_task_id?: unknown; dependencies?: unknown; claim?: unknown; task_id?: unknown; touches?: unknown; requires_rank?: unknown }
258
259
  export interface PostGoalsIdInvitationsReqIdRescindResponse { ok: boolean; request: unknown }
@@ -676,7 +677,7 @@ export interface BongosClient {
676
677
  /** PATCH /goals/{id}/accepting-requests — rank: any-builder */
677
678
  patchGoalsIdAcceptingRequests(args?: RequestArgs): Promise<ApiResponse>;
678
679
  /** POST /goals/{id}/archive — rank: metic+archon */
679
- postGoalsIdArchive(args?: RequestArgs): Promise<PostGoalsIdArchiveResponse>;
680
+ postGoalsIdArchive(args?: RequestArgs & { body?: PostGoalsIdArchiveRequest }): Promise<PostGoalsIdArchiveResponse>;
680
681
  /** PATCH /goals/{id}/category — rank: any-builder */
681
682
  patchGoalsIdCategory(args?: RequestArgs): Promise<ApiResponse>;
682
683
  /** GET /goals/{id}/conflicts — rank: any-builder */
@@ -5139,6 +5139,17 @@
5139
5139
  "description": "Path parameter `id`."
5140
5140
  }
5141
5141
  ],
5142
+ "requestBody": {
5143
+ "required": false,
5144
+ "content": {
5145
+ "application/json": {
5146
+ "schema": {
5147
+ "$ref": "#/components/schemas/PostGoalsIdArchiveRequest"
5148
+ }
5149
+ }
5150
+ },
5151
+ "x-validated": true
5152
+ },
5142
5153
  "responses": {
5143
5154
  "200": {
5144
5155
  "description": "Success.",
@@ -5151,7 +5162,7 @@
5151
5162
  }
5152
5163
  },
5153
5164
  "400": {
5154
- "$ref": "#/components/responses/BadRequest"
5165
+ "$ref": "#/components/responses/ValidationFailed"
5155
5166
  },
5156
5167
  "401": {
5157
5168
  "$ref": "#/components/responses/Unauthorized"
@@ -19912,17 +19923,32 @@
19912
19923
  "repo"
19913
19924
  ]
19914
19925
  },
19926
+ "PostGoalsIdArchiveRequest": {
19927
+ "type": "object",
19928
+ "properties": {
19929
+ "reason": {
19930
+ "type": "string",
19931
+ "maxLength": 2000
19932
+ },
19933
+ "dispositions": {
19934
+ "type": "object"
19935
+ }
19936
+ },
19937
+ "additionalProperties": false
19938
+ },
19915
19939
  "PostGoalsIdArchiveResponse": {
19916
19940
  "type": "object",
19917
19941
  "properties": {
19918
19942
  "ok": {
19919
19943
  "type": "boolean"
19920
19944
  },
19921
- "goal": {}
19945
+ "goal": {},
19946
+ "applied": {}
19922
19947
  },
19923
19948
  "required": [
19924
19949
  "ok",
19925
- "goal"
19950
+ "goal",
19951
+ "applied"
19926
19952
  ]
19927
19953
  },
19928
19954
  "PostGoalsIdConflictsResolveRequest": {
@@ -22676,7 +22702,7 @@
22676
22702
  }
22677
22703
  },
22678
22704
  "x-endpoint-count": 359,
22679
- "x-schema-count": 395,
22705
+ "x-schema-count": 396,
22680
22706
  "x-undocumented-bodies": 9,
22681
22707
  "x-response-schemas": 267,
22682
22708
  "x-generated-by": "scripts/gds/gen-api-docs.js"
@@ -230,7 +230,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
230
230
  | GET | `/api/bongos/goals/:id` | `any-builder` | — | GET /goals/:id — one goal with its members + linked criteria (read). |
231
231
  | PATCH | `/api/bongos/goals/:id` | `any-builder` | — | PATCH /goals/:id — correct a goal's own title/subtitle/description (tasks 1003102, 1003224). |
232
232
  | PATCH | `/api/bongos/goals/:id/accepting-requests` | `any-builder` | — | PATCH /goals/:id/accepting-requests { accepting_requests } — a private goal's lead closes or re-opens it to join-requests (task 1002971, … |
233
- | POST | `/api/bongos/goals/:id/archive` | `metic+archon` | | POST /goals/:id/archive — retire a goal (open\|achieved → archived). |
233
+ | POST | `/api/bongos/goals/:id/archive` | `metic+archon` | `reason`, `dispositions` | POST /goals/:id/archive — retire a goal (open\|achieved → archived). |
234
234
  | PATCH | `/api/bongos/goals/:id/category` | `any-builder` | — | PATCH /goals/:id/category — body { category_id } (null clears it). |
235
235
  | GET | `/api/bongos/goals/:id/conflicts` | `any-builder` | — | GET /goals/:id/conflicts — the server-side task-conflict view (task 1736). |
236
236
  | POST | `/api/bongos/goals/:id/conflicts/resolve` | `any-builder` | `action`, `note`, `touches`, `first_task_id`, `then_task_id`, `task_id` | POST /goals/:id/conflicts/resolve — the owner/manager resolution actions for a detected conflict (task 1736; authority model ADR 0106): s… |
@@ -1627,5 +1627,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1627
1627
  landed since 1.19.587 with no explicit bump. run 34178696372. (task 1002620)
1628
1628
  1.19.589 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1629
1629
  landed since 1.19.588 with no explicit bump. run 34179479729. (task 1002620)
1630
+ 1.19.590 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1631
+ landed since 1.19.589 with no explicit bump. run 34180363432. (task 1002620)
1632
+ 1.19.591 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1633
+ landed since 1.19.590 with no explicit bump. run 34181177013. (task 1002620)
1630
1634
  ---------------------------------------------------------------------------
1631
1635
  ```
@@ -14,7 +14,7 @@ const api = require('../../src/module-api');
14
14
  const { pool } = api;
15
15
  const doneWhen = require('./done-when.js');
16
16
  const { deriveRequiredRank, highestRank } = require('./db-rank-authz.js');
17
- const { nonTerminalSql } = require('./task-status');
17
+ const { nonTerminalSql, isTerminalStatus } = require('./task-status');
18
18
  const { maintenanceGoalTitle } = require('./goal-advisory.js');
19
19
  const { SMOKE_NOT_LIKE } = require('./db-shared.js');
20
20
 
@@ -487,6 +487,130 @@ async function openTasksInGoal(goalId, { cap = 50 } = {}, deps = {}) {
487
487
  );
488
488
  return { total: rows[0]?.total ?? 0, tasks: rows.map(({ total, ...t }) => t) };
489
489
  }
490
+ // archiveGoalWithDispositions — apply the caller's decision about each unfinished
491
+ // task, then archive the goal, in ONE transaction (BV1.R14, task 1003601, goal
492
+ // 1000086, ADR 0250 D3). The write half of the archive two-step whose refusal
493
+ // R10 (task 1003597) built and whose plan `planGoalArchiveDispositions` validates.
494
+ //
495
+ // ATOMIC IS THE WHOLE POINT, not a nicety. The failure this replaces is a goal
496
+ // reported closed while its work is still open; a partially-applied disposition
497
+ // produces the mirror of it — some tasks abandoned, some moved, the goal still
498
+ // open — and leaves the caller unable to tell from outside which half ran. One
499
+ // transaction means the call is safe to retry verbatim after any error.
500
+ //
501
+ // The goal row is locked FOR UPDATE first, so a concurrent create-task-in-goal
502
+ // cannot slip a new task in between the plan being validated and the archive
503
+ // landing. The final re-count inside the same transaction is the belt to that
504
+ // braces: it asserts the invariant the whole goal exists to protect (an archived
505
+ // goal holds no non-terminal work) against the committed state rather than
506
+ // against the plan, and rolls back if it does not hold. Cheap, and it is the one
507
+ // check that cannot be fooled by a bug in the planner.
508
+ //
509
+ // `plan` is the validated output of planGoalArchiveDispositions: [{ taskId, verb,
510
+ // toGoalId }]. Move targets are resolved and checked open BY THE ROUTE via
511
+ // goalNotOpenRefusal — the ONE definition of "this goal takes no new work"
512
+ // (BV1.R05) — and are re-read here under lock only to guard the interval between
513
+ // that check and this write.
514
+ //
515
+ // ABANDON MIRRORS abandonTask (db-ship.js) rather than calling it: that function
516
+ // opens its own connection and commits, which cannot join this transaction. The
517
+ // two behaviours kept identical on purpose are the `value_summary` stamp
518
+ // (`Abandoned: <reason>`) and the refusal on an active claim — a task someone is
519
+ // actively working must not be abandoned out from under them by a goal archive.
520
+ async function archiveGoalWithDispositions({ goalId, plan = [], reason }, deps = {}) {
521
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
522
+ const client = await activePool.connect();
523
+ try {
524
+ await client.query('BEGIN');
525
+
526
+ const { rows: goalRows } = await client.query(
527
+ `SELECT ${GOAL_COLS} FROM goals WHERE id = $1 FOR UPDATE`, [goalId]
528
+ );
529
+ if (!goalRows[0]) throw Object.assign(new Error('goal not found'), { code: 'GOAL_NOT_FOUND' });
530
+ if (goalRows[0].status === 'archived') {
531
+ await client.query('ROLLBACK');
532
+ return { goal: goalRows[0], applied: [], alreadyArchived: true };
533
+ }
534
+
535
+ const applied = [];
536
+ for (const step of plan) {
537
+ const { rows: tRows } = await client.query(
538
+ `SELECT id, title, status FROM tasks WHERE id = $1 AND goal_id = $2 FOR UPDATE`,
539
+ [step.taskId, goalId]
540
+ );
541
+ // The task left this goal (or shipped) while the caller was deciding. Not an
542
+ // error: the disposition is moot, and the final re-count is what actually
543
+ // guards the invariant. Recorded as 'skipped' so the response tells the
544
+ // truth about what this call did rather than claiming a write it never made.
545
+ if (!tRows[0]) { applied.push({ task_id: String(step.taskId), verb: step.verb, result: 'skipped_not_in_goal' }); continue; }
546
+ if (isTerminalStatus(tRows[0].status)) {
547
+ applied.push({ task_id: String(step.taskId), verb: step.verb, result: 'skipped_already_terminal', status: tRows[0].status });
548
+ continue;
549
+ }
550
+
551
+ const { rows: claimed } = await client.query(
552
+ `SELECT id FROM claims WHERE task_id = $1 AND released_at IS NULL LIMIT 1`, [step.taskId]
553
+ );
554
+ if (claimed[0]) {
555
+ throw Object.assign(new Error('task has an active claim'), {
556
+ code: 'TASK_HAS_ACTIVE_CLAIM', taskId: String(step.taskId), taskTitle: tRows[0].title,
557
+ });
558
+ }
559
+
560
+ if (step.verb === 'abandon') {
561
+ await client.query(
562
+ `UPDATE tasks SET status = 'abandoned',
563
+ value_summary = COALESCE(NULLIF($2, ''), value_summary),
564
+ updated_at = now()
565
+ WHERE id = $1`,
566
+ [step.taskId, `Abandoned: ${reason}`]
567
+ );
568
+ applied.push({ task_id: String(step.taskId), verb: 'abandon', result: 'abandoned' });
569
+ } else {
570
+ const { rows: dest } = await client.query(
571
+ `SELECT id, status FROM goals WHERE id = $1 FOR SHARE`, [step.toGoalId]
572
+ );
573
+ if (!dest[0]) {
574
+ throw Object.assign(new Error('move target not found'), { code: 'MOVE_TARGET_NOT_FOUND', toGoalId: String(step.toGoalId) });
575
+ }
576
+ if (dest[0].status !== 'open') {
577
+ throw Object.assign(new Error('move target is not open'), {
578
+ code: 'MOVE_TARGET_NOT_OPEN', toGoalId: String(step.toGoalId), targetStatus: dest[0].status,
579
+ });
580
+ }
581
+ await client.query(`UPDATE tasks SET goal_id = $2, updated_at = now() WHERE id = $1`, [step.taskId, step.toGoalId]);
582
+ applied.push({ task_id: String(step.taskId), verb: 'move', result: 'moved', to_goal_id: String(step.toGoalId) });
583
+ }
584
+ }
585
+
586
+ // The invariant, asserted against committed-in-this-txn state rather than
587
+ // against the plan. If anything is still non-terminal here, the archive would
588
+ // recreate exactly the condition goal 1000086 exists to make impossible.
589
+ const { rows: left } = await client.query(
590
+ `SELECT count(*)::int AS n FROM tasks t
591
+ WHERE t.goal_id = $1 AND ${nonTerminalSql()} AND ${SMOKE_NOT_LIKE.replace(/title/, 't.title')}`,
592
+ [goalId]
593
+ );
594
+ if ((left[0]?.n ?? 0) > 0) {
595
+ throw Object.assign(new Error('goal still holds unfinished tasks after dispositions'), {
596
+ code: 'DISPOSITION_INCOMPLETE_AFTER_APPLY', remaining: left[0].n,
597
+ });
598
+ }
599
+
600
+ const { rows: archived } = await client.query(
601
+ `UPDATE goals SET status = 'archived', updated_at = now() WHERE id = $1 RETURNING ${GOAL_COLS}`,
602
+ [goalId]
603
+ );
604
+ await client.query('COMMIT');
605
+ return { goal: archived[0], applied, alreadyArchived: false };
606
+ } catch (err) {
607
+ try { await client.query('ROLLBACK'); } catch { /* the original error is the one that matters */ }
608
+ throw err;
609
+ } finally {
610
+ client.release();
611
+ }
612
+ }
613
+
490
614
  async function listCriteriaForGoal(goalId, deps = {}) {
491
615
  const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
492
616
  // task 1002164: carry the linked-task counts so the caller can derive the
@@ -937,6 +1061,7 @@ module.exports = {
937
1061
  acceptMembershipRequestAndAddMember,
938
1062
  ensureMaintenanceGoal,
939
1063
  achieveGoalIfComplete,
1064
+ archiveGoalWithDispositions,
940
1065
  addGoalMember,
941
1066
  addGoalScopeModules,
942
1067
  createGoal,
@@ -81,6 +81,7 @@ const {
81
81
  isGoalMember,
82
82
  listActiveClaimsForTasks,
83
83
  listCriteriaForGoal,
84
+ archiveGoalWithDispositions,
84
85
  openTasksInGoal,
85
86
  listGoalMembers,
86
87
  listGoals,
@@ -252,6 +253,7 @@ module.exports = {
252
253
  listInboxFor,
253
254
  listPendingRequestsForGoal,
254
255
  listCriteriaForGoal,
256
+ archiveGoalWithDispositions,
255
257
  openTasksInGoal,
256
258
  createTask,
257
259
  generalGoalIdForVersion,
@@ -519,7 +519,140 @@ function decorateGoalScope(goal) {
519
519
  }
520
520
 
521
521
 
522
+ // planGoalArchiveDispositions — the PURE decision behind the second half of the
523
+ // archive two-step (BV1.R14, task 1003601, goal 1000086, ADR 0250 D3).
524
+ //
525
+ // R10 (task 1003597) made `POST /goals/:id/archive` REFUSE a goal that still
526
+ // holds unfinished tasks and hand the caller the list. This is the other half:
527
+ // the caller comes back saying what happens to each, and this decides whether
528
+ // what they said is a complete, well-formed answer.
529
+ //
530
+ // THE CONSTRAINT IS "NOTHING IS DISCARDED WITHOUT A DECISION", so the map must be
531
+ // TOTAL. A partial map is refused rather than applied-as-far-as-it-goes, because
532
+ // a half-applied disposition leaves the goal un-archivable AND some of its work
533
+ // already abandoned — the caller then cannot tell, from the outside, which half
534
+ // happened. Total-or-nothing is what makes the call safe to retry.
535
+ //
536
+ // PURE + exported for the reason its siblings are: a decision expressed as a pure
537
+ // function has its whole truth table exercised by a DB-free unit test, including
538
+ // the shapes a live-Postgres test would never bother to set up (a verb of the
539
+ // wrong case, a move naming the goal being archived, a map with an extra id for a
540
+ // task that already shipped while the caller was deciding).
541
+ //
542
+ // Inputs: `openTasks` is R10's own `openTasksInGoal(...).tasks` — [{ id, title,
543
+ // status }] — and `dispositions` is the caller's map, keyed by task id. `goalId`
544
+ // is the goal being archived, `reason` the archive's reason.
545
+ //
546
+ // Returns { ok: true, plan: [{ taskId, verb, toGoalId }] } — the plan ORDERED to
547
+ // match `openTasks`, so the writer's audit trail reads in a stable order — or
548
+ // { ok: false, status, body } (the exact HTTP status + JSON the route sends).
549
+ //
550
+ // It deliberately does NOT check that a `move` target exists or is open: that is
551
+ // a database fact, and this function has no database. The route resolves the
552
+ // named goals and applies `goalNotOpenRefusal` to each, which is the ONE
553
+ // definition of "this goal takes no new work" (BV1.R05) and must not be
554
+ // re-implemented here in a second, drifting form.
555
+ const ARCHIVE_DISPOSITION_VERBS = new Set(['abandon', 'move']);
556
+
557
+ function planGoalArchiveDispositions({ goalId, openTasks = [], dispositions = null, reason = '' }) {
558
+ const held = Array.isArray(openTasks) ? openTasks.filter(Boolean) : [];
559
+ if (held.length === 0) return { ok: true, plan: [] };
560
+
561
+ // A goal holding open work may not be archived on a bare call. This is R10's
562
+ // refusal, reproduced here so the two steps cannot drift: same code, same
563
+ // shape, and it RETURNS the work rather than naming a count.
564
+ if (!dispositions || typeof dispositions !== 'object' || Array.isArray(dispositions)) {
565
+ return {
566
+ ok: false,
567
+ status: 409,
568
+ body: {
569
+ error: 'goal_holds_open_tasks',
570
+ message: `Goal #${goalId} still holds ${held.length} unfinished task(s). Re-submit with a \`dispositions\` map saying what happens to each — {"<task id>":{"verb":"abandon"}} or {"<task id>":{"verb":"move","goal_id":<id>}}.`,
571
+ details: { total: held.length, shown: held.length, tasks: held },
572
+ },
573
+ };
574
+ }
575
+
576
+ // An abandon stamps its reason onto the task, and an abandon with no reason is
577
+ // exactly the silent discard D3 exists to prevent — so the reason is required
578
+ // as soon as anything is being dispositioned, not merely encouraged.
579
+ if (typeof reason !== 'string' || reason.trim().length === 0) {
580
+ return {
581
+ ok: false,
582
+ status: 400,
583
+ body: {
584
+ error: 'archive_reason_required',
585
+ message: 'Archiving a goal that holds open work records why. Pass a non-empty `reason`; it is stamped onto every task abandoned by this call.',
586
+ },
587
+ };
588
+ }
589
+
590
+ const plan = [];
591
+ const missing = [];
592
+ for (const t of held) {
593
+ const key = String(t.id);
594
+ const d = dispositions[key];
595
+ if (!d || typeof d !== 'object') { missing.push(t); continue; }
596
+ const verb = typeof d.verb === 'string' ? d.verb : null;
597
+ if (!ARCHIVE_DISPOSITION_VERBS.has(verb)) {
598
+ return {
599
+ ok: false,
600
+ status: 400,
601
+ body: {
602
+ error: 'bad_disposition',
603
+ message: `Task #${key}: verb must be "abandon" or "move".`,
604
+ details: { task_id: key, verb: d.verb ?? null, valid: Array.from(ARCHIVE_DISPOSITION_VERBS) },
605
+ },
606
+ };
607
+ }
608
+ if (verb === 'abandon') { plan.push({ taskId: Number(t.id), verb, toGoalId: null }); continue; }
609
+
610
+ const toGoalId = Number(d.goal_id);
611
+ if (!Number.isInteger(toGoalId) || toGoalId <= 0) {
612
+ return {
613
+ ok: false,
614
+ status: 400,
615
+ body: {
616
+ error: 'bad_disposition',
617
+ message: `Task #${key}: a "move" names the surviving goal it moves to — pass \`goal_id\`.`,
618
+ details: { task_id: key, goal_id: d.goal_id ?? null },
619
+ },
620
+ };
621
+ }
622
+ // Moving a task INTO the goal being archived is the disposition that looks
623
+ // like a decision and is not: the task would be archived-with-the-goal a
624
+ // moment later, which is the silent discard by another name.
625
+ if (toGoalId === Number(goalId)) {
626
+ return {
627
+ ok: false,
628
+ status: 400,
629
+ body: {
630
+ error: 'bad_disposition',
631
+ message: `Task #${key}: cannot move a task into the goal being archived. Abandon it, or name a surviving goal.`,
632
+ details: { task_id: key, goal_id: toGoalId },
633
+ },
634
+ };
635
+ }
636
+ plan.push({ taskId: Number(t.id), verb, toGoalId });
637
+ }
638
+
639
+ if (missing.length > 0) {
640
+ return {
641
+ ok: false,
642
+ status: 400,
643
+ body: {
644
+ error: 'disposition_incomplete',
645
+ message: `${missing.length} of ${held.length} unfinished task(s) have no disposition. Every one needs a decision — nothing is discarded by omission.`,
646
+ details: { total: held.length, undecided: missing.length, tasks: missing },
647
+ },
648
+ };
649
+ }
650
+ return { ok: true, plan };
651
+ }
652
+
653
+
522
654
  module.exports = {
655
+ ARCHIVE_DISPOSITION_VERBS,
523
656
  authorizeCreateJoinRequest,
524
657
  authorizeGoalCreate,
525
658
  authorizeGoalTaskCreate,
@@ -533,5 +666,6 @@ module.exports = {
533
666
  decorateGoalScope,
534
667
  goalNotOpenRefusal,
535
668
  isMemberOrArchon,
669
+ planGoalArchiveDispositions,
536
670
  protectedScopeAdmissionBlock,
537
671
  };