@bongos/core 1.19.998 → 1.19.1000

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.
Files changed (86) hide show
  1. package/.bongos-core.json +105 -90
  2. package/.claude/skills/collab-review/SKILL.md +24 -13
  3. package/clients/bongos-client/README.md +1 -1
  4. package/clients/bongos-client/bongos-client.global.js +4 -0
  5. package/clients/bongos-client/index.cjs +4 -0
  6. package/clients/bongos-client/index.d.ts +7 -0
  7. package/clients/bongos-client/index.mjs +4 -0
  8. package/docs/adr/0330-an-ask-is-answered-where-it-was-asked.md +155 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +154 -4
  11. package/docs/api-reference.md +5 -3
  12. package/docs/copy-inventory.md +18 -13
  13. package/docs/copy-registry.json +70 -15
  14. package/docs/module-api-changelog.md +4 -0
  15. package/modules/hall-ui/public/collab-lib.js +79 -0
  16. package/modules/hall-ui/public/collab.css +60 -0
  17. package/modules/hall-ui/public/collab.js +134 -5
  18. package/modules/lifecycle/help-requests.js +118 -8
  19. package/modules/lifecycle/migrations/lifecycle_011_help_request_replies.sql +113 -0
  20. package/modules/lifecycle/routes/help-requests.js +93 -13
  21. package/modules/provisioning/capacity.js +3 -3
  22. package/modules/provisioning/control-plane-guard.js +1 -1
  23. package/modules/provisioning/core-upgrade.js +7 -7
  24. package/modules/provisioning/migrations/provisioning_027_cloud_host_shape_rename.sql +58 -0
  25. package/modules/provisioning/onboard-plan.js +1 -1
  26. package/modules/provisioning/paid-shape-gate.js +28 -15
  27. package/modules/provisioning/provisioning.js +18 -14
  28. package/modules/provisioning/routes/repo-private.js +1 -1
  29. package/modules/provisioning/tests/liveness.mjs +16 -8
  30. package/modules/provisioning/tests/provisioning.mjs +1 -1
  31. package/modules/public-landing/public/projects.html +26 -27
  32. package/modules/ui-design/kit/fixtures/provisioning-instances.json +1 -1
  33. package/package-lock.json +2 -2
  34. package/package.json +1 -1
  35. package/scripts/gds/init.js +12 -4
  36. package/scripts/gds/onboard.js +1 -1
  37. package/scripts/gds/provision-capacity.js +6 -1
  38. package/scripts/gds/provision-config.js +6 -3
  39. package/scripts/gds/provision-core-upgrade.js +8 -8
  40. package/scripts/gds/provision-disconnect.js +2 -2
  41. package/scripts/gds/provision-process.js +2 -2
  42. package/scripts/gds/provision-repo-private.js +1 -1
  43. package/scripts/gds/provision-repo.js +13 -3
  44. package/scripts/gds/provision-teardown.js +2 -2
  45. package/scripts/gds/provision-units.js +2 -2
  46. package/scripts/gds/provision.js +6 -6
  47. package/src/module-api.js +1 -1
  48. package/tests/collab_page.mjs +140 -7
  49. package/tests/core_upgrade_door.mjs +1 -1
  50. package/tests/core_upgrade_runner.mjs +20 -20
  51. package/tests/deploy_page_projects.mjs +5 -5
  52. package/tests/hall_collab_world.mjs +6 -2
  53. package/tests/help_requests.mjs +201 -6
  54. package/tests/hub_disconnect_ui.mjs +4 -1
  55. package/tests/init.mjs +6 -6
  56. package/tests/onboard.mjs +5 -4
  57. package/tests/project_catalog_backfill.mjs +1 -1
  58. package/tests/project_catalog_bridge.mjs +2 -2
  59. package/tests/project_catalog_routes.mjs +4 -4
  60. package/tests/projects_hub.mjs +1 -1
  61. package/tests/projects_hub_pre_uat.mjs +22 -5
  62. package/tests/projects_hub_repo_private.mjs +1 -1
  63. package/tests/provision.mjs +32 -32
  64. package/tests/provision_byo_host.mjs +1 -1
  65. package/tests/provision_caddy_wiring.mjs +1 -1
  66. package/tests/provision_disconnect.mjs +3 -3
  67. package/tests/provision_repo_private.mjs +1 -1
  68. package/tests/provision_restart.mjs +5 -5
  69. package/tests/provision_settings_apply.mjs +4 -4
  70. package/tests/provision_teardown_address.mjs +2 -2
  71. package/tests/provisioning_capacity.mjs +40 -34
  72. package/tests/provisioning_capacity_gate.mjs +5 -12
  73. package/tests/provisioning_default_domain.mjs +1 -1
  74. package/tests/provisioning_domain_attach.mjs +4 -4
  75. package/tests/provisioning_domain_unique.mjs +2 -2
  76. package/tests/provisioning_error_note_scope.mjs +1 -1
  77. package/tests/provisioning_module_selection.mjs +5 -5
  78. package/tests/provisioning_onboard_mode.mjs +2 -2
  79. package/tests/provisioning_paid_shape_gate.mjs +57 -44
  80. package/tests/provisioning_repo_private_route.mjs +1 -1
  81. package/tests/provisioning_restart.mjs +11 -4
  82. package/tests/provisioning_settings_apply.mjs +12 -5
  83. package/tests/provisioning_settings_env.mjs +1 -1
  84. package/tests/provisioning_teardown_intent.mjs +3 -3
  85. package/tests/public_landing_projects.mjs +13 -6
  86. package/tests/upgrade.mjs +2 -2
