@bongos/core 1.19.812 → 1.19.813

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.812",
6
- "core_contract": "1.19.812",
7
- "source_commit": "b0747d044e08c53ffda8cc93bceafef59787e27a",
5
+ "core_version": "1.19.813",
6
+ "core_contract": "1.19.813",
7
+ "source_commit": "62a2b5a5f9bb96162fdb7d1be299d9fd50b6f7e3",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-17T06:53:39.832Z",
9
+ "built_at": "2026-09-17T07:11:52.940Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 488,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2208,
14
+ "functional_verbatim": 2209,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2722,
20
- "tree_sha256": "7ce723b60b68f0cb665f81b47e71bdcec72bd1ddc5fe8c4e22c9ad0a07b79440",
19
+ "file_count": 2723,
20
+ "tree_sha256": "911e2248028f57d2233c75dde64c923ba2cb3642f6aa99b8607b405bd8ca9b03",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -2537,7 +2537,7 @@
2537
2537
  {
2538
2538
  "path": "docs/module-api-changelog.md",
2539
2539
  "mode": "0000644",
2540
- "sha256": "3478aad2e6df54281dea8fcab1695cb6f943da2d3b9f5fe2ca9a6ec902061d5b"
2540
+ "sha256": "d3749c214cb091b1df2679417ab828c6f9989c62592fe213d06c2d0365ec781b"
2541
2541
  },
2542
2542
  {
2543
2543
  "path": "docs/modules-contract.md",
@@ -5534,10 +5534,15 @@
5534
5534
  "mode": "0000644",
5535
5535
  "sha256": "76ffab3052ce0b48d0b78451a2e34d5cd3c5300410f0856b4a6667cbe708ba2b"
5536
5536
  },
5537
+ {
5538
+ "path": "modules/lifecycle/db-executor.js",
5539
+ "mode": "0000644",
5540
+ "sha256": "b810e4488b35fe70508cae1c6583112033def4726d71af2e7efc7b18c495ba27"
5541
+ },
5537
5542
  {
5538
5543
  "path": "modules/lifecycle/db-goals.js",
5539
5544
  "mode": "0000644",
5540
- "sha256": "2d9b4d62072ec588446d8098a08a2a300df42fa93e62b39086e80da1a95f1141"
5545
+ "sha256": "b4d1c8bd2254f93519a88302072c2f524a1acb4152382fb09bdc22b1ef95f0ed"
5541
5546
  },
