@bongos/core 1.19.589 → 1.19.590

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.590",
6
+ "core_contract": "1.19.590",
7
+ "source_commit": "250f70f068d2406d49d2b57306a8f3f1ebf0f1ab",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-08T02:16:53.692Z",
9
+ "built_at": "2026-09-08T02:32:23.112Z",
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": 2061,
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": 2542,
20
+ "tree_sha256": "ff91f93bb16afd98f8d2130615c602425ac4d133c4b01c83fbe94e6d8015f2cc",
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": "c2736a86c941e12d08b38f798ab18eddf8a365ef709ed952778629d13ebf4e1d"
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": "035ec486f26d0a18ffcc578a861b2758f79ea279371c4f88dfe5fdfa1b04fefe"
7606
7606
  },
7607
7607
  {
7608
7608
  "path": "package.json",
7609
7609
  "mode": "0000644",
7610
- "sha256": "fc682e53b7902f41fb4a455c9f61d70a5b154159df150b6e7e6c696c596df4e4"
7610
+ "sha256": "05c57c598f80ce768e83bc0040feb275732545150cbcb3cd01341ff3633ea96f"
7611
7611
  },
7612
7612
  {
7613
7613
  "path": "public-docs/index.html",
@@ -9317,7 +9317,7 @@
9317
9317
  {
9318
9318
  "path": "src/module-api.js",
9319
9319
  "mode": "0000644",
9320
- "sha256": "9264831ff874468719e9a6f73a3dfa39507f6ce033f25cdfc9d08e840d1a58fe"
9320
+ "sha256": "b2b2d8c9ccf065e7bbc7b984ff9acda5f4400c9db4e9063678e6d996616f7d1c"
9321
9321
  },
9322
9322
  {
9323
9323
  "path": "src/module-loader/catalog.js",
@@ -10437,7 +10437,12 @@
10437
10437
  {
10438
10438
  "path": "tests/goal_archive_disposition.mjs",
10439
10439
  "mode": "0000644",
10440
- "sha256": "d2056a3ae84b79d64409e6e43c477f6b160f202903be61710b40bc4fd06c00c9"
10440
+ "sha256": "bf0baad459ee2e103079a53570ca4998bb3ba66451ad0257478dd236118c8ce6"
10441
+ },
10442
+ {
10443
+ "path": "tests/goal_archive_route_e2e.mjs",
10444
+ "mode": "0000644",
10445
+ "sha256": "ffefb97db455e2b6656f5c26933312572102e3a8aa37605545ae9b425d616be0"
10441
10446
  },
10442
10447
  {
10443
10448
  "path": "tests/goal_category.mjs",
@@ -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,7 @@ 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)
1630
1632
  ---------------------------------------------------------------------------
1631
1633
  ```
@@ -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
  };
@@ -49,6 +49,14 @@ const goalConflicts = require('../goal-conflicts');
49
49
  const { isGoalOwner, authorizeGoalManagement } = require('../goal-authz');
50
50
 
51
51
  const VALID_GOAL_STATUSES = ['open', 'achieved', 'archived'];
52
+
53
+ // ARCHIVE_DISPOSITION_MAX — how many unfinished tasks one archive call will
54
+ // disposition (BV1.R14). A ceiling rather than an unbounded read because the
55
+ // whole plan is validated in memory and applied in ONE transaction, and a goal
56
+ // holding thousands of open tasks is a data problem to fix before archiving, not
57
+ // a payload to stream. Sized well above anything real: the largest bucket on the
58
+ // live instance is a per-version catch-all at ~53 (ADR 0250 §7).
59
+ const ARCHIVE_DISPOSITION_MAX = 500;
52
60
  const VALID_MEMBERSHIP_KINDS = ['lead', 'member'];
53
61
  const METIC_PLUS = ['metic', 'archon'];
54
62
  const RESOLVE_ACTIONS = ['sequence', 'flag_release', 'rescope'];
@@ -63,7 +71,7 @@ const JOIN_REQUEST_RESPONSE_ACTIONS = ['approve', 'deny']; // an owner/manager/A
63
71
  // proceed; a false result means "not a member" → the route should 403. This is the
64
72
  // membership half of the wall; the protected-module rank check is enforced per-route
65
73
  // (scope is never self-granted).
66
- const { authorizeCreateJoinRequest, authorizeGoalCreate, authorizeGoalTaskCreate, authorizeInvitationAccept, authorizeInvitationResponse, authorizeJoinRequestResponse, authorizeMembershipKindChange, authorizeOwnershipTransfer, authorizeRescindInvitation, authorizeWithdrawJoinRequest, decorateGoalScope, isMemberOrArchon, protectedScopeAdmissionBlock } = require('./goal-route-authz.js');
74
+ const { authorizeCreateJoinRequest, authorizeGoalCreate, authorizeGoalTaskCreate, authorizeInvitationAccept, authorizeInvitationResponse, authorizeJoinRequestResponse, authorizeMembershipKindChange, authorizeOwnershipTransfer, authorizeRescindInvitation, authorizeWithdrawJoinRequest, decorateGoalScope, goalNotOpenRefusal, isMemberOrArchon, planGoalArchiveDispositions, protectedScopeAdmissionBlock } = require('./goal-route-authz.js');
67
75
  // task 1003208: structured logging (pino via the doorway) — was console.*.
68
76
  const log = api.logger('lifecycle');
69
77
  module.exports = function buildGoalsRouter() {
@@ -1075,6 +1083,17 @@ module.exports = function buildGoalsRouter() {
1075
1083
  // skill reaches for once a goal is achieved (ADR 0086 §6 / BV1.R64).
1076
1084
  // rank: metic+archon — a goal member (Metic+) or an Archon dispositions the goal.
1077
1085
  router.post('/goals/:id/archive', auth.requireBuilder, auth.requirePermission('goal.archive'), async (req, res) => {
1086
+ // ADR 0118 / R12: every write route that reads a body declares it, so an
1087
+ // unknown field is REJECTED rather than silently ignored — a typo'd
1088
+ // `disposition` (singular) must not read as "no disposition given" and take
1089
+ // the refusal branch, which is exactly the shape that trains a caller to
1090
+ // believe the map was applied. `dispositions` is a dynamic-key map (task id →
1091
+ // verb) so the schema bounds its TYPE here and its CONTENTS are validated by
1092
+ // planGoalArchiveDispositions, which refuses every malformed entry by name.
1093
+ if (validateOrRespond(req, res, {
1094
+ reason: { type: 'string', maxLength: 2000 },
1095
+ dispositions: { type: 'object' },
1096
+ })) return;
1078
1097
  const id = parseId(req, res, { code: 'bad_goal_id' });
1079
1098
  if (id === null) return;
1080
1099
  try {
@@ -1100,17 +1119,96 @@ module.exports = function buildGoalsRouter() {
1100
1119
  // "not without saying what happens to these", not a silent discard.
1101
1120
  //
1102
1121
  // A goal holding nothing open stays a single call, unchanged.
1103
- const held = await db.openTasksInGoal(id);
1104
- if (held.total > 0) {
1105
- return res.fail('goal_holds_open_tasks', {
1122
+ //
1123
+ // BV1.R14 (task 1003601) is the second half: the caller comes back with a
1124
+ // `dispositions` map saying what happens to each open task, and the archive
1125
+ // APPLIES it. R10 shipped the refusal one task ahead of its own remedy, so
1126
+ // between the two every archive of a goal holding work was impossible —
1127
+ // which is also why the ADR 0264 goal cut could not start until this landed.
1128
+ //
1129
+ // The decision is the pure planGoalArchiveDispositions (the sibling of
1130
+ // authorizeGoalCreate / authorizeVersionCreate): the route gathers the
1131
+ // facts, that decides, the route renders the verdict verbatim.
1132
+ //
1133
+ // THE CAP MATTERS HERE AND NOT IN R10. `openTasksInGoal` defaults to 50 rows
1134
+ // (with the true count in `.total`), which is right for a refusal that only
1135
+ // has to show the caller what is in the way. This call must plan over EVERY
1136
+ // open task — a plan built from a truncated list would validate as complete,
1137
+ // and the transaction's final re-count would then roll the whole archive back
1138
+ // with a confusing "still holds N" on a caller who dispositioned everything
1139
+ // they were shown. So: read wide when a disposition is being applied, and
1140
+ // refuse above the ceiling with a code that says what to do.
1141
+ const wantsDisposition = req.body?.dispositions != null;
1142
+ const held = await db.openTasksInGoal(id, wantsDisposition ? { cap: ARCHIVE_DISPOSITION_MAX } : undefined);
1143
+ if (wantsDisposition && held.total > ARCHIVE_DISPOSITION_MAX) {
1144
+ return res.fail('too_many_open_tasks', {
1106
1145
  status: 409,
1107
- message: `Goal #${id} still holds ${held.total} unfinished task(s). Ship or abandon them, or move them to another goal, before archiving it.`,
1108
- details: { total: held.total, shown: held.tasks.length, tasks: held.tasks },
1146
+ message: `Goal #${id} holds ${held.total} unfinished tasks more than the ${ARCHIVE_DISPOSITION_MAX} this call dispositions at once. Move or abandon some first, then archive.`,
1147
+ details: { total: held.total, max: ARCHIVE_DISPOSITION_MAX },
1109
1148
  });
