@bongos/core 1.19.1005 → 1.19.1007

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.
@@ -48,7 +48,7 @@ async function craftsForBuilder(builderId, deps = {}) {
48
48
  return [...new Set(list.map(craftForDiscipline).filter(Boolean))];
49
49
  }
50
50
 
51
- const COLS = 'id, requested_by, needs_craft, needs_builder_id, what_is_stuck, task_id, goal_id, status, created_at, updated_at, settled_by';
51
+ const COLS = 'id, requested_by, needs_craft, needs_builder_id, what_is_stuck, task_id, goal_id, status, created_at, updated_at, settled_by, settled_reason';
52
52
  // The two list reads also NAME the people on a row — who is asking (the fact
53
53
  // the collab page owes a helper: "who to answer", task 1003471) and who a
54
54
  // by-name ask is addressed to — through two LEFT JOINs on builders. COLS stays
@@ -57,10 +57,20 @@ const LIST_COLS = `${COLS.split(', ').map((c) => `h.${c}`).join(', ')}, `
57
57
  + 'a.github_login AS requested_by_login, a.display_name AS requested_by_name, '
58
58
  + 'a.avatar_url AS requested_by_avatar_url, '
59
59
  + 'n.github_login AS needs_builder_login, n.display_name AS needs_builder_name, '
60
- + 'n.avatar_url AS needs_builder_avatar_url';
60
+ + 'n.avatar_url AS needs_builder_avatar_url, '
61
+ // The ARCHIVE's two extra facts (task 1004224). A settled row has to say HOW it
62
+ // ended, and neither half of that is on the row itself: `settled_by` is an id
63
+ // with no name, and `task_id` is a number with no title. Both are on the open
64
+ // reads too rather than on a separate archive-only projection — one projection
65
+ // between the query and the page is the whole reason the open and archived
66
+ // rows can share a renderer.
67
+ + 's.github_login AS settled_by_login, s.display_name AS settled_by_name, '
68
+ + 't.title AS task_title, t.status AS task_status';
61
69
  const LIST_FROM = 'FROM lifecycle_help_requests h '
62
70
  + 'LEFT JOIN builders a ON a.id = h.requested_by '
63
- + 'LEFT JOIN builders n ON n.id = h.needs_builder_id';
71
+ + 'LEFT JOIN builders n ON n.id = h.needs_builder_id '
72
+ + 'LEFT JOIN builders s ON s.id = h.settled_by '
73
+ + 'LEFT JOIN tasks t ON t.id = h.task_id';
64
74
  const MAX_STUCK = 4000;
65
75
  // Defence in depth: both reads are bounded today (open-status churn, per-builder
66
76
  // scope), but an unsettled pile should degrade the page, not the server.
@@ -222,6 +232,91 @@ async function listOpenAll({ limit = MAX_ROWS } = {}, deps = {}) {
222
232
  return rows;
223
233
  }
224
234
 
