@bongos/core 1.19.588 → 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.
@@ -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.588",
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.588",
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.588",
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.588'; // 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
+ });