1110
1149
  }
1111
- const updated = await db.setGoalStatus({ goalId: id, status: 'archived' });
1112
- return res.json({ ok: true, goal: updated });
1150
+ const reason = typeof req.body?.reason === 'string' ? req.body.reason.slice(0, 2000) : '';
1151
+ const plan = planGoalArchiveDispositions({
1152
+ goalId: id, openTasks: held.tasks, dispositions: req.body?.dispositions ?? null, reason,
1153
+ });
1154
+ if (!plan.ok) return res.status(plan.status).json(plan.body);
1155
+
1156
+ // A goal holding nothing open never had a plan to apply — keep it the one
1157
+ // untouched call it has always been, rather than routing it through a
1158
+ // transaction that would do nothing.
1159
+ if (plan.plan.length === 0) {
1160
+ const updated = await db.setGoalStatus({ goalId: id, status: 'archived' });
1161
+ return res.json({ ok: true, goal: updated, applied: [] });
1162
+ }
1163
+
1164
+ // Move targets are resolved HERE, before the write, so a bad target is a
1165
+ // 400 about the caller's request rather than a rollback mid-archive. The
1166
+ // open test is goalNotOpenRefusal — the ONE definition of "this goal takes
1167
+ // no new work" (BV1.R05) — never a second spelling of the status list.
1168
+ const targets = [...new Set(plan.plan.filter((p) => p.verb === 'move').map((p) => p.toGoalId))];
1169
+ for (const t of targets) {
1170
+ const dest = await db.getGoal(t);
1171
+ if (!dest) {
1172
+ return res.fail('bad_disposition', { status: 400, message: `Move target goal #${t} does not exist.`, details: { goal_id: String(t) } });
1173
+ }
1174
+ const notOpen = goalNotOpenRefusal(dest);
1175
+ if (notOpen) {
1176
+ return res.fail('bad_disposition', {
1177
+ status: 409,
1178
+ message: `Move target goal #${t} is '${dest.status}' — a task cannot be moved into a closed goal.`,
1179
+ details: { goal_id: String(t), goal_status: dest.status },
1180
+ });
1181
+ }
1182
+ }
1183
+
1184
+ const result = await db.archiveGoalWithDispositions({ goalId: id, plan: plan.plan, reason });
1185
+ return res.json({ ok: true, goal: result.goal, applied: result.applied });
1113
1186
  } catch (err) {
1187
+ // An actively-claimed task must not be abandoned out from under whoever is
1188
+ // working it — the same refusal POST /tasks/:id/abandon makes, surfaced with
1189
+ // the task named so the caller knows which claim to chase.
1190
+ if (err && err.code === 'TASK_HAS_ACTIVE_CLAIM') {
1191
+ return res.fail('task_has_active_claim', {
1192
+ status: 409,
1193
+ message: `Task #${err.taskId} has an active claim — release it before archiving this goal.`,
1194
+ details: { task_id: err.taskId, task_title: err.taskTitle },
1195
+ });
1196
+ }
1197
+ if (err && (err.code === 'MOVE_TARGET_NOT_FOUND' || err.code === 'MOVE_TARGET_NOT_OPEN')) {
1198
+ return res.fail('bad_disposition', {
1199
+ status: 409,
1200
+ message: `Move target goal #${err.toGoalId} changed while the archive was running — nothing was applied. Re-read and retry.`,
1201
+ details: { goal_id: err.toGoalId, goal_status: err.targetStatus ?? null },
1202
+ });
1203
+ }
1204
+ if (err && err.code === 'DISPOSITION_INCOMPLETE_AFTER_APPLY') {
1205
+ return res.fail('goal_holds_open_tasks', {
1206
+ status: 409,
1207
+ message: `Goal #${id} still held ${err.remaining} unfinished task(s) after the dispositions were applied — nothing was changed. Re-read the goal and retry.`,
1208
+ details: { remaining: err.remaining },
1209
+ });
1210
+ }
1211
+ if (err && err.code === 'GOAL_NOT_FOUND') return res.fail('goal_not_found', 404);
1114
1212
  log.error('[gds] POST /goals/:id/archive', err);
1115
1213
  return res.fail('archive_failed', { status: 500, message: 'internal error' });
1116
1214
  }
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.589",
3
+ "version": "1.19.590",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.589",
9
+ "version": "1.19.590",
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.589",
3
+ "version": "1.19.590",
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
@@ -55,7 +55,7 @@ const { buildInfo } = require('./build-info');
55
55
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
56
56
  // the entry to that file. Look for a version's history there, not here.