235
+ // ---- the ledger settles its own asks (task 1004224) -------------------------
236
+ //
237
+ // THE ASK THAT NOBODY NEEDED TO CLOSE. A request naming a task is a question
238
+ // ABOUT that task, and when the task ships the question is over — but until this
239
+ // existed, the row stayed open until its author remembered it. So Collab kept a
240
+ // to-do list of questions the rest of Bongos had already answered.
241
+ //
242
+ // IT TAKES NO ARGUMENTS, AND THAT IS THE SECURITY PROPERTY. The PATCH path's
243
+ // WHERE clause is the entire permission model for this table: who may close what.
244
+ // A settle helper that accepted an id, or a task id, or a builder id would be a
245
+ // second writer of `status` with no such clause — a way to close somebody else's
246
+ // ask by naming it. This one is derivable from ledger state alone: every row it
247
+ // can possibly touch is a row whose named task is already `shipped`, and no
248
+ // caller can make that true by asking. A request body cannot reach it, because
249
+ // there is no parameter for one to reach.
250
+ //
251
+ // ONLY 'shipped'. Not 'completed' and not 'confirmed' — those are stations on the
252
+ // way to landing, and a question closed at 'confirmed' would reopen as a lie if
253
+ // the ship then stranded. Not 'abandoned' either: a task nobody finished is the
254
+ // case where the asker most needs their question still standing.
255
+ //
256
+ // ONLY rows WITH a task. An ask carrying no task_id has no ledger fact to settle
257
+ // it, and inventing one — treating a reply as an answer, say — would close the
258
+ // clarifying question that was asking for more detail. Those still need a person.
259
+ //
260
+ // IDEMPOTENT, and safe to run on every list read: the WHERE matches only rows
261
+ // that are still open, so a second run within the same page load finds nothing.
262
+ async function settleFromLedger(deps = {}) {
263
+ const activePool = deps.pool || pool;
264
+ const { rows } = await activePool.query(
265
+ `UPDATE lifecycle_help_requests h
266
+ SET status = 'answered', settled_reason = 'task_shipped', updated_at = now()
267
+ WHERE h.status = 'open'
268
+ AND h.task_id IS NOT NULL
269
+ AND EXISTS (SELECT 1 FROM tasks t WHERE t.id = h.task_id AND t.status = 'shipped')
270
+ RETURNING h.id, h.task_id`
271
+ );
272
+ return rows;
273
+ }
274
+
275
+ // The ARCHIVE, addressee side: settled asks that were addressed to THIS reader —
276
+ // by name, or through a craft they belong to. The predicate is deliberately the
277
+ // same one listOpenForBuilder uses, inverted on status only.
278
+ //
279
+ // `crafts` comes from the CALLER'S OWN PROFILE and the route must keep it that
280
+ // way. C01's grader found this surface's one real hole: a ?craft= query param
281
+ // validated against the enum but never against the caller, which let any builder
282
+ // read every request addressed to a craft they did not belong to. An archive is a
283
+ // larger prize than an open board — it is the whole history — so the filter here
284
+ // is applied to a craft list the caller cannot choose, and the route derives it
285
+ // from `builders.preferred_disciplines` exactly as the open read does.
286
+ //
287
+ // The author exclusion stays too: your own settled asks are the OTHER list.
288
+ async function listArchivedForBuilder({ builderId, crafts = [], limit = MAX_ROWS }, deps = {}) {
289
+ const activePool = deps.pool || pool;
290
+ const safeCrafts = (Array.isArray(crafts) ? crafts : []).filter((c) => HELP_CRAFTS.includes(c));
291
+ const { rows } = await activePool.query(
292
+ `SELECT ${LIST_COLS} ${LIST_FROM}
293
+ WHERE h.status <> 'open'
294
+ AND h.requested_by <> $1
295
+ AND (h.needs_builder_id = $1 OR h.needs_craft = ANY($2::text[]))
296
+ ORDER BY h.updated_at DESC, h.id DESC
297
+ LIMIT $3`,
298
+ [builderId, safeCrafts, capRows(limit)]
299
+ );
300
+ return rows;
301
+ }
302
+
303
+ // The ARCHIVE, asker side: settled asks this reader made of other people.
304
+ // listMine already filters by one exact status; this asks the other question —
305
+ // "everything that is no longer open" — which an exact-status filter cannot
306
+ // express without the caller enumerating the vocabulary and the two drifting the
307
+ // next time a status is added.
308
+ async function listArchivedMine({ builderId, limit = MAX_ROWS }, deps = {}) {
309
+ const activePool = deps.pool || pool;
310
+ const { rows } = await activePool.query(
311
+ `SELECT ${LIST_COLS} ${LIST_FROM}
312
+ WHERE h.requested_by = $1 AND h.status <> 'open'
313
+ ORDER BY h.updated_at DESC, h.id DESC
314
+ LIMIT $2`,
315
+ [builderId, capRows(limit)]
316
+ );
317
+ return rows;
318
+ }
319
+
225
320
  async function getHelpRequest(id, deps = {}) {
226
321
  const activePool = deps.pool || pool;
227
322
  const { rows } = await activePool.query(`SELECT ${COLS} FROM lifecycle_help_requests WHERE id = $1`, [id]);
@@ -356,6 +451,7 @@ module.exports = {
356
451
  HELP_CRAFTS, helpCrafts, disciplineForCraft, craftForDiscipline, craftsForBuilder,
357
452
  validateHelpRequest, MAX_STUCK, MAX_ROWS, capRows,
358
453
  builderExists, createHelpRequest, listOpenForBuilder, listMine, listOpenAll, getHelpRequest, setHelpRequestStatus,
454
+ settleFromLedger, listArchivedForBuilder, listArchivedMine,
359
455
  countOpenForBuilder, craftMapSql,
360
456
  MAX_REPLY, MAX_THREAD_ON_LIST, validateReply, createReply, listReplies, repliesForRequests,
361
457
  };
@@ -0,0 +1,114 @@
1
+ -- lifecycle_012_collab_self_settling.sql — Collab stops waiting to be told
2
+ -- (task 1004224, goal 1000095; owner direction 2026-09-24).
3
+ --
4
+ -- THE DEFECT. Both collab tables settle only when a HUMAN ticks a row:
5
+ -- lifecycle_004's help request waits for its asker (widened to the addressee by
6
+ -- ADR 0330), and lifecycle_005's recommendation waits for a decline or a
7
+ -- withdrawal. So a request whose SUBJECT is already finished — the task it names
8
+ -- has shipped, the task it recommended has been taken up — sits open forever,
9
+ -- and the person who asked has to go and close a question the ledger answered
10
+ -- days ago. Collab is the one surface in Bongos that could not read the ledger
11
+ -- it lives in.
12
+ --
13
+ -- WHAT THIS MIGRATION ADDS IS A REASON, NOT A MECHANISM. The settling itself is
14
+ -- an UPDATE derived entirely from `tasks` and `claims` (settleFromLedger() in
15
+ -- help-requests.js and task-recommendations.js) — it takes no parameters at all,
16
+ -- precisely so it can never become a second, unguarded way for a caller to close
17
+ -- somebody else's row. The columns here exist so the RECORD can tell the two
18
+ -- kinds of settlement apart.
19
+ --
20
+ -- WHY settled_by IS NOT ENOUGH. `settled_by` is a builder id, and an automatic
21
+ -- close has no builder behind it. Leaving it NULL would make an auto-settled row
22
+ -- indistinguishable from one settled before ADR 0330 added the column — so the
23
+ -- archive would have to say "closed by nobody" about two quite different things.
24
+ -- `settled_reason` names WHICH mechanism ended the row, and NULL still means the
25
+ -- old thing it always meant: a person did it.
26
+ --
27
+ -- WHY 'taken_up' IS NOT 'accepted'. lifecycle_005's header argues at length that
28
+ -- this table must have no 'accepted' status: accepting a recommendation IS
29
+ -- claiming the task, which is the claims table's job, and a status here saying
30
+ -- "accepted" would be a second, weaker record of holding work. Nothing about
31
+ -- that changed. 'taken_up' is not the recipient accepting anything — it is the
32
+ -- ledger OBSERVING, after the fact, that the claim already exists somewhere
33
+ -- else, and closing a recommendation that has nothing left to recommend. The
34
+ -- claims table remains the only record of who holds a task; this status is
35
+ -- derived FROM it and grants nothing. It is also, deliberately, not settable
36
+ -- through any route: the decline and withdraw writes still name their own
37
+ -- statuses explicitly.
38
+ --
39
+ -- MODULE-OWNED and `lifecycle_`-prefixed, so fitness check 11 permits it: both
40
+ -- tables are ones the lifecycle module owns outright (ADR 0083 §Decision #5).
41
+ -- `tasks` and `claims` are core tables this module only READS, here and in the
42
+ -- settle queries — no core table is altered.
43
+
44
+ ALTER TABLE lifecycle_help_requests
45
+ ADD COLUMN IF NOT EXISTS settled_reason text;
46
+
47
+ -- Enumerated rather than free text: the archive renders a different sentence per
48
+ -- reason, and a value nothing renders would show a reader a blank where the
49
+ -- explanation should be. NULL is the open row and the human-settled row alike.
50
+ DO $$
51
+ BEGIN
52
+ IF NOT EXISTS (
53
+ SELECT 1 FROM pg_constraint WHERE conname = 'lifecycle_help_requests_settled_reason_chk'
54
+ ) THEN
55
+ ALTER TABLE lifecycle_help_requests
56
+ ADD CONSTRAINT lifecycle_help_requests_settled_reason_chk
57
+ CHECK (settled_reason IS NULL OR settled_reason IN ('task_shipped'));
58
+ END IF;
59
+ END $$;
60
+
61
+ COMMENT ON COLUMN lifecycle_help_requests.settled_reason IS
62
+ 'Why this ask stopped being open, when it was not a person who closed it. NULL = open, or settled by the builder in settled_by. ''task_shipped'' = the task it named reached shipped and the ledger closed it (task 1004224).';
63
+
64
+ ALTER TABLE lifecycle_task_recommendations
65
+ ADD COLUMN IF NOT EXISTS settled_reason text;
66
+
67
+ DO $$
68
+ BEGIN
69
+ IF NOT EXISTS (
70
+ SELECT 1 FROM pg_constraint WHERE conname = 'lifecycle_task_recommendations_settled_reason_chk'
71
+ ) THEN
72
+ ALTER TABLE lifecycle_task_recommendations
73
+ ADD CONSTRAINT lifecycle_task_recommendations_settled_reason_chk
74
+ CHECK (settled_reason IS NULL OR settled_reason IN ('recipient_took_it_up'));
75
+ END IF;
76
+ END $$;
77
+
78
+ -- The status vocabulary gains 'taken_up'. See the header for why this is not
79
+ -- 'accepted' and why no route may write it.
80
+ ALTER TABLE lifecycle_task_recommendations
81
+ DROP CONSTRAINT IF EXISTS lifecycle_task_recommendations_status_chk;
82
+ ALTER TABLE lifecycle_task_recommendations
83
+ ADD CONSTRAINT lifecycle_task_recommendations_status_chk
84
+ CHECK (status IN ('open', 'declined', 'withdrawn', 'taken_up'));
85
+
86
+ COMMENT ON COLUMN lifecycle_task_recommendations.settled_reason IS
87
+ 'Why this recommendation stopped being open, when neither party acted on it. NULL = open, declined or withdrawn. ''recipient_took_it_up'' = the recipient now holds or shipped the task, so there is nothing left to recommend (task 1004224).';
88
+
89
+ -- The settle reads. Each UPDATE scans for OPEN rows that have a subject to
90
+ -- check, so the partial index is on exactly that predicate: on a project where
91
+ -- nothing has just shipped these queries should touch almost nothing, and they
92
+ -- run on the Collab page's own load path.
93
+ CREATE INDEX IF NOT EXISTS lifecycle_help_requests_open_task_idx
94
+ ON lifecycle_help_requests (task_id) WHERE status = 'open' AND task_id IS NOT NULL;
95
+
96
+ -- `(task_id, recommended_to)` rather than a bare partial index on status: the
97
+ -- UPDATE's outer filter is `status = 'open'` alone, so the two columns do not
98
+ -- narrow the scan — they are there so the per-row subqueries against `claims`
99
+ -- and `tasks` can be satisfied from the index rather than by fetching the heap
100
+ -- row. An index-only scan, not a better filter; do not read more into it.
101
+ CREATE INDEX IF NOT EXISTS lifecycle_task_recommendations_open_task_idx
102
+ ON lifecycle_task_recommendations (task_id, recommended_to) WHERE status = 'open';
103
+
104
+ -- The ARCHIVE reads, which are the mirror image of lifecycle_004's two partial
105
+ -- 'open' indexes: settled asks, addressed to a person or to a craft. Without
106
+ -- these the archive is a sequential scan over the whole history of the table —
107
+ -- the half of it that only ever grows.
108
+ CREATE INDEX IF NOT EXISTS lifecycle_help_requests_settled_person_idx
109
+ ON lifecycle_help_requests (needs_builder_id) WHERE status <> 'open';
110
+ CREATE INDEX IF NOT EXISTS lifecycle_help_requests_settled_craft_idx
111
+ ON lifecycle_help_requests (needs_craft) WHERE status <> 'open';
112
+
113
+ INSERT INTO schema_migrations (version) VALUES ('lifecycle_012_collab_self_settling')
114
+ ON CONFLICT (version) DO NOTHING;
@@ -15,6 +15,7 @@
15
15
  const express = require('express');
16
16
  const api = require('../../../src/module-api');
17
17
  const help = require('../help-requests');
18
+ const settle = require('../collab-settle');
18
19
 
19
20
  const { asyncHandler, validateOrRespond, parseId } = api;
20
21
 
@@ -29,6 +30,41 @@ async function withReplies(rows) {
29
30
  return rows.map((r) => ({ ...r, replies: threads.get(String(r.id)) || [] }));
30
31
  }
31
32
 
33
+ // COLLAB UPDATES ITSELF (task 1004224). Every list read below settles first, so
34
+ // an ask whose task has shipped is already 'answered' by the time the rows come
35
+ // back — nobody has to close a question the ledger answered days ago.
36
+ //
37
+ // IN FRONT OF THE READ, NOT BESIDE IT. The page makes three of these reads in one
38
+ // fan-out and then renders an archive from a fourth. If a row settled between two
39
+ // of them it would leave one block and appear in another in the SAME load — shown
40
+ // twice, counted twice, and the figures at the top of the page lying about both.
41
+ // Settling before each read cannot prevent that on its own, but it makes the
42
+ // window a race between concurrent requests rather than the guaranteed outcome of
43
+ // a single page load: after the first read of a load has settled everything
44
+ // eligible, the later ones find nothing left to settle.
45
+ //
46
+ // help.settleFromLedger TAKES NO ARGUMENTS. Nothing from the request — not the
47
+ // caller, not the query, not the body — can steer which rows it touches; see its
48
+ // own header for why that is the security property and not merely tidiness.
49
+ //
50
+ // THROTTLED TO ONE SETTLE PER SHORT WINDOW (collab-settle.js). Calling it on
51
+ // every read is correct — it is idempotent and bounded — but one Collab page
52
+ // load fans out across four of these routes, and three of those UPDATEs found
53
+ // nothing and wrote nothing. That is wasted write volume on a read path, and
54
+ // avoidable row-lock contention when several builders open Collab at once.
55
+ //
56
+ // Failure is swallowed, but it is LOGGED: a settle that could not run must
57
+ // degrade to "the old rows are still open" rather than to a Collab page that
58
+ // will not load — and a surface that silently stopped settling would look
59
+ // exactly like one with nothing to settle, which is the harder thing to notice.
60
+ async function settleFirst() {
61
+ try {
62
+ await settle.throttled('help_requests', () => help.settleFromLedger());
63
+ } catch (err) {
64
+ console.error('help-requests: settleFromLedger failed; rows stay as they were', err);
65
+ }
66
+ }
67
+
32
68
  module.exports = function buildHelpRequestsRouter() {
33
69
  const router = express.Router();
34
70
 
@@ -83,6 +119,7 @@ module.exports = function buildHelpRequestsRouter() {
83
119
  //
84
120
  // rank: any-builder — you only ever see what is addressed to you.
85
121
  router.get('/help-requests/for-me', api.requireBuilder, asyncHandler('GET /help-requests/for-me', async (req, res) => {
122
+ await settleFirst();
86
123
  // The caller's crafts come from THEIR OWN profile, never from the query — a
87
124
  // ?craft= param validated only against the enum let any builder read requests
88
125
  // addressed to a craft they do not belong to (grader, task 1003171). The param
@@ -96,16 +133,59 @@ module.exports = function buildHelpRequestsRouter() {
96
133
 
97
134
  // rank: any-builder — your own asks, so you can settle them.
98
135
  router.get('/help-requests/mine', api.requireBuilder, asyncHandler('GET /help-requests/mine', async (req, res) => {
136
+ await settleFirst();
99
137
  const status = typeof req.query.status === 'string' ? req.query.status : null;
100
138
  const rows = await withReplies(await help.listMine({ builderId: req.builder.id, status }));
101
139
  return res.json({ help_requests: rows, count: rows.length });
102
140
  }, { errorCode: 'help_request_list_failed' }));
103
141
 
142
+
143
+ // THE ARCHIVE (task 1004224, owner direction): what a settled ask becomes.
144
+ //
145
+ // Until now a settled ask simply vanished from Collab. That was tolerable while
146
+ // settling meant a person had decided the ask was done — they knew. It stops
147
+ // being tolerable the moment the LEDGER settles asks on its own: a row can now
148
+ // leave the page without anybody having watched it go, and a surface that
149
+ // silently disappears its own history is asking to be distrusted.
150
+ //
151
+ // TWO LISTS, NOT ONE, and the split is the owner's: asks made OF YOU and asks
152
+ // you made OF OTHERS are different questions with different follow-ups, and a
153
+ // merged list would need a per-row label to say which it was — which is the
154
+ // same information, said worse.
155
+ //
156
+ // ONE ROUTE FOR BOTH. They share a settle, a bound and a shape, and the page
157
+ // draws them side by side in one section; two routes would be two round trips
158
+ // for one collapsed block and two places for the projection to drift.
159
+ //
160
+ // rank: any-builder, and the scoping matches the open reads exactly — you see
161
+ // what was addressed to you and what you asked, and nothing else. `crafts`
162
+ // comes from `builders.preferred_disciplines` through craftsForBuilder, NEVER
163
+ // from the query string. That is not caution: a ?craft= param validated only
164
+ // against the enum is precisely the hole C01's grader found on the open read,
165
+ // and an archive is the larger prize because it is the whole history rather
166
+ // than today's board. This route accepts no craft, no builder and no status
167
+ // from the caller, so there is nothing here to narrow or widen.
168
+ router.get('/help-requests/archive', api.requireBuilder, asyncHandler('GET /help-requests/archive', async (req, res) => {
169
+ await settleFirst();
170
+ const crafts = await help.craftsForBuilder(req.builder.id);
171
+ const [askedOfYou, askedOfOthers] = await Promise.all([
172
+ help.listArchivedForBuilder({ builderId: req.builder.id, crafts }),
173
+ help.listArchivedMine({ builderId: req.builder.id }),
174
+ ]);
175
+ return res.json({
176
+ asked_of_you: askedOfYou,
177
+ asked_of_others: askedOfOthers,
178
+ count: askedOfYou.length + askedOfOthers.length,
179
+ crafts,
180
+ });
181
+ }, { errorCode: 'help_request_list_failed' }));
182
+
104
183
  // The BOARD read (task 1003942): every open ask in the project, newest first,
105
184
  // for the Collab page's "all open asks" block. rank: any-builder — an ask is
106
185
  // addressed inside this project and every reader here is a member; the route
107
186
  // takes no craft and no builder from the caller, so there is nothing to widen.
108
187
  router.get('/help-requests', api.requireBuilder, asyncHandler('GET /help-requests', async (req, res) => {
188
+ await settleFirst();
109
189
  const rows = await withReplies(await help.listOpenAll());
110
190
  return res.json({ help_requests: rows, count: rows.length });
111
191
  }, { errorCode: 'help_request_list_failed' }));
@@ -27,6 +27,22 @@
27
27
  const express = require('express');
28
28
  const api = require('../../../src/module-api');
29
29
  const recs = require('../task-recommendations');
30
+ const settle = require('../collab-settle');
31
+
32
+ // COLLAB UPDATES ITSELF (task 1004224), the recommendations half. A recommendation
33
+ // whose recipient now holds or has shipped the task has already succeeded, so the
34
+ // read settles before it lists and the sender stops seeing 'open' about something
35
+ // that is done. recs.settleFromLedger TAKES NO ARGUMENTS — see its own header for
36
+ // why that is what stops it becoming a second, unguarded writer of the status
37
+ // column. Failures are swallowed: the rows stay as they were rather than taking a
38
+ // page down.
39
+ async function settleFirst() {
40
+ try {
41
+ await settle.throttled('task_recommendations', () => recs.settleFromLedger());
42
+ } catch (err) {
43
+ console.error('recommendations: settleFromLedger failed; rows stay as they were', err);
44
+ }
45
+ }
30
46
 
31
47
  const { asyncHandler, validateOrRespond, parseId } = api;
32
48
 
@@ -87,6 +103,7 @@ module.exports = function buildTaskRecommendationsRouter() {
87
103
  // holds the task. It carries the task's own status so the recipient can judge,
88
104
  // and that is all. Claiming is a separate, deliberate act on their part.
89
105
  router.get('/task-recommendations/for-me', api.requireBuilder, asyncHandler('GET /task-recommendations/for-me', async (req, res) => {
106
+ await settleFirst();
90
107
  const rows = await recs.listForRecipient({ builderId: req.builder.id });
91
108
  return res.json({ recommendations: rows, count: rows.length });
92
109
  }, { errorCode: 'recommendation_list_failed' }));
@@ -96,6 +113,7 @@ module.exports = function buildTaskRecommendationsRouter() {
96
113
  // "the recommendation and its author are readable afterwards".
97
114
  router.get('/task-recommendations/mine', api.requireBuilder, asyncHandler('GET /task-recommendations/mine', async (req, res) => {
98
115
  const status = typeof req.query.status === 'string' ? req.query.status : null;
116
+ await settleFirst();
99
117
  const rows = await recs.listFromSender({ builderId: req.builder.id, status });
100
118
  return res.json({ recommendations: rows, count: rows.length });
101
119
  }, { errorCode: 'recommendation_list_failed' }));
@@ -111,6 +129,7 @@ module.exports = function buildTaskRecommendationsRouter() {
111
129
  router.get('/tasks/:id/recommendations', api.requireBuilder, asyncHandler('GET /tasks/:id/recommendations', async (req, res) => {
112
130
  const id = parseId(req, res, { code: 'bad_task_id', positiveInt: true });
113
131
  if (id === null) return;
132
+ await settleFirst();
114
133
  const rows = await recs.listForTask({ taskId: id });
115
134
  return res.json({ recommendations: rows, count: rows.length });
116
135
  }, { errorCode: 'recommendation_list_failed' }));
@@ -32,7 +32,7 @@
32
32
 
33
33
  const { pool } = require('../../src/module-api');
34
34
 
35
- const COLS = 'id, task_id, recommended_by, recommended_to, reason, status, created_at, updated_at';
35
+ const COLS = 'id, task_id, recommended_by, recommended_to, reason, status, created_at, updated_at, settled_reason';
36
36
 
37
37
  const MAX_REASON = 2000;
38
38
  // Defence in depth, matching help-requests: both reads are bounded today (open
@@ -125,7 +125,7 @@ async function listForRecipient({ builderId, limit = MAX_ROWS }, deps = {}) {
125
125
  const activePool = deps.pool || pool;
126
126
  const { rows } = await activePool.query(
127
127
  `SELECT r.id, r.task_id, r.recommended_by, r.recommended_to, r.reason,
128
- r.status, r.created_at, r.updated_at,
128
+ r.status, r.settled_reason, r.created_at, r.updated_at,
129
129
  t.title AS task_title,
130
130
  t.status AS task_status,
131
131
  b.github_login AS recommended_by_login,
@@ -148,7 +148,7 @@ async function listFromSender({ builderId, status = null, limit = MAX_ROWS }, de
148
148
  const activePool = deps.pool || pool;
149
149
  const { rows } = await activePool.query(
150
150
  `SELECT r.id, r.task_id, r.recommended_by, r.recommended_to, r.reason,
151
- r.status, r.created_at, r.updated_at,
151
+ r.status, r.settled_reason, r.created_at, r.updated_at,
152
152
  t.title AS task_title,
153
153
  t.status AS task_status,
154
154
  b.github_login AS recommended_to_login,
@@ -173,7 +173,7 @@ async function listForTask({ taskId, limit = MAX_ROWS }, deps = {}) {
173
173
  const activePool = deps.pool || pool;
174
174
  const { rows } = await activePool.query(
175
175
  `SELECT r.id, r.task_id, r.recommended_by, r.recommended_to, r.reason,
176
- r.status, r.created_at, r.updated_at,
176
+ r.status, r.settled_reason, r.created_at, r.updated_at,
177
177
  sender.github_login AS recommended_by_login,
178
178
  sender.display_name AS recommended_by_name,
179
179
  recipient.github_login AS recommended_to_login,
@@ -189,6 +189,72 @@ async function listForTask({ taskId, limit = MAX_ROWS }, deps = {}) {
189
189
  return rows;
190
190
  }
191
191
 
192
+ // ---- the ledger settles its own recommendations (task 1004224) --------------
193
+ //
194
+ // A recommendation says "you should look at this task". When the person it was
195
+ // sent to is holding that task, or has shipped it, there is nothing left to
196
+ // recommend — and yet the row stayed open until somebody declined or withdrew
197
+ // it, which are both the wrong word for what happened. The sender's own list
198
+ // reported "open" about a recommendation that had entirely succeeded.
199
+ //
200
+ // 'taken_up' IS NOT 'accepted', AND THE DISTINCTION IS THE WHOLE POINT. This
201
+ // module's header argues that accepting a recommendation IS claiming the task,
202
+ // so a status here meaning "accepted" would be a second, weaker record of
203
+ // holding work and would make this table look like a place claims come from.
204
+ // Nothing about that changed: 'taken_up' grants nothing, transfers nothing and
205
+ // is written by nobody. It is this table READING the claims table after the fact
206
+ // and admitting the question is closed. Who holds the task is still answered by
207
+ // joining claims — never by this column.
208
+ //
209
+ // IT TAKES NO ARGUMENTS, for the same reason settleFromLedger in help-requests.js
210
+ // takes none: declineRecommendation and withdrawRecommendation enforce who may
211
+ // settle in their WHERE clauses, and a helper that closed "the row for task N"
212
+ // on request would be a third writer of `status` with no such clause. Every row
213
+ // this can touch is one whose own recipient already holds or shipped its task,
214
+ // and no caller can make that true by asking.
215
+ //
216
+ // NOT 'the task shipped' ALONE — shipped BY THE RECIPIENT, or held by them right
217
+ // now. A task somebody ELSE shipped is the case where the recommendation was
218
+ // never taken up at all, and closing it as 'taken_up' would put a success in the
219
+ // sender's ledger that did not happen.
220
+ //
221
+ // A RELEASED CLAIM DOES NOT COUNT, and that is a correction rather than a
222
+ // simplification. The first version accepted any claim ever made, reasoning that
223
+ // looking at the task is what a recommendation asks for — but a builder who
224
+ // claimed a task and handed it back has arrived at the same place as one who
225
+ // declined, and recording that as 'taken_up' is the same class of mistake as
226
+ // closing on somebody else's ship. The two branches still cover the whole
227
+ // success path: a live claim catches work in progress, and `shipped_by` catches
228
+ // work finished (whose claim is released by then, with outcome 'shipped').
229
+ //
230
+ // It is also the indexable shape, which matters because this runs on a read
231
+ // path: `uniq_active_claim_per_task` is a partial index on `claims (task_id)
232
+ // WHERE released_at IS NULL`, so the live-claim predicate can use it. The
233
+ // any-claim version had no index to reach for — `claims` carries none on
234
+ // `task_id` unpartialled — and this module may not add one, because `claims` is
235
+ // a core table it only reads (ADR 0083 §Decision #5). So each open recommendation
236
+ // would have degraded toward a scan of a table that only grows.
237
+ //
238
+ // IDEMPOTENT, and safe on every list read: only open rows match.
239
+ async function settleFromLedger(deps = {}) {
240
+ const activePool = deps.pool || pool;
241
+ const { rows } = await activePool.query(
242
+ `UPDATE lifecycle_task_recommendations r
243
+ SET status = 'taken_up', settled_reason = 'recipient_took_it_up', updated_at = now()
244
+ WHERE r.status = 'open'
245
+ AND (
246
+ EXISTS (SELECT 1 FROM claims c
247
+ WHERE c.task_id = r.task_id AND c.builder_id = r.recommended_to
248
+ AND c.released_at IS NULL)
249
+ OR EXISTS (SELECT 1 FROM tasks t
250
+ WHERE t.id = r.task_id AND t.status = 'shipped'
251
+ AND t.shipped_by = r.recommended_to)
252
+ )
253
+ RETURNING r.id, r.task_id, r.recommended_to`
254
+ );
255
+ return rows;
256
+ }
257
+
192
258
  // The recipient declines. Costs nothing — see the header: this sets a status and
193
259
  // returns the row, and writes nothing anywhere else about either party.
194
260
  //
@@ -241,6 +307,7 @@ module.exports = {
241
307
  validateRecommendation,
242
308
  builderExists, taskExists,
243
309
  createRecommendation,
310
+ settleFromLedger,
244
311
  listForRecipient, listFromSender, listForTask,
245
312
  declineRecommendation, withdrawRecommendation,
246
313
  countOpenForRecipient,
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1005",
3
+ "version": "1.19.1007",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.1005",
9
+ "version": "1.19.1007",
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.1005",
3
+ "version": "1.19.1007",
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.1005'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.1007'; // 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');