@@ -57,6 +57,13 @@
57
57
  // blocks and the API sends ids as strings.
58
58
  const askById = new Map();
59
59
  let forMeRows = [];
60
+ // Who is reading, and which crafts they answer for — the two facts L.canClose
61
+ // needs. Read from /me on load, never from a query param: the whole reason the
62
+ // addressed read takes its crafts from the profile is that a caller-supplied
63
+ // craft was this surface's privacy hole.
64
+ let meId = null;
65
+ let myCrafts = [];
66
+ let replying = false;
60
67
 
61
68
  // The hall answers on two shapes — /builders on the site, or builders.<domain>
62
69
  // as its own host. A pasted prompt is opened somewhere that is not this page,
@@ -125,6 +132,44 @@
125
132
  </div>${full}`;
126
133
  }
127
134
 
135
+ // ---- the thread (task 1004216, ADR 0330) -----------------------------------
136
+ //
137
+ // An ask can be ANSWERED on the page now. What this draws is deliberately thin:
138
+ // who said it, when, and what they said, oldest first — no avatars on replies
139
+ // (the row already leads with a face and a second column of them would read as
140
+ // a chat), no edit, no delete, no reactions, no threading of replies to
141
+ // replies. The restraint is the argument: this is the answer surface ADR 0330
142
+ // opens, not the chat surface ADR 0187 §4a declined.
143
+ function replyHtml(reply, nowMs) {
144
+ return `<li class="cb-reply">
145
+ <span class="cb-reply__who">${escapeHtml(L.replyAuthor(reply))}</span>
146
+ <span class="cb-reply__when">${escapeHtml(L.saidLabel(reply.created_at, nowMs))}</span>
147
+ <p class="cb-reply__body">${escapeHtml(String(reply.body == null ? '' : reply.body))}</p>
148
+ </li>`;
149
+ }
150
+
151
+ // The composer is CLOSED until asked for. A textarea open on every row would
152
+ // turn a page you read into a page you are being asked to type on, and the
153
+ // blocks are condensed to three rows precisely so the page stays readable.
154
+ function threadHtml(req, key, nowMs) {
155
+ const thread = L.threadOf(req);
156
+ const id = escapeHtml(String(req.id));
157
+ const fid = `cb-reply-${escapeHtml(key)}-${id}`;
158
+ const list = thread.length
159
+ ? `<ul class="cb-thread">${thread.map((r) => replyHtml(r, nowMs)).join('')}</ul>`
160
+ : '';
161
+ return `${list}<div class="cb-reply-bar">
162
+ <button class="cb-ask__door" type="button" data-reply-open="${fid}">${thread.length ? 'Add a reply' : 'Reply'}</button>
163
+ </div>
164
+ <form class="cb-reply-form" id="${fid}" data-reply-form="${id}" hidden>
165
+ <textarea class="cb-reply-input" name="body" rows="3" maxlength="${L.MAX_REPLY}" placeholder="Moot — the metrics stack was removed on the 15th, so there is nothing left to rotate."></textarea>
166
+ <div class="cb-reply-actions">
167
+ <button class="btn-accent" type="submit" data-reply-send="${id}">Post reply</button>
168
+ <button class="btn-ghost" type="button" data-reply-cancel="${fid}">Cancel</button>
169
+ </div>
170
+ </form>`;
171
+ }
172
+
128
173
  function contextChip(req) {
129
174
  const ctx = L.contextOf(req);
130
175
  if (!ctx) return '';
@@ -208,12 +253,22 @@
208
253
  // call on this reader, so its row is the LIT one (.cb-row--live).
209
254
  function forMeRow(req, nowMs, extra, key, baseline) {
210
255
  const live = req.needs_builder_id != null ? ' cb-row--live' : '';
256
+ // CLOSING IS THE HALF THIS BLOCK NEVER HAD (task 1004216). Until now the only
257
+ // write on a row addressed to you was copying it; the person being asked
258
+ // could answer nowhere and close nothing, so a dealt-with ask stayed open
259
+ // until its author happened to remember it. L.canClose decides whether to
260
+ // draw it and the server's own WHERE decides whether it works — so the worst
261
+ // a wrong answer here can do is offer a button that refuses.
262
+ const close = L.canClose(req, meId, myCrafts)
263
+ ? `<div class="ov-row__actions"><button class="btn-ghost" data-settle="answered" data-id="${escapeHtml(String(req.id))}">Close this ask</button></div>`
264
+ : '';
211
265
  return `<li class="ov-row ov-row--lead cb-row${live}" data-id="${escapeHtml(String(req.id))}"${extra ? ' data-extra hidden' : ''}>
212
266
  ${faceHtml(L.faceOf(req, 'in'))}
213
267
  <div class="ov-row__main">${askHtml(req, key)}
214
268
  <div class="ov-row__facts">${directionFact(req, 'in')}${contextChip(req)}</div>
269
+ ${threadHtml(req, key, nowMs)}
215
270
  </div>