57
57
  // ---------------------------------------------------------------------------
58
- const CORE_VERSION = '1.19.589'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
58
+ const CORE_VERSION = '1.19.590'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
59
59
 
60
60
  // A namespaced logger so a module's log lines are attributable + consistent.
61
61
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -24,6 +24,8 @@ process.env.NODE_ENV = 'test';
24
24
  const require = createRequire(import.meta.url);
25
25
  const { openTasksInGoal } = require('../modules/lifecycle/db.js');
26
26
  const { TERMINAL_STATUSES } = require('../modules/lifecycle/task-status.js');
27
+ const { planGoalArchiveDispositions } = require('../modules/lifecycle/routes/goal-route-authz.js');
28
+ const { archiveGoalWithDispositions } = require('../modules/lifecycle/db.js');
27
29
 
28
30
  const src = (rel) => readFileSync(new URL('../' + rel, import.meta.url), 'utf8');
29
31
 
@@ -86,15 +88,24 @@ test('an empty goal reports nothing held — the single-call path stays open', a
86
88
  // ---- the route --------------------------------------------------------------
87
89
 
88
90
  test('archive refuses with a NAMED 409 when the goal holds open work', () => {
89
- assert.match(ARCHIVE, /const held = await db\.openTasksInGoal\(id\);/,
91
+ // R14 (task 1003601) moved this refusal OUT of the route and into the pure
92
+ // planner, so it is asserted on behaviour now rather than on route source —
93
+ // a strictly better pin, and the reason these two tests changed shape.
94
+ assert.match(ARCHIVE, /const held = await db\.openTasksInGoal\(id/,
90
95
  'the archive route must consult the work before discarding it');
91
- assert.match(ARCHIVE, /res\.fail\('goal_holds_open_tasks', \{[\s\S]*?status: 409/,
92
- 'a named code an agent can branch on, not a bare 409');
96
+ const r = planGoalArchiveDispositions({
97
+ goalId: 7, openTasks: [{ id: '1', title: 't', status: 'ready' }], dispositions: null, reason: 'x',
98
+ });
99
+ assert.equal(r.ok, false);
100
+ assert.equal(r.status, 409);
101
+ assert.equal(r.body.error, 'goal_holds_open_tasks', 'a named code an agent can branch on, not a bare 409');
93
102
  });
94
103
 
95
104
  test('the refusal RETURNS the tasks, so the caller can decide without a second fetch', () => {
96
- assert.match(ARCHIVE, /details: \{ total: held\.total, shown: held\.tasks\.length, tasks: held\.tasks \}/,
97
- 'an agent that has to go fetch the list will guess instead');
105
+ const held = [{ id: '1', title: 'one', status: 'ready' }, { id: '2', title: 'two', status: 'active' }];
106
+ const r = planGoalArchiveDispositions({ goalId: 7, openTasks: held, dispositions: null, reason: 'x' });
107
+ assert.deepEqual(r.body.details.tasks, held, 'an agent that has to go fetch the list will guess instead');
108
+ assert.equal(r.body.details.total, 2);
98
109
  });
99
110
 
100
111
  test('the check runs AFTER identity/authorization and BEFORE the status write', () => {
@@ -111,9 +122,14 @@ test('the check runs AFTER identity/authorization and BEFORE the status write',
111
122
  test('archiving a goal that holds nothing is still ONE call', () => {
112
123
  // The guard must not turn the ordinary case into a two-step. There is exactly
113
124
  // one setGoalStatus in this handler and no second round trip in front of it.
114
- assert.equal((ARCHIVE.match(/db\.setGoalStatus/g) || []).length, 1);
125
+ assert.equal((ARCHIVE.match(/db\.setGoalStatus/g) || []).length, 1,
126
+ 'the empty-goal path still writes through the plain setter, not the transaction');
115
127
  assert.equal(/disposition_required|preview=/.test(ARCHIVE), false,
116
128
  'no extra confirm parameter on the empty-goal path');
129
+ // And the planner agrees: nothing held means an empty plan, never a refusal.
130
+ const r = planGoalArchiveDispositions({ goalId: 7, openTasks: [], dispositions: null, reason: '' });
131
+ assert.equal(r.ok, true);
132
+ assert.deepEqual(r.plan, []);
117
133
  });
118
134
 
119
135
  // ---- the two doors agree ----------------------------------------------------
@@ -142,3 +158,188 @@ test('every non-terminal status is one the archive gate would catch', () => {
142
158
  assert.deepEqual([...TERMINAL_STATUSES].sort(), ['abandoned', 'shipped'],
143
159
  'and the terminal set is exactly the two deliberate endings');
144
160
  });
161
+
162
+ // ---- R14: the disposition is APPLIED (task 1003601) --------------------------
163
+ //
164
+ // The second half of the two-step. R10 built the refusal; this is the answer to
165
+ // it. The property under test throughout is TOTALITY: a map that does not cover
166
+ // every open task is refused outright rather than applied as far as it goes,
167
+ // because a half-applied disposition leaves the goal un-archivable AND some of
168
+ // its work already abandoned, with no way for the caller to tell which happened.
169
+
170
+ const TASKS = (...st) => st.map((s, i) => ({ id: String(i + 1), title: `t${i + 1}`, status: s }));
171
+ const plan = (over = {}) => planGoalArchiveDispositions({
172
+ goalId: 7, openTasks: TASKS('ready', 'active'), reason: 'cut in the ten-areas review', ...over,
173
+ });
174
+
175
+ test('a partial map is refused — nothing is discarded by omission', () => {
176
+ const r = plan({ dispositions: { 1: { verb: 'abandon' } } }); // task 2 undecided
177
+ assert.equal(r.ok, false);
178
+ assert.equal(r.status, 400);
179
+ assert.equal(r.body.error, 'disposition_incomplete');
180
+ assert.equal(r.body.details.undecided, 1);
181
+ assert.deepEqual(r.body.details.tasks.map((t) => t.id), ['2'],
182
+ 'the refusal names WHICH task is undecided, not merely how many');
183
+ });
184
+
185
+ test('a complete map plans one step per task, in the order the tasks came in', () => {
186
+ const r = plan({ dispositions: { 1: { verb: 'abandon' }, 2: { verb: 'move', goal_id: 42 } } });
187
+ assert.equal(r.ok, true);
188
+ assert.deepEqual(r.plan, [
189
+ { taskId: 1, verb: 'abandon', toGoalId: null },
190
+ { taskId: 2, verb: 'move', toGoalId: 42 },
191
+ ]);
192
+ });
193
+
194
+ test('an abandon with no reason is refused — that is the silent discard D3 forbids', () => {
195
+ for (const reason of ['', ' ', null, undefined]) {
196
+ const r = plan({ dispositions: { 1: { verb: 'abandon' }, 2: { verb: 'abandon' } }, reason });
197
+ assert.equal(r.ok, false, `reason ${JSON.stringify(reason)} must not pass`);
198
+ assert.equal(r.body.error, 'archive_reason_required');
199
+ }
200
+ });
201
+
202
+ test('only abandon and move are verbs; anything else is a named 400', () => {
203
+ for (const verb of ['delete', 'ABANDON', 'skip', '', null, 42]) {
204
+ const r = plan({ dispositions: { 1: { verb }, 2: { verb: 'abandon' } } });
205
+ assert.equal(r.ok, false, `verb ${JSON.stringify(verb)} must not pass`);
206
+ assert.equal(r.body.error, 'bad_disposition');
207
+ }
208
+ });
209
+
210
+ test('a move must name where it moves to', () => {
211
+ for (const goal_id of [undefined, null, 0, -1, 'forty-two', 1.5]) {
212
+ const r = plan({ dispositions: { 1: { verb: 'move', goal_id }, 2: { verb: 'abandon' } } });
213
+ assert.equal(r.ok, false, `goal_id ${JSON.stringify(goal_id)} must not pass`);
214
+ assert.equal(r.body.error, 'bad_disposition');
215
+ }
216
+ });
217
+
218
+ test('a task cannot be moved INTO the goal being archived', () => {
219
+ // The disposition that looks like a decision and is not: the task would be
220
+ // archived along with the goal a moment later — the silent discard renamed.
221
+ const r = plan({ dispositions: { 1: { verb: 'move', goal_id: 7 }, 2: { verb: 'abandon' } } });
222
+ assert.equal(r.ok, false);
223
+ assert.equal(r.body.error, 'bad_disposition');
224
+ assert.match(r.body.message, /into the goal being archived/);
225
+ });
226
+
227
+ test('a disposition for a task that is not open is simply unused, never an error', () => {
228
+ // The caller may hold a stale list; extra keys are harmless because the plan is
229
+ // built from the OPEN TASKS, not from the map.
230
+ const r = plan({ dispositions: { 1: { verb: 'abandon' }, 2: { verb: 'abandon' }, 999: { verb: 'move' } } });
231
+ assert.equal(r.ok, true);
232
+ assert.deepEqual(r.plan.map((p) => p.taskId), [1, 2]);
233
+ });
234
+
235
+ // ---- R14: the write is one transaction --------------------------------------
236
+
237
+ function txPool(handler) {
238
+ const c = makeSqlAwareClient(handler);
239
+ return { connect: c.connect, query: (sql, params) => c._client.query(sql, params), queries: c._client.queries };
240
+ }
241
+ const flat = (q) => String(q.sql).replace(/\s+/g, ' ').trim();
242
+
243
+ test('the whole disposition + archive runs inside ONE transaction', async () => {
244
+ const pool = txPool((sql) => {
245
+ const q = String(sql).replace(/\s+/g, ' ');
246
+ if (/^SELECT .* FROM goals WHERE id = \$1 FOR UPDATE/.test(q.trim())) return { rows: [{ id: 7, status: 'open' }] };
247
+ if (/FROM tasks WHERE id = \$1 AND goal_id = \$2 FOR UPDATE/.test(q)) return { rows: [{ id: 1, title: 't1', status: 'ready' }] };
248
+ if (/FROM claims WHERE task_id/.test(q)) return { rows: [] };
249
+ if (/count\(\*\)::int AS n FROM tasks/.test(q)) return { rows: [{ n: 0 }] };
250
+ if (/UPDATE goals SET status = 'archived'/.test(q)) return { rows: [{ id: 7, status: 'archived' }] };
251
+ return { rows: [] };
252
+ });
253
+ const out = await archiveGoalWithDispositions(
254
+ { goalId: 7, plan: [{ taskId: 1, verb: 'abandon', toGoalId: null }], reason: 'cut' },
255
+ { pool }
256
+ );
257
+ assert.equal(out.goal.status, 'archived');
258
+ const sqls = pool.queries.map(flat);
259
+ assert.equal(sqls.filter((q) => q === 'BEGIN').length, 1, 'exactly one BEGIN');
260
+ assert.equal(sqls.filter((q) => q === 'COMMIT').length, 1, 'exactly one COMMIT');
261
+ assert.ok(sqls.indexOf('BEGIN') < sqls.findIndex((q) => /UPDATE tasks SET status = 'abandoned'/.test(q)),
262
+ 'the task writes are inside the transaction');
263
+ assert.ok(sqls.findIndex((q) => /UPDATE goals SET status = 'archived'/.test(q)) < sqls.indexOf('COMMIT'),
264
+ 'so is the archive');
265
+ });
266
+
267
+ test('the goal row is locked BEFORE the plan is applied', async () => {
268
+ // Without FOR UPDATE on the goal, a concurrent POST /goals/:id/tasks can slip a
269
+ // new task in between the plan validating and the archive landing.
270
+ const pool = txPool((sql) => {
271
+ const q = String(sql).replace(/\s+/g, ' ');
272
+ if (/FROM goals WHERE id = \$1 FOR UPDATE/.test(q)) return { rows: [{ id: 7, status: 'open' }] };
273
+ if (/FROM tasks WHERE id = \$1 AND goal_id = \$2 FOR UPDATE/.test(q)) return { rows: [{ id: 1, title: 't', status: 'ready' }] };
274
+ if (/FROM claims/.test(q)) return { rows: [] };
275
+ if (/count\(\*\)::int AS n/.test(q)) return { rows: [{ n: 0 }] };
276
+ if (/UPDATE goals/.test(q)) return { rows: [{ id: 7, status: 'archived' }] };
277
+ return { rows: [] };
278
+ });
279
+ await archiveGoalWithDispositions({ goalId: 7, plan: [{ taskId: 1, verb: 'abandon', toGoalId: null }], reason: 'r' }, { pool });
280
+ const sqls = pool.queries.map(flat);
281
+ const lock = sqls.findIndex((q) => /FROM goals WHERE id = \$1 FOR UPDATE/.test(q));
282
+ const firstWrite = sqls.findIndex((q) => /^UPDATE tasks/.test(q));
283
+ assert.ok(lock !== -1 && firstWrite !== -1 && lock < firstWrite, 'lock the goal, then apply');
284
+ });
285
+
286
+ test('an actively-claimed task stops the archive and rolls it back', async () => {
287
+ const pool = txPool((sql) => {
288
+ const q = String(sql).replace(/\s+/g, ' ');
289
+ if (/FROM goals WHERE id = \$1 FOR UPDATE/.test(q)) return { rows: [{ id: 7, status: 'open' }] };
290
+ if (/FROM tasks WHERE id = \$1 AND goal_id = \$2 FOR UPDATE/.test(q)) return { rows: [{ id: 1, title: 'busy', status: 'active' }] };
291
+ if (/FROM claims WHERE task_id/.test(q)) return { rows: [{ id: 99 }] }; // someone is on it
292
+ return { rows: [] };
293
+ });
294
+ await assert.rejects(
295
+ archiveGoalWithDispositions({ goalId: 7, plan: [{ taskId: 1, verb: 'abandon', toGoalId: null }], reason: 'r' }, { pool }),
296
+ (e) => e.code === 'TASK_HAS_ACTIVE_CLAIM'
297
+ );
298
+ const sqls = pool.queries.map(flat);
299
+ assert.ok(sqls.includes('ROLLBACK'), 'nothing is left applied');
300
+ assert.equal(sqls.some((q) => /UPDATE goals SET status = 'archived'/.test(q)), false, 'and the goal is not archived');
301
+ });
302
+
303
+ test('the invariant is re-checked against committed state, not against the plan', async () => {
304
+ // The one check a bug in the planner cannot fool: if anything is still
305
+ // non-terminal after the writes, the archive rolls back rather than recreating
306
+ // exactly the condition goal 1000086 exists to make impossible.
307
+ const pool = txPool((sql) => {
308
+ const q = String(sql).replace(/\s+/g, ' ');
309
+ if (/FROM goals WHERE id = \$1 FOR UPDATE/.test(q)) return { rows: [{ id: 7, status: 'open' }] };
310
+ if (/FROM tasks WHERE id = \$1 AND goal_id = \$2 FOR UPDATE/.test(q)) return { rows: [{ id: 1, title: 't', status: 'ready' }] };
311
+ if (/FROM claims/.test(q)) return { rows: [] };
312
+ if (/count\(\*\)::int AS n FROM tasks/.test(q)) return { rows: [{ n: 3 }] }; // three snuck in
313
+ return { rows: [] };
314
+ });
315
+ await assert.rejects(
316
+ archiveGoalWithDispositions({ goalId: 7, plan: [{ taskId: 1, verb: 'abandon', toGoalId: null }], reason: 'r' }, { pool }),
317
+ (e) => e.code === 'DISPOSITION_INCOMPLETE_AFTER_APPLY' && e.remaining === 3
318
+ );
319
+ assert.ok(pool.queries.map(flat).includes('ROLLBACK'));
320
+ });
321
+
322
+ test('the re-check asks the SAME question the refusal asked', () => {
323
+ // Same terminal set, same smoke exclusion — or the transaction rolls back work
324
+ // the gate in front of it had already accepted.
325
+ const dbGoals = src('modules/lifecycle/db-goals.js');
326
+ const body = dbGoals.slice(dbGoals.indexOf('async function archiveGoalWithDispositions'));
327
+ assert.match(body, /\$\{nonTerminalSql\(\)\}/);
328
+ assert.match(body, /SMOKE_NOT_LIKE\.replace/);
329
+ });
330
+
331
+ test('an abandon stamps the archive reason, the way POST /tasks/:id/abandon does', () => {
332
+ const dbGoals = src('modules/lifecycle/db-goals.js');
333
+ const body = dbGoals.slice(dbGoals.indexOf('async function archiveGoalWithDispositions'));
334
+ assert.match(body, /`Abandoned: \$\{reason\}`/,
335
+ 'the same stamp abandonTask writes, so the ledger reads identically whichever door was used');
336
+ });
337
+
338
+ test('a move into a closed goal is refused, via the ONE definition of "not open"', () => {
339
+ // The route resolves move targets and applies goalNotOpenRefusal (BV1.R05)
340
+ // rather than re-typing the status list — two spellings of "open" is how the
341
+ // three task-create vectors drifted apart in the first place.
342
+ assert.match(ARCHIVE, /goalNotOpenRefusal\(dest\)/);
343
+ assert.equal(/dest\.status === 'open'|dest\.status !== 'open'/.test(ARCHIVE), false,
344
+ 'the route must not re-implement the open test');
345
+ });
@@ -0,0 +1,135 @@
1
+ // tests/goal_archive_route_e2e.mjs — the archive two-step over real HTTP
2
+ // (BV1.R14, task 1003601, goal 1000086, ADR 0250 D3).
3
+ //
4
+ // WHY A SECOND FILE. goal_archive_disposition.mjs pins the two halves in
5
+ // isolation — the pure planner's truth table, and the writer's transaction shape
6
+ // against a fake pool. Neither exercises the WIRING: that the route reads the
7
+ // body it declares, calls the planner with the right arguments, renders its
8
+ // verdict verbatim, resolves move targets BEFORE writing, and hands the writer a
9
+ // plan. Every one of those is a place a correct planner and a correct writer can
10
+ // still add up to a broken endpoint, and each was a real risk on this change
11
+ // (the route previously read no body at all).
12
+ //
13
+ // So this drives the REAL router over a real socket, with the db layer stubbed at
14
+ // the module boundary the way task_visual_route_gate.mjs does — the router under
15
+ // test is the shipped one, not a re-assembly of it.
16
+ //
17
+ // THE CASE WORTH THE FILE ON ITS OWN is #6: a typo'd `disposition` (singular).
18
+ // Without the ADR 0118 schema the handler reads `req.body.dispositions` as
19
+ // undefined and takes the REFUSAL branch — so a caller who wrote a complete map
20
+ // under a misspelled key is told the goal holds open work, which is true,
21
+ // unhelpful, and indistinguishable from having sent nothing. That is the failure
22
+ // shape that teaches an agent to retry with a different map instead of fixing the
23
+ // key.
24
+ //
25
+ // Run: node tests/goal_archive_route_e2e.mjs
26
+
27
+ import { strict as assert } from 'node:assert';
28
+ import { createRequire } from 'node:module';
29
+ const require = createRequire(import.meta.url);
30
+ process.env.NODE_ENV = 'test';
31
+
32
+ const express = require('express');
33
+ const api = require('../src/module-api.js');
34
+ const db = require('../modules/lifecycle/db.js');
35
+
36
+ api.requireBuilder = (req, _res, next) => { req.builder = { id: '3', rank: 'archon' }; next(); };
37
+ api.requirePermission = () => (req, _res, next) => next();
38
+
39
+ let GOALS = {
40
+ 7: { id: 7, status: 'open', title: 'cut me', version_id: 'BONGOS-V1' },
41
+ 42: { id: 42, status: 'open', title: 'survivor', version_id: 'BONGOS-V1' },
42
+ 99: { id: 99, status: 'archived', title: 'closed', version_id: 'BONGOS-V1' },
43
+ };
44
+ let OPEN = [{ id: '1', title: 'one', status: 'ready' }, { id: '2', title: 'two', status: 'active' }];
45
+ let applied = null;
46
+ db.getGoal = async (id) => GOALS[Number(id)] ?? null;
47
+ db.isGoalMember = async () => true;
48
+ db.openTasksInGoal = async () => ({ total: OPEN.length, tasks: OPEN });
49
+ db.setGoalStatus = async ({ goalId }) => ({ ...GOALS[goalId], status: 'archived' });
50
+ db.archiveGoalWithDispositions = async ({ goalId, plan, reason }) => {
51
+ applied = { goalId, plan, reason };
52
+ return { goal: { ...GOALS[goalId], status: 'archived' }, applied: plan.map(p => ({ task_id: String(p.taskId), verb: p.verb, result: p.verb === 'abandon' ? 'abandoned' : 'moved' })) };
53
+ };
54
+
55
+ const app = express();
56
+ app.use(express.json());
57
+ app.use((req, res, next) => {
58
+ res.fail = (code, statusOrOpts, details) => {
59
+ const o = typeof statusOrOpts === 'number' ? { status: statusOrOpts } : (statusOrOpts || {});
60
+ return res.status(o.status || 400).json({ error: code, message: o.message, details: o.details ?? details });
61
+ };
62
+ next();
63
+ });
64
+ app.use('/api/gds', require('../modules/lifecycle/routes/goals.js')());
65
+ const server = app.listen(0);
66
+ await new Promise(r => server.once('listening', r));
67
+ const base = `http://127.0.0.1:${server.address().port}/api/gds`;
68
+ const post = async (p, body) => {
69
+ const r = await fetch(base + p, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body ?? {}) });
70
+ return { status: r.status, body: await r.json().catch(() => null) };
71
+ };
72
+
73
+ let n = 0; const ok = (m) => { n++; console.log(' ✓', m); };
74
+
75
+ // 1. bare archive of a goal holding work → the R10 refusal, WITH the tasks
76
+ let r = await post('/goals/7/archive');
77
+ assert.equal(r.status, 409); assert.equal(r.body.error, 'goal_holds_open_tasks');
78
+ assert.equal(r.body.details.tasks.length, 2);
79
+ ok('bare archive refuses 409 goal_holds_open_tasks and returns both tasks');
80
+
81
+ // 2. a partial map → refused, names the undecided task
82
+ r = await post('/goals/7/archive', { reason: 'cut', dispositions: { 1: { verb: 'abandon' } } });
83
+ assert.equal(r.status, 400); assert.equal(r.body.error, 'disposition_incomplete');
84
+ assert.deepEqual(r.body.details.tasks.map(t => t.id), ['2']);
85
+ ok('partial map refuses 400 disposition_incomplete, naming task 2');
86
+
87
+ // 3. complete map with no reason → refused
88
+ r = await post('/goals/7/archive', { dispositions: { 1: { verb: 'abandon' }, 2: { verb: 'abandon' } } });
89
+ assert.equal(r.status, 400); assert.equal(r.body.error, 'archive_reason_required');
90
+ ok('an abandon with no reason refuses 400 archive_reason_required');
91
+
92
+ // 4. move into a CLOSED goal → refused before any write
93
+ applied = null;
94
+ r = await post('/goals/7/archive', { reason: 'cut', dispositions: { 1: { verb: 'move', goal_id: 99 }, 2: { verb: 'abandon' } } });
95
+ assert.equal(r.status, 409); assert.equal(r.body.error, 'bad_disposition');
96
+ assert.equal(applied, null, 'nothing was written');
97
+ ok('move into an archived goal refuses 409 bad_disposition, writing nothing');
98
+
99
+ // 5. move into the goal being archived → refused
100
+ r = await post('/goals/7/archive', { reason: 'cut', dispositions: { 1: { verb: 'move', goal_id: 7 }, 2: { verb: 'abandon' } } });
101
+ assert.equal(r.status, 400); assert.equal(r.body.error, 'bad_disposition');
102
+ ok('move into the goal being archived refuses 400 bad_disposition');
103
+
104
+ // 6. unknown body field → rejected by the ADR 0118 schema
105
+ r = await post('/goals/7/archive', { reason: 'cut', disposition: { 1: { verb: 'abandon' } } });
106
+ assert.equal(r.status, 400);
107
+ assert.equal(r.body.error?.code ?? r.body.error, 'validation_failed');
108
+ ok('a typo\'d `disposition` (singular) is rejected as validation_failed, not read as "none given"');
109
+
110
+ // 7. THE HAPPY PATH — complete map, one abandon + one move
111
+ applied = null;
112
+ r = await post('/goals/7/archive', { reason: 'cut in the ten-areas review', dispositions: { 1: { verb: 'abandon' }, 2: { verb: 'move', goal_id: 42 } } });
113
+ assert.equal(r.status, 200, JSON.stringify(r.body));
114
+ assert.equal(r.body.goal.status, 'archived');
115
+ assert.deepEqual(r.body.applied, [
116
+ { task_id: '1', verb: 'abandon', result: 'abandoned' },
117
+ { task_id: '2', verb: 'move', result: 'moved' },
118
+ ]);
119
+ assert.deepEqual(applied.plan, [
120
+ { taskId: 1, verb: 'abandon', toGoalId: null },
121
+ { taskId: 2, verb: 'move', toGoalId: 42 },
122
+ ]);
123
+ assert.equal(applied.reason, 'cut in the ten-areas review');
124
+ ok('a complete map archives the goal and reports what happened to each task');
125
+
126
+ // 8. a goal holding nothing is still ONE call, no reason needed
127
+ OPEN = [];
128
+ r = await post('/goals/7/archive');
129
+ assert.equal(r.status, 200); assert.equal(r.body.goal.status, 'archived');
130
+ assert.deepEqual(r.body.applied, []);
131
+ ok('a goal holding nothing archives in one call with no reason');
132
+
133
+ assert.equal(n, 8, 'every check must run — an early return would pass silently');
134
+ console.log(`\n${n}/8 end-to-end checks passed against the real router.`);
135
+ server.close();