5542
5547
  {
5543
5548
  "path": "modules/lifecycle/db-grade.js",
@@ -5567,12 +5572,12 @@
5567
5572
  {
5568
5573
  "path": "modules/lifecycle/db-tasks.js",
5569
5574
  "mode": "0000644",
5570
- "sha256": "b26ba2c82bea0ee71e3db888bf531cee605aac90fa5ded2bcffe548cce650f4f"
5575
+ "sha256": "87b075187493e8026f99912dceb7195d09564bfe3f969f344141661c60ef6558"
5571
5576
  },
5572
5577
  {
5573
5578
  "path": "modules/lifecycle/db-versions.js",
5574
5579
  "mode": "0000644",
5575
- "sha256": "7eaaafc9414bf77c12c57eefde02ddf1c67601f4b6b15ded3692bc3765ac036b"
5580
+ "sha256": "4b0681bf2bc7cd40a0a7bcdc9d2fab033c735390555be54b0a1083e5fa231abd"
5576
5581
  },
5577
5582
  {
5578
5583
  "path": "modules/lifecycle/db.js",
@@ -5587,7 +5592,7 @@
5587
5592
  {
5588
5593
  "path": "modules/lifecycle/done-when.js",
5589
5594
  "mode": "0000644",
5590
- "sha256": "186a830fbb63ff5510af78ba4fe77cd94325571eed2809ace7817c66fb4155ba"
5595
+ "sha256": "200e7c0937fefb97703927a699bff40b9c5061fae29c6779b998542a00724a22"
5591
5596
  },
5592
5597
  {
5593
5598
  "path": "modules/lifecycle/est-advisory.js",
@@ -5627,7 +5632,7 @@
5627
5632
  {
5628
5633
  "path": "modules/lifecycle/goal-edits.js",
5629
5634
  "mode": "0000644",
5630
- "sha256": "f00d1bb6bd57624679295f33d55ad1d81a9479789a78d4325f13948b9559f9f5"
5635
+ "sha256": "c22a0782ddf7a93deb40f700a2afab15a313925647bbdf424db777da5aa29aa1"
5631
5636
  },
5632
5637
  {
5633
5638
  "path": "modules/lifecycle/goal-rollup.js",
@@ -5762,7 +5767,7 @@
5762
5767
  {
5763
5768
  "path": "modules/lifecycle/routes/done-when.js",
5764
5769
  "mode": "0000644",
5765
- "sha256": "9370fd1fc8a4a1ff864c1e85321b05a74bb45150ef8eb92cefdbbd85524bbe27"
5770
+ "sha256": "13acccfc01be1760dc016519ccb430c448b237d6e02db83aa01815b2e45db7de"
5766
5771
  },
5767
5772
  {
5768
5773
  "path": "modules/lifecycle/routes/gate-approvals.js",
@@ -7767,12 +7772,12 @@
7767
7772
  {
7768
7773
  "path": "package-lock.json",
7769
7774
  "mode": "0000644",
7770
- "sha256": "2a7a58719ee7045ba87756d837dd6c059a099e04b4e86ee4a5c8cc9e2c42b50e"
7775
+ "sha256": "e5b6e6ec23435368ffb25a25943670922f08c4a9f9a7a12e8a12d62e9bc48691"
7771
7776
  },
7772
7777
  {
7773
7778
  "path": "package.json",
7774
7779
  "mode": "0000644",
7775
- "sha256": "46f70444019c4bb61f50098fb4e9d1f2b05cc66142c6f59ea36a17c8e722e636"
7780
+ "sha256": "beca5540ada5fdee92caf4c003fdd683ba0ac71218eb54fc9520db7b1e23a74e"
7776
7781
  },
7777
7782
  {
7778
7783
  "path": "public-docs/index.html",
@@ -9662,7 +9667,7 @@
9662
9667
  {
9663
9668
  "path": "src/module-api.js",
9664
9669
  "mode": "0000644",
9665
- "sha256": "7532469124e010639f2fcf10257a9ffad594d90f3a15a67647c9a9c052ded87e"
9670
+ "sha256": "c2878962ff1e2c457ea2eee7e6d1d0a6310a103d64ac1c4a1617e4081bc08ac5"
9666
9671
  },
9667
9672
  {
9668
9673
  "path": "src/module-loader/catalog.js",
@@ -10527,12 +10532,12 @@
10527
10532
  {
10528
10533
  "path": "tests/criterion_authoring_gate.mjs",
10529
10534
  "mode": "0000644",
10530
- "sha256": "970db80cf43dba855bf9bd331434add458ab16e719f06710487094a3a578ec53"
10535
+ "sha256": "ebe63de3ffcc33b5848e4226c5d2746d0662a04b9997518469ee98c9515e25d4"
10531
10536
  },
10532
10537
  {
10533
10538
  "path": "tests/criterion_goal_attach.mjs",
10534
10539
  "mode": "0000644",
10535
- "sha256": "979589fd08dc41da3c837513452353aa87f7653620fad22c8a9115d4d7d13b90"
10540
+ "sha256": "58a0685287cb34b1cad88d0b0d0024e801068f8a7f07f53702857076c554bcad"
10536
10541
  },
10537
10542
  {
10538
10543
  "path": "tests/criterion_progress.mjs",
@@ -2098,5 +2098,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2098
2098
  landed since 1.19.810 with no explicit bump. run 35189324772. (task 1002620)
2099
2099
  1.19.812 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2100
2100
  landed since 1.19.811 with no explicit bump. run 35191850392. (task 1002620)
2101
+ 1.19.813 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2102
+ landed since 1.19.812 with no explicit bump. run 35193306517. (task 1002620)
2101
2103
  ---------------------------------------------------------------------------
2102
2104
  ```
@@ -0,0 +1,45 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/db-executor.js — which thing runs the query (task 1003840).
4
+ //
5
+ // WHY THIS IS ONE FUNCTION AND NOT SEVEN COPIES. Every read in this module family
6
+ // already carried the same hand-written line:
7
+ //
8
+ // const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
9
+ //
10
+ // Threading a transaction client through the criterion surface meant editing that
11
+ // line in seven files. A rule about WHICH connection a write lands on is exactly
12
+ // the rule you do not want seven copies of: get one wrong and a write silently
13
+ // leaves the transaction it was supposed to be inside, which is invisible in
14
+ // review and invisible at runtime until a race loses.
15
+ //
16
+ // THE ORDER MATTERS, and it is not the same for the two seams:
17
+ //
18
+ // 1. `deps.client` — a real transaction client, honoured ALWAYS. It is
19
+ // production behaviour, not a test affordance: when a caller has opened a
20
+ // transaction, every statement in that unit of work must run on that client
21
+ // or it is not in the transaction at all.
22
+ // 2. `deps.pool` — the #538 test seam, honoured ONLY under NODE_ENV==='test',
23
+ // exactly as before. Unchanged so a stray deps.pool in production code still
24
+ // cannot redirect a write.
25
+ // 3. the module's own pool — the default, unchanged.
26
+ //
27
+ // A caller that passes both gets the client, because the transaction is the
28
+ // stronger claim.
29
+
30
+ function executorFor(deps, defaultPool) {
31
+ if (deps && deps.client) return deps.client;
32
+ if (deps && deps.pool && process.env.NODE_ENV === 'test') return deps.pool;
33
+ return defaultPool;
34
+ }
35
+
36
+ // True when the caller is inside a transaction, which is the only place a row
37
+ // lock means anything: `SELECT ... FOR UPDATE` on the pool takes a lock and
38
+ // releases it on the next statement, so asking for one outside a transaction is
39
+ // a false sense of safety rather than a cheap extra. Callers use this to refuse
40
+ // rather than to silently downgrade.
41
+ function inTransaction(deps) {
42
+ return Boolean(deps && deps.client);
43
+ }
44
+
45
+ module.exports = { executorFor, inTransaction };
@@ -17,6 +17,7 @@ const { deriveRequiredRank, highestRank } = require('./db-rank-authz.js');
17
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
+ const { executorFor, inTransaction } = require('./db-executor.js');
20
21
  // BV1.R16 (task 1003603) — auto-close, shared with the done-when cascade so the
21
22
  // two achievement writers cannot drift apart (ADR 0263 §4).
22
23
  const { autoCloseVersionsForGoals } = require('./version-autoclose.js');
@@ -325,10 +326,22 @@ async function getGoalAdmission(goalId, deps = {}) {
325
326
  }
326
327
 
327
328
 
329
+ // deps.client runs the read inside a caller's transaction; deps.forUpdate adds the
330
+ // row lock (task 1003840). The lock is what actually closes the criterion routes'
331
+ // check-then-write window: without it, re-reading inside the transaction still sees
332
+ // a row another transaction is free to change before the write lands.
333
+ //
334
+ // forUpdate REFUSES outside a transaction rather than quietly dropping to a plain
335
+ // read. FOR UPDATE on a pooled connection takes a lock and releases it at the end
336
+ // of that implicit statement, so the caller would hold nothing while believing it
337
+ // held something — the precise false sense of safety this task exists to remove.
328
338
  async function getGoal(id, deps = {}) {
329
- const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
339
+ const activePool = executorFor(deps, pool);
340
+ if (deps.forUpdate && !inTransaction(deps)) {
341
+ throw new Error('getGoal({ forUpdate }) requires deps.client — a row lock outside a transaction holds nothing');
342
+ }
330
343
  const { rows } = await activePool.query(
331
- `SELECT ${GOAL_COLS} FROM goals WHERE id = $1`,
344
+ `SELECT ${GOAL_COLS} FROM goals WHERE id = $1${deps.forUpdate ? ' FOR UPDATE' : ''}`,
332
345
  [id]
333
346
  );
334
347
  return rows[0] ?? null;
@@ -444,8 +457,15 @@ async function reorderGoals({ versionId, orderedIds }, deps = {}) {
444
457
  // the auto-achieve + archive/reopen on top). Bumps updated_at. Returns null if
445
458
  // the goal id is unknown.
446
459
 
460
+ // THE WRITE achieveGoalIfComplete DELEGATES TO, so it must honour deps.client or
461
+ // the auto-achieve leaves the caller's transaction (found in review of task
462
+ // 1003840's first cut, where it was missed). Against real Postgres that is not
463
+ // merely untransacted, it SELF-DEADLOCKS: the criterion route holds this goal row
464
+ // FOR UPDATE on its transaction client, and this UPDATE on a pool connection
465
+ // blocks on that lock — while the transaction is awaiting this UPDATE before it
466
+ // can COMMIT and release it. Neither side can move.
447
467
  async function setGoalStatus({ goalId, status }, deps = {}) {
448
- const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
468
+ const activePool = executorFor(deps, pool);
449
469
  const { rows } = await activePool.query(
450
470
  `UPDATE goals
451
471
  SET status = $2, updated_at = now()
@@ -467,8 +487,10 @@ async function setGoalStatus({ goalId, status }, deps = {}) {
467
487
  // siblings, so the flip logic is unit-testable with a fake client.
468
488
 
469
489
  async function achieveGoalIfComplete({ goalId }, deps = {}) {
470
- const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
471
- const goal = await getGoal(goalId, deps);
490
+ const activePool = executorFor(deps, pool);
491
+ // The nested getGoal must NOT inherit forUpdate — this is a re-derive, not the
492
+ // authority read, and locking here would widen the transaction for no gain.
493
+ const goal = await getGoal(goalId, { ...deps, forUpdate: false });
472
494
  // Only an OPEN goal can auto-achieve. An already achieved/archived goal is left
473
495
  // exactly as-is — re-confirming a criterion must not churn its status.
474
496
  if (!goal || goal.status !== 'open') return { achieved: false, goal: goal ?? null };
@@ -512,7 +534,7 @@ async function achieveGoalIfComplete({ goalId }, deps = {}) {
512
534
  //
513
535
  // Runs on the caller's exec so it sees the flip above, which in the ship path is
514
536
  // still uncommitted. Never throws: autoCloseVersionsForGoals catches per version.
515
- const exec = deps.client || ((deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool);
537
+ const exec = executorFor(deps, pool);
516
538
  const versionsClosed = await autoCloseVersionsForGoals(exec, [updated]);
517
539
  return { achieved: true, goal: updated, versionsClosed };
518
540
  }
@@ -829,7 +851,7 @@ async function isGoalMember({ goalId, builderId }, deps = {}) {
829
851
  // isGoalMember boolean.
830
852
 
831
853
  async function getGoalMember({ goalId, builderId }, deps = {}) {
832
- const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
854
+ const activePool = executorFor(deps, pool);
833
855
  const { rows } = await activePool.query(
834
856
  `SELECT goal_id, builder_id, membership_kind, joined_at
835
857
  FROM goal_members
@@ -17,6 +17,7 @@ const { generalGoalTitle } = require('./goal-advisory.js');
17
17
  const { matchProtected } = api.permissionPathCheck;
18
18
  const { decorateGoalMembership, capNewcomerTasks } = require('./claim-feed.js');
19
19
  const { deriveRequiredRank, highestRank } = require('./db-rank-authz.js');
20
+ const { executorFor } = require('./db-executor.js');
20
21
 
21
22
  // -------------------------------------------------------------------------
22
23
  // Tasks
@@ -334,16 +335,21 @@ async function updateTaskColumn(id, column, value) {
334
335
  // planning session uses. sort_order defaults to max(existing)+1 (mirrors the
335
336
  // criterion-proposal ratify path). Throws 23505 on a duplicate (version_id,
336
337
  // criterion_id) — the route maps that to 409.
337
- async function createCriterion({ versionId, criterionId, criterionMd, goalId = null, sortOrder = null }) {
338
+ // deps.client threads the caller's transaction (task 1003840). BOTH statements
339
+ // below must run on it: the MAX(sort_order) read and the INSERT that depends on
340
+ // it are one decision, and splitting them across connections reopens a narrower
341
+ // version of the same race — two creates reading the same next order.
342
+ async function createCriterion({ versionId, criterionId, criterionMd, goalId = null, sortOrder = null }, deps = {}) {
343
+ const executor = executorFor(deps, pool);
338
344
  let order = sortOrder;
339
345
  if (order == null) {
340
- const { rows: maxRows } = await pool.query(
346
+ const { rows: maxRows } = await executor.query(
341
347
  `SELECT COALESCE(MAX(sort_order), 0) + 1 AS next FROM done_when_criteria WHERE version_id = $1`,
342
348
  [versionId]
343
349
  );
344
350
  order = maxRows[0].next;
345
351
  }
346
- const { rows } = await pool.query(
352
+ const { rows } = await executor.query(
347
353
  `INSERT INTO done_when_criteria (version_id, criterion_id, criterion_md, goal_id, sort_order)
348
354
  VALUES ($1, $2, $3, $4, $5)
349
355
  RETURNING id, version_id, criterion_id, criterion_md, goal_id, sort_order, satisfied`,
@@ -364,8 +370,8 @@ async function createCriterion({ versionId, criterionId, criterionMd, goalId = n
364
370
  // question this raises — that the goal exists and sits on the criterion's own
365
371
  // version, who may write it, and whether the move completes the goal; this is the
366
372
  // UPDATE and nothing else, exactly as updateTaskGoal below is for tasks.
367
- async function updateCriterionGoal(id, goalId) {
368
- const { rows } = await pool.query(
373
+ async function updateCriterionGoal(id, goalId, deps = {}) {
374
+ const { rows } = await executorFor(deps, pool).query(
369
375
  `UPDATE done_when_criteria SET goal_id = $1 WHERE id = $2
370
376
  RETURNING id, version_id, criterion_id, criterion_md, goal_id, sort_order, satisfied`,
371
377
  [goalId, id]
@@ -15,6 +15,7 @@ const { pool, withTx } = api;
15
15
  const doneWhen = require('./done-when.js');
16
16
  const { nonTerminalSql } = require('./task-status');
17
17
  const { SMOKE_NOT_LIKE } = require('./db-shared.js');
18
+ const { executorFor, inTransaction } = require('./db-executor.js');
18
19
  // db-goals.js does NOT require this file, so this direction adds no require cycle
19
20
  // (fitness pins the count). createVersion needs it to seed a version's FIRST goal
20
21
  // in the same transaction — task 1003701.
@@ -30,10 +31,23 @@ async function listVersions() {
30
31
  }
31
32
 
32
33
 
33
- async function getVersion(id) {
34
- const { rows } = await pool.query(
34
+ // deps.client/deps.pool, so the criterion routes can read the version inside the
35
+ // same transaction that authorizes and writes (task 1003840). A version's status
36
+ // is one of the facts the decision rests on — reading it on a different connection
37
+ // is reading it at a different instant.
38
+ async function getVersion(id, deps = {}) {
39
+ // deps.forUpdate locks the version row too (task 1003840, round 2). The goal
40
+ // lock alone left one of the three named facts unguarded: closeVersion takes
41
+ // FOR UPDATE on this row, but under READ COMMITTED a PLAIN read is never
42
+ // blocked by another session's lock — so a freeze could still commit between
43
+ // this read and this transaction's COMMIT, and the criterion would land on a
44
+ // version that had closed. Locking makes the two serialize.
45
+ if (deps.forUpdate && !inTransaction(deps)) {
46
+ throw new Error('getVersion({ forUpdate }) requires deps.client — a row lock outside a transaction holds nothing');
47
+ }
48
+ const { rows } = await executorFor(deps, pool).query(
35
49
  `SELECT id, name, track, status, scope_doc_path, started_at, shipped_at, done_when
36
- FROM versions WHERE id = $1`,
50
+ FROM versions WHERE id = $1${deps.forUpdate ? ' FOR UPDATE' : ''}`,
37
51
  [id]
38
52
  );
39
53
  return rows[0] ?? null;
@@ -26,6 +26,7 @@
26
26
  const { pool } = require('../../src/module-api');
27
27
  const { nonTerminalSql } = require('./task-status');
28
28
  const { SMOKE_NOT_LIKE } = require('./db-shared.js');
29
+ const { executorFor } = require('./db-executor.js');
29
30
  // BV1.R16 (task 1003603) — auto-close. Dependency-free module, so requiring it
30
31
  // here creates no cycle (db-versions.js requires THIS file, which is why the
31
32
  // rule could not live beside closeVersion).
@@ -101,10 +102,12 @@ async function criteriaGroupedByGoal(versionId) {
101
102
  // Fetch a single criterion by primary key. Used by the routes to validate
102
103
  // existence before writing (so a missing id returns 404 instead of a silent
103
104
  // no-op UPDATE).
104
- async function getCriterion(criterionId) {
105
+ // deps.client so the PATCH route can read the criterion inside its transaction
106
+ // (task 1003840) — its goal_id is the fact the authority decision rests on.
107
+ async function getCriterion(criterionId, deps = {}) {
105
108
  const id = Number(criterionId);
106
109
  if (!Number.isFinite(id)) return null;
107
- const { rows } = await pool.query(
110
+ const { rows } = await executorFor(deps, pool).query(
108
111
  `SELECT id, version_id, criterion_id, criterion_md, satisfied,
109
112
  satisfied_by_task_id, checked_at, sort_order, goal_id
110
113
  FROM done_when_criteria
@@ -23,6 +23,7 @@
23
23
  // correction is a cohesive slice — the goal-authz.js / claim-feed.js precedent.
24
24
 
25
25
  const api = require('../../src/module-api');
26
+ const { executorFor } = require('./db-executor.js');
26
27
  const { pool } = api;
27
28
 
28
29
  // ---------------------------------------------------------------------------
@@ -119,7 +120,10 @@ async function updateGoalProse({ goalId, patch = {} }, deps = {}) {
119
120
  // (criterionProseRefusal) so the route can answer 404 vs 409 distinctly; this
120
121
  // only writes. Returns { criterion, deltas } or null when it vanished mid-flight.
121
122
  async function updateCriterionProse({ criterionId, criterionMd }, deps = {}) {
122
- const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
123
+ // Read-then-write in its own right: the before-value it captures for the audit
124
+ // delta must come from the same transaction as the UPDATE, or the recorded
125
+ // "before" can be a value that was already gone (task 1003840).
126
+ const activePool = executorFor(deps, pool);
123
127
  const { rows: before } = await activePool.query(
124
128
  `SELECT id, criterion_md, satisfied FROM done_when_criteria WHERE id = $1`,
125
129
  [criterionId]
@@ -130,40 +130,58 @@ module.exports = function buildDoneWhenRouter() {
130
130
  goal_id: { type: 'integer', min: 1 },
131
131
  sort_order: { type: 'integer', min: 0 },
132
132
  })) return;
133
- const version = await db.getVersion(versionId);
134
- if (!version) return res.fail('version_not_found', { status: 404, message: `No version '${versionId}'.` });
135
133
  // bad_goal_id now means exactly what it says: the goal is absent or belongs
136
134
  // to another version. (The unauthorized case rendered through this same
137
135
  // helper until task 1002614 — see the mask note in authorizeCriterionCreate.)
138
136
  const badGoalId = () => res.fail('bad_goal_id', { status: 400, message: `goal_id ${req.body.goal_id} does not exist on version '${versionId}'.` });
139
- // A goal_id, if given, must exist AND belong to this version.
140
- let goal = null;
141
- if (req.body.goal_id != null) {
142
- goal = await db.getGoal(req.body.goal_id);
143
- if (!goal || String(goal.version_id) !== String(versionId)) return badGoalId();
144
- }
145
- // ADR 0154: the authority wall. Checked AFTER the goal's version linkage so a
146
- // bad ref is still the clearer 400. Membership is only read when a goal was
147
- // named and the caller isn't an Archon — a primary-key hit on
148
- // goal_members (goal_id, builder_id) (migration 160), on a low-frequency route.
149
- const actorMembership = (goal && req.builder.rank !== 'archon')
150
- ? await db.getGoalMember({ goalId: goal.id, builderId: req.builder.id })
151
- : null;
152
- const decision = authorizeCriterionCreate({
153
- goal, version, actorId: req.builder.id, actorRank: req.builder.rank, actorMembership,
154
- });
155
- if (!decision.ok) {
156
- return res.status(decision.status).json(decision.body);
157
- }
158
137
  try {
159
- const criterion = await db.createCriterion({
160
- versionId,
161
- criterionId: req.body.criterion_id,
162
- criterionMd: req.body.criterion_md,
163
- goalId: req.body.goal_id ?? null,
164
- sortOrder: req.body.sort_order ?? null,
165
- });
166
- res.status(201).json({ ok: true, criterion });
138
+ // RESOLVE, DECIDE and WRITE in ONE transaction, with the goal row LOCKED
139
+ // (task 1003840). Before this, the three facts the decision rests on — the
140
+ // version's status, the goal's owner/version linkage, the actor's
141
+ // membership — were read on the pool, and the INSERT landed afterwards on
142
+ // another connection. Between the two, the goal could be transferred or
143
+ // closed, the membership revoked, or the version frozen, and the write
144
+ // would land on authority that no longer held.
145
+ //
146
+ // The LOCK is the part that closes it. A bare transaction around the same
147
+ // sequence would still read the stale rows; the lock makes a concurrent
148
+ // transfer wait behind this one instead of racing it.
149
+ const outcome = await api.withTx(async (client) => {
150
+ const version = await db.getVersion(versionId, { client, forUpdate: true });
151
+ if (!version) return { fail: ['version_not_found', { status: 404, message: `No version '${versionId}'.` }] };
152
+ // A goal_id, if given, must exist AND belong to this version.
153
+ let goal = null;
154
+ if (req.body.goal_id != null) {
155
+ goal = await db.getGoal(req.body.goal_id, { client, forUpdate: true });
156
+ if (!goal || String(goal.version_id) !== String(versionId)) return { badGoal: true };
157
+ }
158
+ // ADR 0154: the authority wall. Checked AFTER the goal's version linkage so a
159
+ // bad ref is still the clearer 400. Membership is only read when a goal was
160
+ // named and the caller isn't an Archon — a primary-key hit on
161
+ // goal_members (goal_id, builder_id) (migration 160), on a low-frequency route.
162
+ const actorMembership = (goal && req.builder.rank !== 'archon')
163
+ ? await db.getGoalMember({ goalId: goal.id, builderId: req.builder.id }, { client })
164
+ : null;
165
+ const decision = authorizeCriterionCreate({
166
+ goal, version, actorId: req.builder.id, actorRank: req.builder.rank, actorMembership,
167
+ });
168
+ if (!decision.ok) return { decision };
169
+ const criterion = await db.createCriterion({
170
+ versionId,
171
+ criterionId: req.body.criterion_id,
172
+ criterionMd: req.body.criterion_md,
173
+ goalId: req.body.goal_id ?? null,
174
+ sortOrder: req.body.sort_order ?? null,
175
+ }, { client });
176
+ return { criterion };
177
+ }, { pool: api.pool });
178
+ // Responses are sent AFTER the transaction resolves, never from inside it:
179
+ // a refusal must roll back rather than commit a half-written unit of work,
180
+ // and a 201 must not be promised before the COMMIT succeeds.
181
+ if (outcome.badGoal) return badGoalId();
182
+ if (outcome.fail) return res.fail(outcome.fail[0], outcome.fail[1]);
183
+ if (outcome.decision) return res.status(outcome.decision.status).json(outcome.decision.body);
184
+ res.status(201).json({ ok: true, criterion: outcome.criterion });
167
185
  } catch (err) {
168
186
  if (err && err.code === '23505') {
169
187
  return res.fail('criterion_exists', { status: 409, message: `criterion_id '${req.body.criterion_id}' already exists on version '${versionId}'.` });
@@ -294,64 +312,82 @@ module.exports = function buildDoneWhenRouter() {
294
312
  }
295
313
  const id = parseId(req, res, { param: 'criterionId', code: 'bad_criterion_id' });
296
314
  if (id === null) return;
297
- const existing = await doneWhen.getCriterion(id);
298
- if (!existing) return res.fail('criterion_not_found', 404);
299
- const version = await db.getVersion(existing.version_id);
300
- // The ownership wall, resolved exactly as the CREATE route resolves it: the
301
- // goal + version, and membership read only for a non-Archon. One closure, so
302
- // both ends of a move are decided by the same code rather than a copy.
303
- const authorizeFor = async (goal) => {
304
- const actorMembership = (goal && req.builder.rank !== 'archon')
305
- ? await db.getGoalMember({ goalId: goal.id, builderId: req.builder.id })
306
- : null;
307
- return authorizeCriterionCreate({
308
- goal, version, actorId: req.builder.id, actorRank: req.builder.rank, actorMembership,
309
- });
310
- };
311
- const currentGoal = existing.goal_id != null ? await db.getGoal(existing.goal_id) : null;
312
- const authority = await authorizeFor(currentGoal);
313
- if (!authority.ok) return res.status(authority.status).json(authority.body);
315
+ // Resolve, authorize and write inside ONE transaction, both goal rows LOCKED
316
+ // (task 1003840) — the same window the CREATE route above had, and the same
317
+ // cure. A move reads authority at BOTH ends, so both ends are locked: losing
318
+ // the race at either would move a criterion under authority that had just
319
+ // been revoked. Locking in a stable order (source before destination) is what
320
+ // keeps two concurrent moves from deadlocking on each other's rows.
321
+ const outcome = await api.withTx(async (client) => {
322
+ const existing = await doneWhen.getCriterion(id, { client });
323
+ if (!existing) return { fail: ['criterion_not_found', 404] };
324
+ const version = await db.getVersion(existing.version_id, { client, forUpdate: true });
325
+ // The ownership wall, resolved exactly as the CREATE route resolves it: the
326
+ // goal + version, and membership read only for a non-Archon. One closure, so
327
+ // both ends of a move are decided by the same code rather than a copy.
328
+ const authorizeFor = async (goal) => {
329
+ const actorMembership = (goal && req.builder.rank !== 'archon')
330
+ ? await db.getGoalMember({ goalId: goal.id, builderId: req.builder.id }, { client })
331
+ : null;
332
+ return authorizeCriterionCreate({
333
+ goal, version, actorId: req.builder.id, actorRank: req.builder.rank, actorMembership,
334
+ });
335
+ };
336
+ const targetGoalId = movingGoal ? (req.body.goal_id ?? null) : null;
337
+
338
+ // Lock BOTH ends in ASCENDING GOAL ID, never in the order the route happens
339
+ // to need them (task 1003840, round 2 — the first cut locked source then
340
+ // destination, which is call order, not a canonical one). Two opposite
341
+ // moves, 5→10 and 10→5, would then take the two locks in opposite orders
342
+ // and deadlock; Postgres aborts one rather than corrupting anything, but an
343
+ // avoidable abort is still an outage for whoever loses. A single agreed
344
+ // order means they queue instead.
345
+ const lockIds = [...new Set([
346
+ existing.goal_id != null ? Number(existing.goal_id) : null,
347
+ targetGoalId !== null ? Number(targetGoalId) : null,
348
+ ].filter((v) => v != null))].sort((a, b) => a - b);
349
+ const locked = new Map();
350
+ for (const gid of lockIds) locked.set(gid, await db.getGoal(gid, { client, forUpdate: true }));
351
+
352
+ const currentGoal = existing.goal_id != null ? locked.get(Number(existing.goal_id)) ?? null : null;
353
+ const authority = await authorizeFor(currentGoal);
354
+ if (!authority.ok) return { decision: authority };
314
355
 
315
- const targetGoalId = movingGoal ? (req.body.goal_id ?? null) : null;
316
- if (movingGoal) {
317
- let targetGoal = null;
318
- if (targetGoalId !== null) {
319
- targetGoal = await db.getGoal(targetGoalId);
320
- // Same refusal the CREATE route gives, for the same reason: a criterion may
321
- // never cross versions, which would put it in one version's rollup and the
322
- // other's goal at once.
323
- if (!targetGoal || String(targetGoal.version_id) !== String(existing.version_id)) {
324
- return res.fail('bad_goal_id', { status: 400, message: `goal_id ${targetGoalId} does not exist on version '${existing.version_id}'.` });
356
+ if (movingGoal) {
357
+ let targetGoal = null;
358
+ if (targetGoalId !== null) {
359
+ targetGoal = locked.get(Number(targetGoalId)) ?? null;
360
+ // Same refusal the CREATE route gives, for the same reason: a criterion may
361
+ // never cross versions, which would put it in one version's rollup and the
362
+ // other's goal at once.
363
+ if (!targetGoal || String(targetGoal.version_id) !== String(existing.version_id)) {
364
+ return { fail: ['bad_goal_id', { status: 400, message: `goal_id ${targetGoalId} does not exist on version '${existing.version_id}'.` }] };
365
+ }
325
366
  }
367
+ const destination = await authorizeFor(targetGoal);
368
+ if (!destination.ok) return { decision: destination };
326
369
  }
327
- const destination = await authorizeFor(targetGoal);
328
- if (!destination.ok) return res.status(destination.status).json(destination.body);
329
- }
330
370
 
331
- let criterion = existing;
332
- let deltas = [];
333
- let unchanged = false;
334
- if (changingProse) {
335
- const refusal = goalEdits.criterionProseRefusal(existing);
336
- if (refusal) return res.fail(refusal.code, refusal.status, refusal.message ? { message: refusal.message } : undefined);
337
- const result = await goalEdits.updateCriterionProse({ criterionId: id, criterionMd: req.body.criterion_md });
338
- if (!result) return res.fail('criterion_not_found', 404);
339
- await goalEdits.recordProseEdit('criterion', id, req, result.deltas);
340
- criterion = result.criterion;
341
- deltas = result.deltas;
342
- unchanged = !!result.unchanged;
343
- }
344
- const achievedGoals = [];
345
- const fromGoalId = existing.goal_id ?? null;
371
+ let criterion = existing;
372
+ let deltas = [];
373
+ let unchanged = false;
374
+ if (changingProse) {
375
+ const refusal = goalEdits.criterionProseRefusal(existing);
376
+ if (refusal) return { refusal };
377
+ const result = await goalEdits.updateCriterionProse({ criterionId: id, criterionMd: req.body.criterion_md }, { client });
378
+ if (!result) return { fail: ['criterion_not_found', 404] };
379
+ criterion = result.criterion;
380
+ deltas = result.deltas;
381
+ unchanged = !!result.unchanged;
382
+ }
383
+ const achievedGoals = [];
384
+ const fromGoalId = existing.goal_id ?? null;
346
385
  // A move to where it already is writes nothing at all — not the row, not the
347
386
  // audit, not a re-derive. Nothing about the world changed.
348
- if (movingGoal && fromGoalId !== targetGoalId) {
349
- const moved = await db.updateCriterionGoal(id, targetGoalId);
350
- if (!moved) return res.fail('criterion_not_found', 404);
351
- criterion = moved;
352
- await goalEdits.recordCriterionGoalMove(
353
- { criterionId: id, fromGoalId, toGoalId: targetGoalId }, req
354
- );
387
+ if (movingGoal && fromGoalId !== targetGoalId) {
388
+ const moved = await db.updateCriterionGoal(id, targetGoalId, { client });
389
+ if (!moved) return { fail: ['criterion_not_found', 404] };
390
+ criterion = moved;
355
391
  // BOTH ENDS are re-derived, because a move changes what each of them still
356
392
  // needs. The destination is the obvious half — adopting an already-satisfied
357
393
  // criterion can be the thing that completes a goal. The SOURCE is the half
@@ -361,13 +397,41 @@ module.exports = function buildDoneWhenRouter() {
361
397
  // will ever close again — no later ship re-checks it, because no task under
362
398
  // it changed. achieveGoalIfComplete is conservative and idempotent (open
363
399
  // goals only, and it never un-achieves), so asking twice is two reads.
364
- for (const goalId of [fromGoalId, targetGoalId]) {
365
- if (goalId === null) continue;
366
- const outcome = await db.achieveGoalIfComplete({ goalId });
367
- if (outcome && outcome.achieved) achievedGoals.push(goalId);
400
+ for (const goalId of [fromGoalId, targetGoalId]) {
401
+ if (goalId === null) continue;
402
+ const achieved = await db.achieveGoalIfComplete({ goalId }, { client });
403
+ if (achieved && achieved.achieved) achievedGoals.push(goalId);
404
+ }
368
405
  }
406
+ return { criterion, deltas, unchanged, achievedGoals, moved: movingGoal && fromGoalId !== targetGoalId, fromGoalId, targetGoalId };
407
+ }, { pool: api.pool });
408
+
409
+ // Everything below runs only after COMMIT. The audit writes in particular
410
+ // belong out here: they are announcements of a change that happened, so
411
+ // recording one for a unit of work that rolled back would put an edit in the
412
+ // ledger that never landed — the ledger lying, which is the one thing it
413
+ // must not do.
414
+ if (outcome.fail) return res.fail(outcome.fail[0], outcome.fail[1]);
415
+ if (outcome.decision) return res.status(outcome.decision.status).json(outcome.decision.body);
416
+ if (outcome.refusal) {
417
+ const r = outcome.refusal;
418
+ return res.fail(r.code, r.status, r.message ? { message: r.message } : undefined);
369
419
  }
370
- return res.json({ ok: true, criterion, deltas, unchanged, achieved_goals: achievedGoals });
420
+ if (outcome.deltas && outcome.deltas.length) {
421
+ await goalEdits.recordProseEdit('criterion', id, req, outcome.deltas);
422
+ }
423
+ if (outcome.moved) {
424
+ await goalEdits.recordCriterionGoalMove(
425
+ { criterionId: id, fromGoalId: outcome.fromGoalId, toGoalId: outcome.targetGoalId }, req
426
+ );
427
+ }
428
+ return res.json({
429
+ ok: true,
430
+ criterion: outcome.criterion,
431
+ deltas: outcome.deltas,
432
+ unchanged: outcome.unchanged,
433
+ achieved_goals: outcome.achievedGoals,
434
+ });
371
435
  }, { errorCode: 'criterion_update_failed' }));
372
436
 
373
437
  // Auth: flip a criterion back to unsatisfied (rare; for corrections).
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.812",
3
+ "version": "1.19.813",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.812",
9
+ "version": "1.19.813",
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.812",
3
+ "version": "1.19.813",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.812'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.813'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -144,8 +144,29 @@ await test('authority is checked BEFORE goal state — an owner sees goal_not_op
144
144
 
145
145
  await test('bad_goal_id now means exactly what it says — the mask branch is gone from the route', () => {
146
146
  assert.match(doneWhenSrc, /const badGoalId = \(\) => res\.fail\('bad_goal_id'/, 'one helper builds the absent/foreign refusal');
147
- assert.match(doneWhenSrc, /if \(!goal \|\| String\(goal\.version_id\) !== String\(versionId\)\) return badGoalId\(\);/, 'absent/foreign goal path');
147
+ // The absent/foreign check and the refusal it renders are now separated by the
148
+ // transaction boundary (task 1003840): the check runs inside withTx and returns
149
+ // a sentinel, because sending a response from inside the transaction would
150
+ // commit a unit of work the route is in the middle of refusing. So the chain is
151
+ // asserted in two links rather than as one literal line — the invariant is
152
+ // "an absent or foreign goal reaches badGoalId and nothing else", which is what
153
+ // this test was always about.
154
+ assert.match(
155
+ doneWhenSrc,
156
+ /if \(!goal \|\| String\(goal\.version_id\) !== String\(versionId\)\) return \{ badGoal: true \};/,
157
+ 'absent/foreign goal path still refuses, inside the transaction',
158
+ );
159
+ assert.match(
160
+ doneWhenSrc,
161
+ /if \(outcome\.badGoal\) return badGoalId\(\);/,
162
+ 'and that sentinel is rendered by badGoalId once the transaction resolves',
163
+ );
148
164
  assert.doesNotMatch(doneWhenSrc, /maskAsBadGoalId/, 'no code path renders an authorization refusal as bad_goal_id');
165
+ // The sentinel must be answered exactly where it is raised — a second producer
166
+ // with no consumer, or vice versa, is how a refusal quietly becomes a 201.
167
+ const raised = (doneWhenSrc.match(/badGoal: true/g) || []).length;
168
+ const answered = (doneWhenSrc.match(/outcome\.badGoal/g) || []).length;
169
+ assert.equal(raised, answered, 'every badGoal sentinel raised is answered');
149
170
  });
150
171
 
151
172
  await test('a SHIPPED version is refused (409) — closed scope', () => {
@@ -36,21 +36,54 @@ const api = require('../src/module-api.js');
36
36
  // db.updateCriterionGoal instead would leave its SQL with no coverage at all —
37
37
  // a mutation that made the UPDATE write nothing stayed green until this landed.
38
38
  const sqlLog = [];
39
+
40
+ // One statement handler, shared by the pool and by the transaction client it
41
+ // hands out, so a statement is recorded identically wherever it runs — otherwise
42
+ // "the UPDATE reached the pool" would quietly stop being true the moment the
43
+ // route started using a transaction, and the assertions would pass over nothing.
44
+ const runSql = async (sql, params, via = 'pool') => {
45
+ sqlLog.push({ sql, params, via });
46
+ if (/UPDATE done_when_criteria/.test(sql)) {
47
+ const [goalId, id] = params;
48
+ criterion = { ...criterion, id: Number(id), goal_id: goalId };
49
+ return { rows: [{ ...criterion }] };
50
+ }
51
+ if (/COALESCE\(MAX\(sort_order\)/.test(sql)) return { rows: [{ next: 7 }] };
52
+ if (/INSERT INTO done_when_criteria/.test(sql)) {
53
+ const [versionId, criterionId, criterionMd, goalId, sortOrder] = params;
54
+ return { rows: [{ id: 99, version_id: versionId, criterion_id: criterionId, criterion_md: criterionMd, goal_id: goalId, sort_order: sortOrder, satisfied: false }] };
55
+ }
56
+ if (/^\s*(BEGIN|COMMIT|ROLLBACK)\s*$/i.test(sql)) return { rows: [] };
57
+ throw new Error(`unexpected SQL reached the pool: ${sql}`);
58
+ };
59
+
60
+ // connect() makes withTx work against the fake (task 1003840). Every client it
61
+ // returns is recorded, so a test can assert the transaction BOUNDARY — that a
62
+ // write happened between BEGIN and COMMIT — not merely that the write happened.
63
+ const clients = [];
39
64
  Object.defineProperty(api, 'pool', {
40
65
  configurable: true,
41
66
  value: {
42
- query: async (sql, params) => {
43
- sqlLog.push({ sql, params });
44
- if (/UPDATE done_when_criteria/.test(sql)) {
45
- const [goalId, id] = params;
46
- criterion = { ...criterion, id: Number(id), goal_id: goalId };
47
- return { rows: [{ ...criterion }] };
48
- }
49
- throw new Error(`unexpected SQL reached the pool: ${sql}`);
67
+ query: (sql, params) => runSql(sql, params, 'pool'),
68
+ connect: async () => {
69
+ const client = {
70
+ query: (sql, params) => runSql(sql, params, 'client'),
71
+ release: () => { client.released = true; },
72
+ released: false,
73
+ };
74
+ clients.push(client);
75
+ return client;
50
76
  },
51
77
  },
52
78
  });
53
79
 
80
+ // The statements of the LAST transaction, in order — BEGIN … COMMIT included.
81
+ function txStatements() {
82
+ const begin = sqlLog.findIndex((q) => /^\s*BEGIN\s*$/i.test(q.sql));
83
+ if (begin === -1) return [];
84
+ return sqlLog.slice(begin).map((q) => q.sql.trim().split(/\s+/).slice(0, 3).join(' '));
85
+ }
86
+
54
87
  const db = require('../modules/lifecycle/db.js');
55
88
  const doneWhen = require('../modules/lifecycle/done-when.js');
56
89
  const goalEdits = require('../modules/lifecycle/goal-edits.js');
@@ -71,6 +104,7 @@ function resetWorld() {
71
104
  actor = METIC;
72
105
  completable = new Set([3]);
73
106
  sqlLog.length = 0;
107
+ clients.length = 0;
74
108
  calls = { achieve: [], auditMove: [], prose: [] };
75
109
  // Derived from the SQL the real updateCriterionGoal actually sent, as
76
110
  // [criterionId, goalId] — so "nothing was written" means no UPDATE reached the
@@ -140,6 +174,15 @@ async function patch(body) {
140
174
  return { status: res.status, body: await res.json().catch(() => ({})) };
141
175
  }
142
176
 
177
+ async function post(versionId, body) {
178
+ const res = await fetch(`${base}/versions/${versionId}/done-when`, {
179
+ method: 'POST',
180
+ headers: { 'content-type': 'application/json' },
181
+ body: JSON.stringify(body),
182
+ });
183
+ return { status: res.status, body: await res.json().catch(() => ({})) };
184
+ }
185
+
143
186
  let passed = 0, failed = 0;
144
187
  async function test(name, fn) {
145
188
  resetWorld();
@@ -342,6 +385,188 @@ await test('the UPDATE sets goal_id from the parameter, keyed by criterion id',
342
385
  assert.deepEqual(update.params, [3, 55]);
343
386
  });
344
387
 
388
+
389
+ // ---- the TOCTOU window (task 1003840) --------------------------------------
390
+ //
391
+ // The route used to resolve its facts on the pool, decide, and then write on
392
+ // another connection. Between the decision and the write the goal could be
393
+ // transferred or closed and the membership revoked, and the write would land on
394
+ // authority that no longer held. These pin the three properties that close it:
395
+ // one transaction, the authority read LOCKED inside it, and nothing written (or
396
+ // announced) when the decision refuses.
397
+
398
+ // Record what the route ASKS FOR, since getGoal is stubbed here and its SQL never
399
+ // reaches the recording pool.
400
+ const goalReads = [];
401
+ const versionReads = [];
402
+ const realGetGoal = db.getGoal;
403
+ const realGetVersion = db.getVersion;
404
+ db.getGoal = async (id, deps = {}) => { goalReads.push({ id: Number(id), deps }); return realGetGoal(id, deps); };
405
+ db.getVersion = async (id, deps = {}) => { versionReads.push({ id, deps }); return realGetVersion(id, deps); };
406
+
407
+ await test('the authority read is LOCKED and inside the transaction (task 1003840)', async () => {
408
+ actor = ARCHON;
409
+ goalReads.length = 0;
410
+ await patch({ goal_id: 3 });
411
+ assert.ok(goalReads.length > 0, 'the route read a goal at all');
412
+ for (const r of goalReads) {
413
+ assert.ok(r.deps && r.deps.client, `goal ${r.id} was read OUTSIDE the transaction`);
414
+ assert.equal(r.deps.forUpdate, true, `goal ${r.id} was read without FOR UPDATE — a re-read that is not locked still races`);
415
+ }
416
+ });
417
+
418
+ await test('the write lands BETWEEN begin and commit (task 1003840)', async () => {
419
+ actor = ARCHON;
420
+ await patch({ goal_id: 3 });
421
+ const seq = sqlLog.map((q) => q.sql.trim().split(/\s+/).slice(0, 2).join(' ').toUpperCase());
422
+ const begin = seq.findIndex((x) => x.startsWith('BEGIN'));
423
+ const update = seq.findIndex((x) => x.startsWith('UPDATE DONE_WHEN_CRITERIA'));
424
+ const commit = seq.findIndex((x) => x.startsWith('COMMIT'));
425
+ assert.ok(begin !== -1, 'a transaction was opened');
426
+ assert.ok(update !== -1, 'the move was written');
427
+ assert.ok(commit !== -1, 'the transaction was committed');
428
+ assert.ok(begin < update && update < commit,
429
+ `expected BEGIN < UPDATE < COMMIT, got ${begin} / ${update} / ${commit}`);
430
+ // ORDER IS NOT ENOUGH. A write sent to the POOL while a transaction happens to
431
+ // be open still lands between BEGIN and COMMIT in this log, and is exactly the
432
+ // bug — it is outside the transaction and rolls back with nothing. Caught by
433
+ // probing: dropping the client from updateCriterionGoal left the ordering
434
+ // assertion green. So the connection is asserted, not just the sequence.
435
+ const writes = sqlLog.filter((q) => /UPDATE done_when_criteria/.test(q.sql));
436
+ assert.ok(writes.length > 0, 'the move was written');
437
+ for (const w of writes) {
438
+ assert.equal(w.via, 'client', 'the write must run ON the transaction client, not the pool');
439
+ }
440
+ });
441
+
442
+ await test('the transaction client is always released (task 1003840)', async () => {
443
+ // A leaked client is a pool slot that never comes back; under load that is an
444
+ // outage, and it would not show up in any assertion about the response.
445
+ actor = ARCHON;
446
+ await patch({ goal_id: 3 });
447
+ assert.ok(clients.length > 0, 'a client was checked out');
448
+ for (const c of clients) assert.equal(c.released, true, 'every checked-out client is released');
449
+ });
450
+
451
+ await test('a REFUSED move writes nothing and announces nothing (task 1003840)', async () => {
452
+ // The rollback path. A refusal must leave no row written and no audit entry:
453
+ // recording a move that did not happen would put an edit in the ledger that
454
+ // never landed.
455
+ actor = METIC; // not the owner of goal 3
456
+ goals.set(3, { id: 3, version_id: V1, owner_builder_id: 999, status: 'open' });
457
+ const res = await patch({ goal_id: 3 });
458
+ assert.ok(res.status >= 400, `expected a refusal, got ${res.status}`);
459
+ assert.deepEqual(calls.updateGoal, [], 'no UPDATE reached the pool');
460
+ assert.deepEqual(calls.auditMove, [], 'no move was announced for a write that never landed');
461
+ assert.equal(clients.every((c) => c.released), true, 'the client is released on the refusal path too');
462
+ });
463
+
464
+ await test('FOR UPDATE outside a transaction is refused, not silently downgraded (task 1003840)', async () => {
465
+ // The lock is the whole fix, so asking for one where it cannot be held must be
466
+ // an error rather than a quiet plain read — otherwise a future caller drops the
467
+ // client, keeps the flag, and believes it is safe.
468
+ // The REAL implementation, not this file's stub of it: db.getGoal is replaced
469
+ // above, so asserting against that would only prove the stub ignores the flag.
470
+ // It throws before touching a pool, so this needs no database.
471
+ const realGoals = require('../modules/lifecycle/db-goals.js');
472
+ await assert.rejects(
473
+ () => realGoals.getGoal(3, { forUpdate: true }),
474
+ /requires deps\.client/,
475
+ 'forUpdate without a client must throw',
476
+ );
477
+ });
478
+
479
+
480
+ // ---- CREATE route: the same machinery, the same proof (task 1003840, round 2) --
481
+ //
482
+ // The first cut shipped the CREATE route's transaction with no runtime coverage —
483
+ // only a source-regex on the sentinel shape — while its PATCH sibling got four
484
+ // behavioural tests. Identical machinery deserves identical proof: a regex cannot
485
+ // tell whether the INSERT actually ran on the transaction client.
486
+
487
+ await test('CREATE: the goal is locked, inside the transaction (task 1003840)', async () => {
488
+ actor = ARCHON;
489
+ goalReads.length = 0;
490
+ const r = await post(V1, { criterion_id: 'C9', criterion_md: 'x', goal_id: 3 });
491
+ assert.equal(r.status, 201, JSON.stringify(r.body));
492
+ assert.ok(goalReads.length > 0, 'the route read the goal');
493
+ for (const g of goalReads) {
494
+ assert.ok(g.deps && g.deps.client, 'the goal was read OUTSIDE the transaction');
495
+ assert.equal(g.deps.forUpdate, true, 'the goal was read without FOR UPDATE');
496
+ }
497
+ });
498
+
499
+ await test('CREATE: the INSERT runs ON the transaction client (task 1003840)', async () => {
500
+ actor = ARCHON;
501
+ await post(V1, { criterion_id: 'C9', criterion_md: 'x', goal_id: 3 });
502
+ const inserts = sqlLog.filter((q) => /INSERT INTO done_when_criteria/.test(q.sql));
503
+ assert.equal(inserts.length, 1, 'exactly one INSERT');
504
+ assert.equal(inserts[0].via, 'client', 'the INSERT must not leave the transaction');
505
+ // Its sort_order read is part of the SAME decision and must share the client,
506
+ // or two concurrent creates can read the same next order.
507
+ const maxRead = sqlLog.find((q) => /COALESCE\(MAX\(sort_order\)/.test(q.sql));
508
+ assert.ok(maxRead, 'the sort_order read happened');
509
+ assert.equal(maxRead.via, 'client', 'the read that decides sort_order shares the transaction');
510
+ });
511
+
512
+ await test('CREATE: a refusal writes nothing and releases the client (task 1003840)', async () => {
513
+ actor = METIC;
514
+ goals.set(3, { id: 3, version_id: V1, owner_builder_id: 999, status: 'open' });
515
+ const r = await post(V1, { criterion_id: 'C9', criterion_md: 'x', goal_id: 3 });
516
+ assert.ok(r.status >= 400, `expected a refusal, got ${r.status}`);
517
+ assert.equal(sqlLog.filter((q) => /INSERT INTO done_when_criteria/.test(q.sql)).length, 0, 'nothing was inserted');
518
+ assert.ok(clients.length > 0 && clients.every((c) => c.released), 'the client is released on the refusal path');
519
+ });
520
+
521
+ await test('the VERSION is locked too, not only the goal (task 1003840)', async () => {
522
+ // The task names three facts; the first cut locked one. A plain read is never
523
+ // blocked by another session's row lock under READ COMMITTED, so an unlocked
524
+ // version could still be frozen between this read and this COMMIT.
525
+ actor = ARCHON;
526
+ versionReads.length = 0;
527
+ await post(V1, { criterion_id: 'C9', criterion_md: 'x', goal_id: 3 });
528
+ assert.ok(versionReads.length > 0, 'the route read the version');
529
+ for (const v of versionReads) {
530
+ assert.ok(v.deps && v.deps.client, 'the version was read outside the transaction');
531
+ assert.equal(v.deps.forUpdate, true, 'the version was read without FOR UPDATE');
532
+ }
533
+ });
534
+
535
+ await test('a move locks both goals in ASCENDING ID ORDER, not call order (task 1003840)', async () => {
536
+ // Call order is source-then-destination, so 10->5 and 5->10 running at once
537
+ // would take the two locks in opposite orders and deadlock. One agreed order
538
+ // makes them queue. Asserted for the descending move, where the two differ.
539
+ actor = ARCHON;
540
+ goals.set(10, { id: 10, version_id: V1, owner_builder_id: ARCHON.id, status: 'open' });
541
+ criterion.goal_id = 10;
542
+ goalReads.length = 0;
543
+ const r = await patch({ goal_id: 3 });
544
+ assert.equal(r.status, 200, JSON.stringify(r.body));
545
+ const lockOrder = goalReads.filter((g) => g.deps && g.deps.forUpdate).map((g) => g.id);
546
+ assert.deepEqual(lockOrder, [...lockOrder].sort((a, b) => a - b),
547
+ `goals were locked in ${lockOrder.join(',')} — not ascending id order`);
548
+ assert.deepEqual(lockOrder, [3, 10], 'both ends are locked, lowest id first');
549
+ });
550
+
551
+ await test('setGoalStatus writes on the transaction client (task 1003840)', async () => {
552
+ // The auto-achieve's actual writer. Missed in the first cut, where it kept its
553
+ // own copy of the pool rule — so the UPDATE ran on the pool while the route
554
+ // held that same row FOR UPDATE on its client: a self-deadlock against real
555
+ // Postgres, not merely an untransacted write.
556
+ const realGoals = require('../modules/lifecycle/db-goals.js');
557
+ const seen = [];
558
+ const fakeClient = { query: async (sql, params) => { seen.push({ sql, params }); return { rows: [{ id: 3, status: 'achieved' }] }; } };
559
+ await realGoals.setGoalStatus({ goalId: 3, status: 'achieved' }, { client: fakeClient });
560
+ assert.equal(seen.length, 1, 'the UPDATE reached the injected client');
561
+ assert.match(seen[0].sql, /UPDATE goals/);
562
+ assert.deepEqual(seen[0].params, [3, 'achieved']);
563
+ });
564
+
565
+ // The wrapper installed above is removed, so nothing after this file's own tests
566
+ // inherits a patched module export.
567
+ db.getGoal = realGetGoal;
568
+ db.getVersion = realGetVersion;
569
+
345
570
  await new Promise((r) => server.close(r));
346
571
  console.log(`\ncriterion_goal_attach: ${passed} passed, ${failed} failed`);
347
572
  process.exit(failed === 0 ? 0 : 1);