216
- <div class="ov-row__right">${ageChip(req, nowMs, baseline)}</div>
271
+ <div class="ov-row__right">${ageChip(req, nowMs, baseline)}${close}</div>
217
272
  </li>`;
218
273
  }
219
274
 
@@ -235,6 +290,7 @@
235
290
  ${faceHtml(L.faceOf(req, 'out'))}
236
291
  <div class="ov-row__main">${askHtml(req, key)}
237
292
  <div class="ov-row__facts">${directionFact(req, 'out')}${contextChip(req)}${ageChip(req, nowMs, baseline)}</div>
293
+ ${threadHtml(req, key, nowMs)}
238
294
  </div>
239
295
  <div class="ov-row__right">
240
296
  <div class="ov-row__actions">
@@ -252,6 +308,7 @@
252
308
  ${faceHtml(L.faceOf(req, 'in'))}
253
309
  <div class="ov-row__main">${askHtml(req, key)}
254
310
  <div class="ov-row__facts">${boardFact(req)}${contextChip(req)}</div>
311
+ ${threadHtml(req, key, nowMs)}
255
312
  </div>
256
313
  <div class="ov-row__right">${ageChip(req, nowMs, baseline)}</div>
257
314
  </li>`;
@@ -434,17 +491,28 @@
434
491
 
435
492
  async function load() {
436
493
  const nowMs = Date.now();
437
- const [forMe, mine, all] = await Promise.all([
494
+ const [forMe, mine, all, me] = await Promise.all([
438
495
  api.helpRequests.getHelpRequestsForMe({}),
439
496
  api.helpRequests.getHelpRequestsMine({ query: { status: 'open' } }),
440
497
  // The board read (task 1003942) — GET /help-requests, every open ask in the
441
498
  // project. Through api.request because the generated client predates it.
442
499
  api.request('GET', '/help-requests'),
500
+ // WHO IS READING (task 1004216), IN the fan-out rather than in front of it.
501
+ // The close control needs the viewer's id, and a serialized await here
502
+ // would put a round trip in front of every other read on the page.
503
+ api.me.getMe({}),
443
504
  ]);
444
505
 
445
506
  const rowsOf = (r) => ((r && r.ok && r.data && r.data.help_requests) || []);
446
507
  const crafts = (forMe.ok && forMe.data && forMe.data.crafts) || [];
447
508
 
509
+ // FAILS CLOSED. A /me that errored leaves meId null, L.canClose says no, and
510
+ // the page simply draws no close control — the same shape a reader with no
511
+ // right to close sees. Guessing an id from any other response would offer a
512
+ // button the server then refuses.
513
+ meId = (me && me.ok && me.data && me.data.builder && me.data.builder.id) || null;
514
+ myCrafts = crafts;
515
+
448
516
  // Re-indexed on every load, not merged into: a request settled elsewhere
449
517
  // must disappear from the copy path at the same moment it disappears from
450
518
  // the rows, or a stale entry would keep handing a session work that is done.
@@ -548,8 +616,11 @@
548
616
 
549
617
  const r = await api.request('GET', '/mingle');
550
618
  const pairs = (r.ok && r.data && r.data.open_pairs) || [];
551
- const meId = (r.ok && r.data && r.data.you) || null;
552
- paint('#cb-mingle', '#cb-mingle-empty', pairs.map((p) => mingleRow(p, meId)).join(''), pairs.length);
619
+ // Named apart from the module-level `meId` on purpose: mingle asks the mingle
620
+ // route who you are, and two different reads answering to one name in one
621
+ // file is how a shadowed binding becomes a bug nobody can see.
622
+ const mingleMe = (r.ok && r.data && r.data.you) || null;
623
+ paint('#cb-mingle', '#cb-mingle-empty', pairs.map((p) => mingleRow(p, mingleMe)).join(''), pairs.length);
553
624
  }
554
625
 
555
626
  async function decideMingle(id, decision) {
@@ -571,6 +642,49 @@
571
642
  }
572
643
  }
573
644
 
645
+ // Post a reply. One in flight at a time (the `replying` latch), the same guard
646
+ // `settling` gives the settle actions: a double-submit on an APPEND-ONLY table
647
+ // writes the answer twice and there is no edit or delete to take it back with.
648
+ async function postReply(form) {
649
+ if (replying) return;
650
+ const id = form.getAttribute('data-reply-form');
651
+ const input = form.querySelector('.cb-reply-input');
652
+ const v = L.validateReplyBody(input && input.value);
653
+ if (!v.ok) {
654
+ // Refused here rather than at the server, so an empty reply costs no round
655
+ // trip and the message names the field the reader is looking at.
656
+ toast(v.reason === 'empty' ? 'Write something first.' : `A reply is at most ${L.MAX_REPLY} characters.`);
657
+ if (input) input.focus();
658
+ return;
659
+ }
660
+ replying = true;
661
+ const send = form.querySelector('[data-reply-send]');
662
+ if (send) { send.disabled = true; send.textContent = 'Posting…'; }
663
+ try {
664
+ const r = await api.request('POST', `/help-requests/${id}/replies`, { body: v.body });
665
+ if (!r.ok) {
666
+ toast(window.OTB.errMessage ? window.OTB.errMessage(r.data) : 'Could not post that reply.');
667
+ return;
668
+ }
669
+ if (input) input.value = '';
670
+ toast('Replied.');
671
+ await load();
672
+ } finally {
673
+ replying = false;
674
+ if (send) { send.disabled = false; send.textContent = 'Post reply'; }
675
+ }
676
+ }
677
+
678
+ function toggleReply(fid, open) {
679
+ const form = document.getElementById(fid);
680
+ if (!form) return;
681
+ form.hidden = !open;
682
+ if (open) {
683
+ const input = form.querySelector('.cb-reply-input');
684
+ if (input) input.focus();
685
+ }
686
+ }
687
+
574
688
  async function settle(id, status) {
575
689
  if (settling) return;
576
690
  settling = true;
@@ -580,7 +694,7 @@
580
694
  toast(window.OTB.errMessage ? window.OTB.errMessage(r.data) : 'Could not update that request.');
581
695
  return;
582
696
  }
583
- toast(status === 'answered' ? 'Marked answered.' : 'Withdrawn.');
697
+ toast(status === 'answered' ? 'Closed.' : 'Withdrawn.');
584
698
  await load();
585
699
  } finally {
586
700
  settling = false;
@@ -597,6 +711,10 @@
597
711
  if (copyBtn) { ev.preventDefault(); copyAsk(copyBtn); return; }
598
712
  const copyAll = closest('#cb-for-me-copy');
599
713
  if (copyAll) { ev.preventDefault(); copyQueue(); return; }
714
+ const replyOpen = closest('[data-reply-open]');
715
+ if (replyOpen) { ev.preventDefault(); toggleReply(replyOpen.getAttribute('data-reply-open'), true); return; }
716
+ const replyCancel = closest('[data-reply-cancel]');
717
+ if (replyCancel) { ev.preventDefault(); toggleReply(replyCancel.getAttribute('data-reply-cancel'), false); return; }
600
718
  const mingleBtn = closest('[data-mingle]');
601
719
  if (mingleBtn) {
602
720
  ev.preventDefault();
@@ -609,6 +727,17 @@
609
727
  settle(Number(btn.getAttribute('data-id')), btn.getAttribute('data-settle'));
610
728
  });
611
729
 
730
+ // A form, not a click handler on the button: submit is what Enter does in a
731
+ // textarea's form and what a screen reader announces, and a control that only
732
+ // answered a mouse would be a reply surface you could not reach from a
733
+ // keyboard.
734
+ document.addEventListener('submit', (ev) => {
735
+ const form = ev.target && ev.target.closest ? ev.target.closest('[data-reply-form]') : null;
736
+ if (!form) return;
737
+ ev.preventDefault();
738
+ postReply(form);
739
+ });
740
+
612
741
  load().catch(() => {
613
742
  const sub = $('#cb-sub');
614
743
  // V6: a message that stops someone points at the next move in the same breath.
@@ -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';
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';
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
@@ -228,24 +228,134 @@ async function getHelpRequest(id, deps = {}) {
228
228
  return rows[0] || null;
229
229
  }
230
230
 
231
- // Only the ASKER settles their own request. Answering is not a claim on anyone —
232
- // nobody is assigned by being asked, which is the same consent posture passing a
233
- // task takes (ADR 0187 §3).
234
- async function setHelpRequestStatus({ id, builderId, status }, deps = {}) {
231
+ // THE ASKER OR THE ADDRESSEE settles the request (ADR 0330, widening the
232
+ // asker-only rule lifecycle_004 shipped with).
233
+ //
234
+ // Asker-only was coherent while there was nothing to say on the row: the only
235
+ // person who could know an ask had been answered was the person who had gone and
236
+ // got the answer somewhere else. Replies change that — the answer is now ON the
237
+ // ask, and a request addressed to someone who has just answered it in front of
238
+ // everyone and still cannot close it is a surface arguing with itself. So the
239
+ // person a request NAMES may close it too, by id or through a craft they belong
240
+ // to, and `settled_by` records which of them did.
241
+ //
242
+ // WHAT DOES NOT WIDEN: 'withdrawn' stays the ASKER'S ALONE. Withdrawing means "I
243
+ // no longer need this", which is a statement only the person who needed it can
244
+ // make; letting an addressee withdraw would hand them a way to make an ask
245
+ // disappear without ever answering it. An addressee who wants rid of an ask
246
+ // answers it and closes it, in the open, with their name on both.
247
+ //
248
+ // Consent is untouched. Being asked still assigns nobody anything (ADR 0187 §3) —
249
+ // closing an ask is not accepting work, it is ending a question.
250
+ async function setHelpRequestStatus({ id, builderId, status, crafts = [] }, deps = {}) {
235
251
  const activePool = deps.pool || pool;
252
+ const safeCrafts = (Array.isArray(crafts) ? crafts : []).filter((c) => HELP_CRAFTS.includes(c));
253
+ const askerOnly = status === 'withdrawn';
236
254
  const { rows } = await activePool.query(
237
255
  `UPDATE lifecycle_help_requests
238
- SET status = $3, updated_at = now()
239
- WHERE id = $1 AND requested_by = $2 AND status = 'open'
256
+ SET status = $3, updated_at = now(), settled_by = $2
257
+ WHERE id = $1 AND status = 'open'
258
+ AND (requested_by = $2
259
+ OR ($4::boolean IS FALSE
260
+ AND (needs_builder_id = $2 OR needs_craft = ANY($5::text[]))))
240
261
  RETURNING ${COLS}`,
241
- [id, builderId, status]
262
+ [id, builderId, status, askerOnly, safeCrafts]
242
263
  );
243
264
  return rows[0] || null;
244
265
  }
245
266
 
267
+ // ---- replies (task 1004216) -------------------------------------------------
268
+ //
269
+ // APPEND-ONLY, and the table's own trigger is the authority — there is deliberately
270
+ // no updateReply and no deleteReply here, and adding one would not work if you
271
+ // tried. A reply is a thing somebody said; the record of what was said at the time
272
+ // is the entire point.
273
+ const MAX_REPLY = 4000;
274
+ const REPLY_COLS = 'r.id, r.help_request_id, r.builder_id, r.body, r.created_at, '
275
+ + 'b.github_login AS builder_login, b.display_name AS builder_name, b.avatar_url AS builder_avatar_url';
276
+ const REPLY_FROM = 'FROM lifecycle_help_request_replies r LEFT JOIN builders b ON b.id = r.builder_id';
277
+
278
+ // PURE, for the same reason validateHelpRequest is: the route refuses with a named
279
+ // field before a query runs, and every branch is testable without a database.
280
+ function validateReply(input) {
281
+ const errors = [];
282
+ const body = typeof (input || {}).body === 'string' ? input.body.trim() : '';
283
+ if (!body) errors.push({ field: 'body', reason: 'required' });
284
+ else if (body.length > MAX_REPLY) errors.push({ field: 'body', reason: 'too_long' });
285
+ return { ok: errors.length === 0, errors };
286
+ }
287
+
288
+ async function createReply({ helpRequestId, builderId, body }, deps = {}) {
289
+ const activePool = deps.pool || pool;
290
+ const { rows } = await activePool.query(
291
+ `INSERT INTO lifecycle_help_request_replies (help_request_id, builder_id, body)
292
+ VALUES ($1, $2, $3)
293
+ RETURNING id, help_request_id, builder_id, body, created_at`,
294
+ [helpRequestId, builderId, String(body).trim()]
295
+ );
296
+ return rows[0];
297
+ }
298
+
299
+ // Oldest first — the order a conversation is read in, and the opposite of every
300
+ // other list on the Collab page, which sorts by what needs attention. A thread is
301
+ // not a queue.
302
+ async function listReplies(helpRequestId, deps = {}) {
303
+ const activePool = deps.pool || pool;
304
+ const { rows } = await activePool.query(
305
+ `SELECT ${REPLY_COLS} ${REPLY_FROM}
306
+ WHERE r.help_request_id = $1
307
+ ORDER BY r.created_at ASC, r.id ASC
308
+ LIMIT ${MAX_ROWS}`,
309
+ [helpRequestId]
310
+ );
311
+ return rows;
312
+ }
313
+
314
+ // The most replies one thread contributes to a LIST read. A page draws a thread
315
+ // under every row, so the bound that matters is per-ask, not overall: a single
316
+ // global LIMIT would let one busy ask eat the budget and hand the rows below it
317
+ // empty threads, which reads as "nobody answered" — the exact lie this whole
318
+ // surface exists to stop telling.
319
+ const MAX_THREAD_ON_LIST = 20;
320
+
321
+ // Every reply on a set of asks in ONE query, so a page rendering twenty rows makes
322
+ // one round trip rather than twenty. Returns a Map keyed by help_request_id.
323
+ //
324
+ // BOUNDED, like every other read here. listReplies caps at MAX_ROWS; this one
325
+ // backs three list routes on the page's own load path, so an unbounded result set
326
+ // would be the one place a busy project could make the Collab page arrive by the
327
+ // megabyte. The window is per help_request_id, and the ORDER inside it is the
328
+ // same one listReplies uses, so the first N a list shows are the first N the
329
+ // single-thread read shows — never a different N in a different order.
330
+ async function repliesForRequests(ids, deps = {}) {
331
+ const activePool = deps.pool || pool;
332
+ const wanted = [...new Set((Array.isArray(ids) ? ids : []).map(Number).filter(Number.isInteger))];
333
+ if (!wanted.length) return new Map();
334
+ const { rows } = await activePool.query(
335
+ `SELECT * FROM (
336
+ SELECT ${REPLY_COLS},
337
+ ROW_NUMBER() OVER (PARTITION BY r.help_request_id
338
+ ORDER BY r.created_at ASC, r.id ASC) AS thread_pos
339
+ ${REPLY_FROM}
340
+ WHERE r.help_request_id = ANY($1::bigint[])
341
+ ) t
342
+ WHERE t.thread_pos <= ${MAX_THREAD_ON_LIST}
343
+ ORDER BY t.created_at ASC, t.id ASC`,
344
+ [wanted]
345
+ );
346
+ const out = new Map();
347
+ for (const row of rows) {
348
+ const key = String(row.help_request_id);
349
+ if (!out.has(key)) out.set(key, []);
350
+ out.get(key).push(row);
351
+ }
352
+ return out;
353
+ }
354
+
246
355
  module.exports = {
247
356
  HELP_CRAFTS, helpCrafts, disciplineForCraft, craftForDiscipline, craftsForBuilder,
248
357
  validateHelpRequest, MAX_STUCK, MAX_ROWS, capRows,
249
358
  builderExists, createHelpRequest, listOpenForBuilder, listMine, listOpenAll, getHelpRequest, setHelpRequestStatus,
250
359
  countOpenForBuilder, craftMapSql,
360
+ MAX_REPLY, MAX_THREAD_ON_LIST, validateReply, createReply, listReplies, repliesForRequests,
251
361
  };
@@ -0,0 +1,113 @@
1
+ -- lifecycle_011_help_request_replies.sql — an ask can finally be ANSWERED where it
2
+ -- was asked (task 1004216, goal 1000095; ADR 0330, amending ADR 0187 §4a).
3
+ --
4
+ -- THE HOLE THIS FILLS. lifecycle_004 gave a builder a way to ask for help and gave
5
+ -- the ASKER a way to mark their own row answered. It gave the person being asked
6
+ -- nothing at all. So the answer — the thing the whole surface exists to produce —
7
+ -- happened somewhere Bongos cannot see, and the row sat open until its author
8
+ -- happened to remember it. Six asks addressed to the owner were open between four
9
+ -- and thirteen days when this was written, every one of them already answerable.
10
+ --
11
+ -- WHY THIS IS NOT THE SURFACE ADR 0187 RULED OUT. §4a put conversation out of
12
+ -- scope and deferred it to the Agora (task 1002946, goal 1000064) — "this goal
13
+ -- must not quietly reimplement the Agora to get around it". That deferral no
14
+ -- longer has an addressee: task 1002946 is ABANDONED and goal 1000064 is ARCHIVED,
15
+ -- verified 2026-09-23. The surface that was to own replies will not be built.
16
+ --
17
+ -- The restraint survives the reversal, and it lives in the schema rather than in a
18
+ -- comment asking nicely. A reply is APPEND-ONLY: there is no updated_at, no
19
+ -- edited_by, no deleted_at, no parent_reply_id, no reaction table and no mention
20
+ -- table. What you can do is add one, and read them in order. That is an answer
21
+ -- surface; a thing you can edit, nest, react to and address at people is a chat,
22
+ -- and a chat is what §4a declined to build here.
23
+ --
24
+ -- WHY THE FK RESTRICTS RATHER THAN CASCADES, which is the opposite of what it
25
+ -- first said. `ON DELETE CASCADE` reads right — a reply has no meaning without
26
+ -- the ask it answers — but it CONTRADICTS append-only, and Postgres says so out
27
+ -- loud: a cascade is an ordinary `DELETE FROM lifecycle_help_request_replies`,
28
+ -- the trigger below refuses it, and deleting a help request that had ever been
29
+ -- answered failed outright. Caught by running the migration on a real database
30
+ -- rather than by reading it.
31
+ --
32
+ -- Restrict is the honest resolution rather than the lesser evil. Nothing deletes
33
+ -- a help request: it is SETTLED (`status`), and there is no DELETE route for one
34
+ -- anywhere in this module. So the FK forbids a thing that does not happen, and
35
+ -- if it ever must, "you cannot silently destroy answers as a side effect of
36
+ -- removing the question" is the right refusal for an append-only record to give.
37
+ --
38
+ -- MODULE-OWNED and `lifecycle_`-prefixed, so fitness check 11 permits it (ADR 0083
39
+ -- §Decision #5): a new table the lifecycle module owns outright, plus one column
40
+ -- on a table it already owns.
41
+
42
+ BEGIN;
43
+
44
+ CREATE TABLE IF NOT EXISTS lifecycle_help_request_replies (
45
+ id bigserial PRIMARY KEY,
46
+ help_request_id bigint NOT NULL REFERENCES lifecycle_help_requests(id) ON DELETE RESTRICT,
47
+ builder_id bigint NOT NULL REFERENCES builders(id),
48
+ body text NOT NULL,
49
+ created_at timestamptz NOT NULL DEFAULT now(),
50
+
51
+ -- Same shape as what_is_stuck's own check: whitespace is not an answer.
52
+ CONSTRAINT lifecycle_help_request_replies_body_chk
53
+ CHECK (length(btrim(body)) > 0)
54
+ );
55
+
56
+ -- Every read is "the replies on this ask, oldest first" — the order a
57
+ -- conversation is read in, and the order the page renders.
58
+ CREATE INDEX IF NOT EXISTS lifecycle_help_request_replies_thread_idx
59
+ ON lifecycle_help_request_replies (help_request_id, created_at);
60
+
61
+ COMMENT ON TABLE lifecycle_help_request_replies IS
62
+ 'Answers on a help request, append-only and oldest-first (task 1004216). No edit, no delete, no nesting, no reactions, no mentions — that restraint is what keeps this an answer surface rather than the chat surface ADR 0187 4a declined to build.';
63
+
64
+ -- WHO SETTLED IT. Until now only the asker could, so the answer was never in
65
+ -- question. ADR 0330 lets the ADDRESSEE close an ask too — a request addressed to
66
+ -- someone who has answered it and cannot close it is the defect that widening
67
+ -- fixes — which makes "who closed this" a real question for the first time.
68
+ -- Nullable, and null for every historical row: a settle that predates this column
69
+ -- was the asker's by construction, and backfilling requested_by would invent a
70
+ -- fact rather than record one.
71
+ ALTER TABLE lifecycle_help_requests
72
+ ADD COLUMN IF NOT EXISTS settled_by bigint REFERENCES builders(id);
73
+
74
+ COMMENT ON COLUMN lifecycle_help_requests.settled_by IS
75
+ 'The builder who moved this request out of open — the asker or, since ADR 0330, its addressee. NULL on rows settled before the column existed; that absence means "not recorded", never "the asker".';
76
+
77
+ -- The table must be append-only in the DATABASE, not merely in the routes that
78
+ -- happen to exist today. A later route, a migration, or a hand-run UPDATE would
79
+ -- otherwise silently rewrite what somebody said — and the whole value of a reply
80
+ -- as a record is that it is what was said at the time.
81
+ CREATE OR REPLACE FUNCTION lifecycle_help_request_replies_append_only()
82
+ RETURNS trigger AS $$
83
+ BEGIN
84
+ RAISE EXCEPTION
85
+ 'lifecycle_help_request_replies is append-only (ADR 0330): a reply cannot be % once written',
86
+ CASE TG_OP WHEN 'UPDATE' THEN 'edited' ELSE 'deleted' END;
87
+ END;
88
+ $$ LANGUAGE plpgsql;
89
+
90
+ DROP TRIGGER IF EXISTS lifecycle_help_request_replies_no_update ON lifecycle_help_request_replies;
91
+ CREATE TRIGGER lifecycle_help_request_replies_no_update
92
+ BEFORE UPDATE OR DELETE ON lifecycle_help_request_replies
93
+ FOR EACH ROW EXECUTE FUNCTION lifecycle_help_request_replies_append_only();
94
+
95
+ -- Proof the guard is armed, checked here rather than trusted: a migration that
96
+ -- silently failed to attach the trigger would leave the table editable, and every
97
+ -- test of the ROUTES would still pass.
98
+ DO $$
99
+ BEGIN
100
+ IF NOT EXISTS (
101
+ SELECT 1 FROM pg_trigger
102
+ WHERE tgrelid = 'lifecycle_help_request_replies'::regclass
103
+ AND tgname = 'lifecycle_help_request_replies_no_update'
104
+ AND NOT tgisinternal
105
+ ) THEN
106
+ RAISE EXCEPTION 'lifecycle_011: the append-only trigger is not attached — the table would accept an UPDATE or DELETE';
107
+ END IF;
108
+ END $$;
109
+
110
+ INSERT INTO schema_migrations (version) VALUES ('lifecycle_011_help_request_replies')
111
+ ON CONFLICT (version) DO NOTHING;
112
+
113
+ COMMIT;
@@ -2,10 +2,11 @@
2
2
  // read for "I am stuck" (task 1003171 / C01, goal 1000075; criterion
3
3
  // collab-ask-for-help).
4
4
  //
5
- // Five routes: ask, see what is addressed to me, see my own, see the whole open
6
- // board (task 1003942), settle mine.
7
- // The Collab PAGE is C02 and the matching rules beyond craft are its job — this
8
- // surface deliberately stops at "the request is addressed and answerable".
5
+ // Seven routes: ask, see what is addressed to me, see my own, see the whole open
6
+ // board (task 1003942), settle it, and — since task 1004216 — REPLY to one and
7
+ // read its replies. The Collab PAGE is C02 and the matching rules beyond craft
8
+ // are its job; this surface stops at "the request is addressed, answerable, and
9
+ // answered".
9
10
  //
10
11
  // Reaches core only through the doorway (ADR 0083).
11
12
 
@@ -17,6 +18,17 @@ const help = require('../help-requests');
17
18
 
18
19
  const { asyncHandler, validateOrRespond, parseId } = api;
19
20
 
21
+ // Hang each row's replies on it in ONE query (task 1004216). The page renders a
22
+ // thread under every ask, and fetching them per row would turn one list read into
23
+ // twenty — the shape that makes a page feel slow for reasons no single request
24
+ // explains. An empty thread is `[]`, never absent: a reader of this response
25
+ // should never have to tell "no replies" apart from "this response does not carry
26
+ // replies".
27
+ async function withReplies(rows) {
28
+ const threads = await help.repliesForRequests(rows.map((r) => r.id));
29
+ return rows.map((r) => ({ ...r, replies: threads.get(String(r.id)) || [] }));
30
+ }
31
+
20
32
  module.exports = function buildHelpRequestsRouter() {
21
33
  const router = express.Router();
22
34
 
@@ -78,14 +90,14 @@ module.exports = function buildHelpRequestsRouter() {
78
90
  const mine = await help.craftsForBuilder(req.builder.id);
79
91
  const want = typeof req.query.craft === 'string' ? req.query.craft : null;
80
92
  const crafts = want ? mine.filter((c) => c === want) : mine;
81
- const rows = await help.listOpenForBuilder({ builderId: req.builder.id, crafts });
93
+ const rows = await withReplies(await help.listOpenForBuilder({ builderId: req.builder.id, crafts }));
82
94
  return res.json({ help_requests: rows, count: rows.length, crafts });
83
95
  }, { errorCode: 'help_request_list_failed' }));
84
96
 
85
97
  // rank: any-builder — your own asks, so you can settle them.
86
98
  router.get('/help-requests/mine', api.requireBuilder, asyncHandler('GET /help-requests/mine', async (req, res) => {
87
99
  const status = typeof req.query.status === 'string' ? req.query.status : null;
88
- const rows = await help.listMine({ builderId: req.builder.id, status });
100
+ const rows = await withReplies(await help.listMine({ builderId: req.builder.id, status }));
89
101
  return res.json({ help_requests: rows, count: rows.length });
90
102
  }, { errorCode: 'help_request_list_failed' }));
91
103
 
@@ -94,15 +106,23 @@ module.exports = function buildHelpRequestsRouter() {
94
106
  // addressed inside this project and every reader here is a member; the route
95
107
  // takes no craft and no builder from the caller, so there is nothing to widen.
96
108
  router.get('/help-requests', api.requireBuilder, asyncHandler('GET /help-requests', async (req, res) => {
97
- const rows = await help.listOpenAll();
109
+ const rows = await withReplies(await help.listOpenAll());
98
110
  return res.json({ help_requests: rows, count: rows.length });
99
111
  }, { errorCode: 'help_request_list_failed' }));
100
112
 
101
- // Only the ASKER settles their own request: 'answered' when someone helped,
102
- // 'withdrawn' when they no longer need it. Nobody is assigned by being asked —
103
- // the same consent posture ADR 0187 §3 gives task recommendations.
113
+ // THE ASKER OR THE ADDRESSEE settles the request: 'answered' when it has been
114
+ // dealt with, 'withdrawn' when the asker no longer needs it. ADR 0330 widened
115
+ // 'answered' to the person the request NAMES — once a reply puts the answer on
116
+ // the row itself, an addressee who has answered in the open and still cannot
117
+ // close it is a surface arguing with itself. 'withdrawn' stays the asker's
118
+ // alone: only the person who needed something can say they no longer need it.
119
+ //
120
+ // Nobody is assigned by being asked — closing an ask ends a question, it does
121
+ // not accept work (ADR 0187 §3).
104
122
  //
105
- // rank: any-builder — ownership is enforced in the UPDATE's WHERE, not by rank.
123
+ // rank: any-builder — who may settle is enforced in the UPDATE's WHERE, not by
124
+ // rank. The caller's crafts come from THEIR OWN profile, never from the body,
125
+ // for the same reason the addressed read refuses a ?craft= it was handed.
106
126
  router.patch('/help-requests/:id', api.requireBuilder, asyncHandler('PATCH /help-requests/:id', async (req, res) => {
107
127
  const id = parseId(req, res, { code: 'bad_help_request_id', positiveInt: true });
108
128
  if (id === null) return;
@@ -110,18 +130,78 @@ module.exports = function buildHelpRequestsRouter() {
110
130
  status: { required: true, type: 'string', enum: ['answered', 'withdrawn'] },
111
131
  })) return;
112
132
 
113
- const row = await help.setHelpRequestStatus({ id, builderId: req.builder.id, status: req.body.status });
133
+ const crafts = await help.craftsForBuilder(req.builder.id);
134
+ const row = await help.setHelpRequestStatus({
135
+ id, builderId: req.builder.id, status: req.body.status, crafts,
136
+ });
114
137
  if (!row) {
115
138
  // ONE refusal for three cases: absent, someone else's, or already settled.
116
139
  // The first version looked the row up to say WHICH, which let a caller
117
140
  // enumerate existing ids — and contradicted this very comment (grader, task
118
141
  // 1003171). The lookup is gone; the comment and the code now agree.
119
142
  return res.fail('help_request_not_settleable', 404, {
120
- reason: 'no open help request of yours with that id',
143
+ reason: 'no open help request with that id that you asked or were asked',
121
144
  });
122
145
  }
123
146
  return res.json({ ok: true, help_request: row });
124
147
  }, { errorCode: 'help_request_update_failed' }));
125
148
 
149
+ // ---- replies (task 1004216, ADR 0330) -------------------------------------
150
+ //
151
+ // THE HALF THIS SURFACE SHIPPED WITHOUT. A builder could ask, and the asker
152
+ // could mark their own row answered; the person being ASKED had no write at all,
153
+ // so the answer happened somewhere Bongos could not see it.
154
+ //
155
+ // ANY BUILDER MAY REPLY, deliberately. The board read (GET /help-requests) is
156
+ // already project-wide, so every member can see every open ask — and a reply
157
+ // says nothing more private than the ask it hangs under. Restricting replies to
158
+ // the addressee would also break the common good case: somebody who is not who
159
+ // you asked turns out to know the answer.
160
+ //
161
+ // APPEND-ONLY, with no edit and no delete route. That is not an omission to fill
162
+ // in later: the table's own trigger refuses an UPDATE or a DELETE, and the
163
+ // restraint is what keeps this an answer surface rather than the chat surface
164
+ // ADR 0187 §4a declined to build here.
165
+ router.post('/help-requests/:id/replies', api.requireBuilder, asyncHandler('POST /help-requests/:id/replies', async (req, res) => {
166
+ const id = parseId(req, res, { code: 'bad_help_request_id', positiveInt: true });
167
+ if (id === null) return;
168
+ if (validateOrRespond(req, res, {
169
+ body: { required: true, type: 'string', minLength: 1, maxLength: help.MAX_REPLY },
170
+ })) return;
171
+
172
+ const v = help.validateReply(req.body);
173
+ if (!v.ok) return res.fail('invalid_reply', 400, { errors: v.errors });
174
+
175
+ // Checked rather than left to the FK, so a reply to an id that never existed
176
+ // is a named 404 instead of a Postgres string rendered as a 500.
177
+ const target = await help.getHelpRequest(id);
178
+ if (!target) return res.fail('help_request_not_found', 404, { reason: 'no help request with that id' });
179
+
180
+ const reply = await help.createReply({
181
+ helpRequestId: id, builderId: req.builder.id, body: req.body.body,
182
+ });
183
+ return res.status(201).json({ ok: true, reply });
184
+ }, { errorCode: 'help_request_reply_failed' }));
185
+
186
+ // rank: any-builder, matching the board read this hangs under.
187
+ //
188
+ // AN UNKNOWN ID ANSWERS `[]`, NOT 404, and that asymmetry with the POST above is
189
+ // deliberate rather than an oversight (raised by the grader). This surface has
190
+ // already had one lesson about existence lookups: PATCH used to look a row up to
191
+ // say WHICH of three things was wrong, which let a caller enumerate ids by
192
+ // watching the refusal change. A 404 here would rebuild exactly that oracle on
193
+ // the cheapest possible request — a GET, repeatable, with nothing written.
194
+ //
195
+ // The POST can afford the 404 because it is a WRITE: it costs an attempt, it is
196
+ // rate-limited by being a write, and a reply to a phantom ask has to fail with a
197
+ // reason or fail as an FK 500 that tells the caller nothing useful. A read has no
198
+ // such excuse, and "no replies" is a true answer about an ask that does not exist.
199
+ router.get('/help-requests/:id/replies', api.requireBuilder, asyncHandler('GET /help-requests/:id/replies', async (req, res) => {
200
+ const id = parseId(req, res, { code: 'bad_help_request_id', positiveInt: true });
201
+ if (id === null) return;
202
+ const replies = await help.listReplies(id);
203
+ return res.json({ replies, count: replies.length });
204
+ }, { errorCode: 'help_request_reply_list_failed' }));
205
+
126
206
  return router;
127
207
  };