@bongos/core 1.19.374 → 1.19.376

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 (56) hide show
  1. package/.bongos-core.json +99 -54
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +9 -1
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/docs/adr/0194-joinability-one-door-decision.md +42 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +158 -5
  10. package/docs/api-reference.md +6 -4
  11. package/docs/copy-inventory.md +373 -356
  12. package/docs/copy-registry.json +646 -452
  13. package/migrations/core_227_task_created_by.sql +83 -0
  14. package/modules/dev-box/app/src/vendor/bongos-client.cjs +4 -0
  15. package/modules/government/catalog.js +1 -1
  16. package/modules/hall-ui/public/builders.js +5 -3
  17. package/modules/hall-ui/public/goals-lib.js +5 -5
  18. package/modules/hall-ui/public/goals-page.js +54 -23
  19. package/modules/hall-ui/public/goals-render.js +15 -0
  20. package/modules/hall-ui/public/hall-render.js +63 -22
  21. package/modules/hall-ui/public/my-projects.js +6 -1
  22. package/modules/hall-ui/public/task-lib.js +29 -0
  23. package/modules/hall-ui/public/watch.css +8 -0
  24. package/modules/hall-ui/public/watch.js +56 -3
  25. package/modules/hall-ui/public/work.js +117 -5
  26. package/modules/ideas/inbox.js +4 -0
  27. package/modules/lifecycle/db-tasks.js +61 -5
  28. package/modules/lifecycle/goal-rollup.js +118 -0
  29. package/modules/lifecycle/routes/goals.js +26 -0
  30. package/modules/lifecycle/routes/task-write-routes.js +3 -0
  31. package/modules/lifecycle/routes/tasks.js +47 -1
  32. package/modules/onboarding/module.json +1 -1
  33. package/modules/onboarding/routes/access-requests.js +87 -1
  34. package/modules/platform-identity/routes/my-projects.js +35 -9
  35. package/package-lock.json +2 -2
  36. package/package.json +1 -1
  37. package/public-landing/projects.html +169 -3
  38. package/scripts/gds/login.js +24 -3
  39. package/src/bongos/auth-admission.js +50 -5
  40. package/src/bongos/auth.js +2 -1
  41. package/src/bongos/routes/auth.js +15 -0
  42. package/src/branding.js +3 -2
  43. package/src/module-api.js +22 -1
  44. package/tests/access_requests_invite.mjs +165 -0
  45. package/tests/auth_admission_gate.mjs +42 -0
  46. package/tests/goal_sort_staleness.mjs +286 -0
  47. package/tests/goal_subtitle.mjs +6 -1
  48. package/tests/goals_lib.mjs +16 -4
  49. package/tests/join_relay_outcome.mjs +61 -0
  50. package/tests/joinability_apply.mjs +72 -0
  51. package/tests/joinability_invite_only.mjs +192 -0
  52. package/tests/joinability_open.mjs +89 -0
  53. package/tests/module_api.mjs +1 -0
  54. package/tests/projects_hub.mjs +76 -2
  55. package/tests/projects_hub_pre_uat.mjs +4 -2
  56. package/tests/public_landing_projects.mjs +10 -3
@@ -46,6 +46,45 @@
46
46
  } = window.OTBTaskLib; // deHashRefs is not called here — it is used inside buildPromptForTask
47
47
  // Shared component kit (hall-kit.js, task 2166): tabs, pager, filter bar.
48
48
  const { makePager, paginate, wirePager, pagerBarHtml, makeTabs, makeFilterBar } = window.OTBKit;
49
+ // task 1003278 — the shared staleness vocabulary + activity clock (task-lib.js,
50
+ // one definition for both tiers) and the creator axis.
51
+ const { taskActivityMs, STALE_CHOICES, isStale } = window.OTBTaskLib;
52
+ // task 1003278 — Creator and Age are SERVER-side axes: the board asks the feed
53
+ // a narrower question rather than downloading it and reducing it here. That is
54
+ // the criterion's explicit branch ("if it does, the new axes filter and page
55
+ // SERVER-side"), and it is why changing either control re-fetches instead of
56
+ // just re-rendering. Every other control stays client-side over what came
57
+ // back, exactly as before — those narrow an already-loaded page, this narrows
58
+ // what gets loaded.
59
+ // The Backlog group, BOUNDED and server-filtered. This read measured 690,625
60
+ // bytes for ~298 rows on 2026-08-25 and had no ceiling at all — it is
61
+ // `SELECT * FROM tasks WHERE status='backlog'`, so it grew with the project
62
+ // forever. Its own function because Creator/Age re-fetch it, same as the
63
+ // claimable feed. Best-effort: on failure the section stays as it was.
64
+ function loadBacklog() {
65
+ const q = serverFilterQuery().replace(/^\?/, '&');
66
+ return getJSON(`${API}/tasks?status=backlog&limit=${BACKLOG_LIMIT}${q}`)
67
+ .then((r) => { taskState.backlog = r.tasks || []; renderBacklog(taskState.backlog); })
68
+ .catch(() => { /* backlog stays as it was */ });
69
+ }
70
+
71
+ // The last server-facing filter query we actually fetched with. Compared on
72
+ // every filter change to decide re-fetch vs re-render.
73
+ let lastServerFilterQuery = '';
74
+
75
+ function serverFilterQuery() {
76
+ const parts = [];
77
+ const creator = $('#f-creator') ? $('#f-creator').value : '';
78
+ const stale = $('#f-stale') ? $('#f-stale').value : '';
79
+ if (creator) parts.push(`created_by=${encodeURIComponent(creator === '__none__' ? 'none' : creator)}`);
80
+ if (stale) parts.push(`stale_days=${encodeURIComponent(stale)}`);
81
+ return parts.length ? `?${parts.join('&')}` : '';
82
+ }
83
+
84
+ // task 1003278 — the ceiling on the Backlog read. Comfortably above today's
85
+ // ~300 rows so nothing visibly changes now, and low enough that the page can
86
+ // never be asked to hold the whole table as the project grows.
87
+ const BACKLOG_LIMIT = 500;
49
88
  const fmtDate = (iso) => window.OTB.fmtDate(iso);
50
89
  const toast = (msg) => window.OTB.toast(msg, { duration: 2200, clear: true });
51
90
  const curLabel = (caps) => { const l = (window.__BRANDING__ && window.__BRANDING__.currency && window.__BRANDING__.currency.label) || 'credits'; return caps ? l.charAt(0).toUpperCase() + l.slice(1) : l; };
@@ -255,6 +294,11 @@
255
294
  const kFilter = $('#f-kind').value;
256
295
  const dFilter = $('#f-discipline').value;
257
296
  const nFilter = $('#f-newcomer') ? $('#f-newcomer').value : '';
297
+ // task 1003278 — creator + staleness. '__none__' on Creator isolates the
298
+ // unattributed (created before tasks.created_by existed, or by a system path)
299
+ // — the goals owner filter's 'system / unattributed' convention.
300
+ const cFilter = $('#f-creator') ? $('#f-creator').value : '';
301
+ const staleFilter = $('#f-stale') ? $('#f-stale').value : '';
258
302
  const sortBy = $('#f-sort').value;
259
303
  const sQuery = searchQuery();
260
304
 
@@ -267,6 +311,13 @@
267
311
  if (kFilter && t.kind !== kFilter) return false;
268
312
  if (dFilter && (t.discipline || 'unclassified') !== dFilter) return false;
269
313
  if (nFilter === 'yes' && !t.newcomer_friendly) return false;
314
+ // Creator + Age were applied by the SERVER (serverFilterQuery), so they are
315
+ // re-checked here only as a belt on a stale render — never as the primary
316
+ // filter. Re-applying them is cheap and keeps the view honest if a poll
317
+ // lands between a control change and its re-fetch.
318
+ if (cFilter === '__none__' && t.created_by != null) return false;
319
+ if (cFilter && cFilter !== '__none__' && String(t.created_by) !== cFilter) return false;
320
+ if (staleFilter && !isStale(taskActivityMs(t), staleFilter)) return false;
270
321
  return true;
271
322
  });
272
323
 
@@ -295,6 +346,10 @@
295
346
  } else if (sortBy === 'id') {
296
347
  // task 1830: sort by task number, ascending (lowest/oldest first).
297
348
  list = list.slice().sort((a, b) => Number(a.id) - Number(b.id));
349
+ } else if (sortBy === 'stale') {
350
+ // task 1003278: what is rotting — least-recently-touched first, on the
351
+ // shared activity clock (updated_at ∨ shipped_at, task-lib.js).
352
+ list = list.slice().sort((a, b) => taskActivityMs(a) - taskActivityMs(b));
298
353
  }
299
354
 
300
355
  const count = $('#claim-count');
@@ -405,6 +460,44 @@
405
460
  }
406
461
  }
407
462
 
463
+ // task 1003278: the Creator filter — options are the builders who created the
464
+ // tasks on the board (claimable + backlog), labelled via the directory the
465
+ // goals page also reads (GET /builders/directory, best-effort — 'builder #id'
466
+ // until it lands). '__none__' isolates the unattributed: every task created
467
+ // before tasks.created_by existed (core_227 deliberately backfills nothing —
468
+ // a guessed attribution is a confident lie), plus system paths like the
469
+ // newcomer restock.
470
+ async function populateCreatorFilter() {
471
+ const sel = $('#f-creator');
472
+ if (!sel) return;
473
+ const rows = [...taskState.all, ...(taskState.backlog || [])];
474
+ const ids = [...new Set(rows.filter((t) => t.created_by != null).map((t) => String(t.created_by)))];
475
+ let byId = new Map();
476
+ if (ids.length) {
477
+ try {
478
+ const d = await getJSON(`${API}/builders/directory`);
479
+ byId = new Map((d.builders || []).map((b) => [String(b.id), b]));
480
+ } catch (_) { /* labels fall back to builder #id */ }
481
+ }
482
+ const label = (id) => {
483
+ const b = byId.get(id);
484
+ return b ? (b.display_name || '@' + b.github_login) : `builder #${id}`;
485
+ };
486
+ sel.length = 1; // keep the leading "— any —" option
487
+ for (const id of ids.sort((a, b) => label(a).localeCompare(label(b)))) {
488
+ const opt = document.createElement('option');
489
+ opt.value = id;
490
+ opt.textContent = label(id);
491
+ sel.appendChild(opt);
492
+ }
493
+ if (rows.some((t) => t.created_by == null)) {
494
+ const opt = document.createElement('option');
495
+ opt.value = '__none__';
496
+ opt.textContent = 'system / unattributed';
497
+ sel.appendChild(opt);
498
+ }
499
+ }
500
+
408
501
  // task 1755: the Goal filter — options are the goals present on the claimable
409
502
  // feed, labeled by title (falls back to 'Goal #id' before titles load).
410
503
  // '__none__' isolates ungoaled tasks. Populated once after the feed loads.
@@ -983,7 +1076,7 @@
983
1076
  // the same way loadAll does so a transient failure doesn't blank the board.
984
1077
  async function refreshLive() {
985
1078
  try {
986
- const claimResp = await getJSON(`${API}/tasks/claimable`);
1079
+ const claimResp = await getJSON(`${API}/tasks/claimable${serverFilterQuery()}`);
987
1080
  renderInFlight(claimResp.active_claims || []);
988
1081
  taskState.all = claimResp.claimable || [];
989
1082
  renderClaimable();
@@ -1072,9 +1165,7 @@
1072
1165
 
1073
1166
  // task 1754: the Backlog group — not-yet-ready tasks. Best-effort + independent
1074
1167
  // of the claimable feed; leaves the section hidden on failure.
1075
- getJSON(`${API}/tasks?status=backlog`)
1076
- .then((r) => { taskState.backlog = r.tasks || []; renderBacklog(taskState.backlog); })
1077
- .catch(() => { /* backlog stays hidden */ });
1168
+ loadBacklog();
1078
1169
 
1079
1170
  // task 1755: goal id → title map for the Goal filter (best-effort).
1080
1171
  taskState.goalTitles = {};
@@ -1096,7 +1187,7 @@
1096
1187
  // empty-but-honest (the gate also blocks active_claims, so say so).
1097
1188
  try {
1098
1189
  const [claimResp, versionsResp] = await Promise.all([
1099
- getJSON(`${API}/tasks/claimable`),
1190
+ getJSON(`${API}/tasks/claimable${serverFilterQuery()}`),
1100
1191
  getJSON(`${API}/versions`).catch(() => ({ versions: [] })),
1101
1192
  ]);
1102
1193
  taskState.versions = versionsResp.versions || [];
@@ -1106,6 +1197,7 @@
1106
1197
  taskState.all = claimResp.claimable || [];
1107
1198
  renderClaimable();
1108
1199
  populateGoalFilter(); // task 1755
1200
+ populateCreatorFilter().catch(() => {}); // task 1003278 — fire-and-forget, the owner-filter posture
1109
1201
  // task 2170: a shared URL's goal filter can only apply once the options
1110
1202
  // exist — restore it now and re-render if it changed anything.
1111
1203
  if (restoreUrlFilter('f-goal')) renderClaimable();
@@ -1168,6 +1260,7 @@
1168
1260
  { value: 'minutes', label: 'minutes (low → high)' },
1169
1261
  { value: 'priority', label: 'priority (P0 first)' },
1170
1262
  { value: 'id', label: 'task number (low → high)' },
1263
+ { value: 'stale', label: 'stalest first (untouched longest)' },
1171
1264
  ],
1172
1265
  },
1173
1266
  { id: 'f-version', label: 'Version', options: [{ value: '', label: '— any —' }] },
@@ -1191,6 +1284,11 @@
1191
1284
  label: 'Newcomer',
1192
1285
  options: [{ value: '', label: '— any —' }, { value: 'yes', label: 'newcomer-friendly only' }],
1193
1286
  },
1287
+ // task 1003278 — Creator options fill after the feeds land
1288
+ // (populateCreatorFilter, the f-goal pattern); staleness buckets are the
1289
+ // shared STALE_CHOICES so both tiers offer the same lenses.
1290
+ { id: 'f-creator', label: 'Creator', options: [{ value: '', label: '— any —' }] },
1291
+ { id: 'f-stale', label: 'Age', options: STALE_CHOICES },
1194
1292
  {
1195
1293
  id: 'f-fit',
1196
1294
  label: 'Run mode',
@@ -1203,6 +1301,20 @@
1203
1301
  onChange: () => {
1204
1302
  pagers.ready.page = 1;
1205
1303
  pagers.backlog.page = 1;
1304
+ // task 1003278 — Creator and Age are answered by the SERVER, so a change
1305
+ // to either re-fetches a narrower feed instead of re-reducing the wide
1306
+ // one. The test is whether the SERVER-FACING QUESTION changed, not which
1307
+ // control fired: makeFilterBar's onChange is handed every value, not the
1308
+ // changed id (hall-kit.js:269), and comparing the query is the direct
1309
+ // question anyway — a control that does not appear in it can never cost
1310
+ // a round-trip, which is what keeps the other filters instant.
1311
+ const q = serverFilterQuery();
1312
+ if (q !== lastServerFilterQuery) {
1313
+ lastServerFilterQuery = q;
1314
+ refreshLive();
1315
+ loadBacklog();
1316
+ return;
1317
+ }
1206
1318
  renderClaimable();
1207
1319
  renderBacklog();
1208
1320
  },
@@ -429,6 +429,10 @@ async function routeIdeaToGoal({
429
429
  // (db.findTaskBySourceRef): one idea can only ever mint one routed task.
430
430
  source: 'idea-route',
431
431
  sourceRef: `idea-${idea.id}`,
432
+ // task 1003278 — the filer created this task, via the route. capturedBy is
433
+ // already the authenticated filer's id (never body-supplied), so routing an
434
+ // idea attributes the task to the person whose idea it was.
435
+ createdBy: capturedBy ?? null,
432
436
  }, { client });
433
437
 
434
438
  // d. The lineage row — origin='route' (ideas_003 widened the CHECK for
@@ -72,6 +72,11 @@ async function createTask({
72
72
  // (requiresRank stays null for the goal vector → derived purely from touches +
73
73
  // sensitivity), so goal_id never influences the requires_rank clamp.
74
74
  goalId = null,
75
+ // task 1003278: WHO created it — the builder id from the authenticated request.
76
+ // Nullable, appended at the end (the goals bind-order convention): a system
77
+ // path with no builder in the request (a seed, a migration backfill) passes
78
+ // nothing and the row honestly reads unattributed, never guessed.
79
+ createdBy = null,
75
80
  }, deps = {}) { // deps.client enlists this INSERT in the CALLER'S transaction (task 1003065, BV1.R01); the argument for it is at modules/ideas/inbox.js routeIdeaToGoal
76
81
  // task 1498 / ADR 0085: every task carries a deliberate rank floor. Derive it
77
82
  // from touches + sensitivity, then let an explicit requiresRank override only
@@ -112,12 +117,12 @@ async function createTask({
112
117
  est_minutes, est_cost_usd, manual_degree, priority,
113
118
  automation_tag, credits_reward, source, source_ref, value_summary,
114
119
  kind, discipline, parallel_safe, newcomer_friendly, needs_migration,
115
- security_sensitive, requires_rank, module_key, goal_id, promoted_at)
120
+ security_sensitive, requires_rank, module_key, goal_id, created_by, promoted_at)
116
121
  VALUES ($1, $2, $3, $4, $5,
117
122
  $6, $7, $8, $9,
118
123
  $10, $11, $12, $13, $14,
119
124
  $15, $16, $17, $18, $19,
120
- $20, $21, $22, $23,
125
+ $20, $21, $22, $23, $24,
121
126
  CASE WHEN $4 IN ('ready','active') THEN now() ELSE NULL END)
122
127
  RETURNING *`,
123
128
  [
@@ -144,6 +149,7 @@ async function createTask({
144
149
  requiresRankEffective,
145
150
  moduleKey ?? null,
146
151
  goalIdEffective ?? null,
152
+ createdBy ?? null,
147
153
  ]
148
154
  );
149
155
  // task 1677 / ADR 0096: audit each creation-time auto-assignment so the trail is
@@ -473,7 +479,7 @@ async function getTask(id) {
473
479
  }
474
480
 
475
481
 
476
- async function listTasks({ versionId, status, kind, discipline, goalId } = {}) {
482
+ async function listTasks({ versionId, status, kind, discipline, goalId, createdBy, staleDays, limit, offset } = {}) {
477
483
  const params = [];
478
484
  const where = [];
479
485
  if (versionId) {
@@ -496,13 +502,44 @@ async function listTasks({ versionId, status, kind, discipline, goalId } = {}) {
496
502
  params.push(goalId);
497
503
  where.push(`goal_id = $${params.length}`);
498
504
  }
505
+ // task 1003278 — the creator + staleness axes, server-side. They exist HERE and
506
+ // not only in the browser because the board was reducing the whole table to
507
+ // answer them: GET /tasks?status=backlog measured 690,625 bytes on 2026-08-25,
508
+ // and the Goals page's whole-version read 5.2 MB. `'none'` selects the
509
+ // unattributed (created before tasks.created_by existed, or by a system path) —
510
+ // the goals owner-filter convention, and a real question ("what has no owner").
511
+ if (createdBy === 'none') {
512
+ where.push('created_by IS NULL');
513
+ } else if (createdBy) {
514
+ params.push(createdBy);
515
+ where.push(`created_by = $${params.length}`);
516
+ }
517
+ // Untouched for N days, on the SAME clock the clients use: max(updated_at,
518
+ // shipped_at) — a ship path can stamp shipped_at without touching updated_at.
519
+ // A row with neither is stale by construction (GREATEST of two epochs), which
520
+ // matches isStale()'s treatment of an undated row: a row nobody can date is
521
+ // what a rot-hunt must not hide.
522
+ if (staleDays) {
523
+ params.push(staleDays);
524
+ where.push(
525
+ `GREATEST(COALESCE(updated_at, 'epoch'::timestamptz), COALESCE(shipped_at, 'epoch'::timestamptz))
526
+ <= now() - ($${params.length}::int * INTERVAL '1 day')`
527
+ );
528
+ }
499
529
  const sql =
500
530
  `SELECT t.*, ${CREDITS_AWARDED_SUBQUERY} AS credits_awarded,
501
531
  pms_calibrated_minutes(t.est_minutes, t.kind) AS est_minutes_calibrated
502
532
  FROM tasks t` +
503
533
  (where.length ? ` WHERE ${where.join(' AND ')}` : '') +
504
534
  ` ORDER BY priority NULLS LAST, created_at`;
505
- const { rows } = await pool.query(sql, params);
535
+ // BOUND, not interpolated. Both call sites validate to a finite non-negative
536
+ // integer today, so string interpolation would not be exploitable — but a
537
+ // query builder that is only safe because of who happens to call it is one
538
+ // careless caller away from not being safe at all (raised in review).
539
+ let tail = '';
540
+ if (limit) { params.push(Number(limit)); tail += ` LIMIT $${params.length}`; }
541
+ if (offset) { params.push(Number(offset)); tail += ` OFFSET $${params.length}`; }
542
+ const { rows } = await pool.query(sql + tail, params);
506
543
  return rows;
507
544
  }
508
545
 
@@ -571,7 +608,7 @@ const SESSION_FIT_SQL = `
571
608
  END AS fits_session_modes
572
609
  FROM tasks t`;
573
610
 
574
- async function listClaimableTasks({ versionId = null, builderId = null, discipline = null, fit = null } = {}) {
611
+ async function listClaimableTasks({ versionId = null, builderId = null, discipline = null, fit = null, createdBy = null, staleDays = null } = {}) {
575
612
  const params = [];
576
613
  const where = [];
577
614
  let goalJoin = '';
@@ -584,6 +621,25 @@ async function listClaimableTasks({ versionId = null, builderId = null, discipli
584
621
  params.push(discipline);
585
622
  where.push(`v.discipline = $${params.length}`);
586
623
  }
624
+ // task 1003278 — the SAME creator + staleness axes GET /tasks offers, so the
625
+ // Work Board asks the server a narrow question instead of pulling the feed and
626
+ // reducing it in the browser. 'none' selects the unattributed; the staleness
627
+ // clock is max(updated_at, shipped_at), identical to listTasks, the rollup SQL
628
+ // and task-lib.js — four expressions of one rule, pinned against each other by
629
+ // tests/goal_sort_staleness.mjs.
630
+ if (createdBy === 'none') {
631
+ where.push('v.created_by IS NULL');
632
+ } else if (createdBy) {
633
+ params.push(createdBy);
634
+ where.push(`v.created_by = $${params.length}`);
635
+ }
636
+ if (staleDays) {
637
+ params.push(staleDays);
638
+ where.push(
639
+ `GREATEST(COALESCE(v.updated_at, 'epoch'::timestamptz), COALESCE(v.shipped_at, 'epoch'::timestamptz))
640
+ <= now() - ($${params.length}::int * INTERVAL '1 day')`
641
+ );
642
+ }
587
643
  if (builderId) {
588
644
  params.push(builderId);
589
645
  where.push(`NOT EXISTS (
@@ -0,0 +1,118 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/goal-rollup.js — per-goal task rollups, computed in Postgres
4
+ // instead of in the browser (task 1003278, goal 1000072, criterion
5
+ // legibility-sort-and-staleness).
6
+ //
7
+ // THE MEASUREMENT THAT FORCED THIS. The Goals page derived each goal's readiness
8
+ // counters, its last-activity timestamp and (task 1003278) its craft set by
9
+ // FETCHING EVERY TASK IN THE VERSION — `GET /tasks?version=BONGOS-V1&include=deps`
10
+ // — and reducing them client-side. On the real corpus that request is
11
+ // **5,229,455 bytes for ~1,400 tasks**, measured 2026-08-25, to produce roughly
12
+ // forty small counter objects. The criterion's own words: "~1,357 tasks on
13
+ // BONGOS-V1 alone. A view that does not survive that number is not a solution."
14
+ // It did not survive it; it merely did not fall over.
15
+ //
16
+ // So the reduction moves to where the rows already are. One query per version
17
+ // returns one row per goal, and the payload stops scaling with the task count.
18
+ //
19
+ // THE BUCKETS ARE THE CLIENT'S, VERBATIM. goals-lib.js `readinessOf` is the
20
+ // definition of record and this SQL mirrors it exactly — done (shipped |
21
+ // confirmed | completed) · cut (abandoned | dropped, excluded from the total the
22
+ // way migration 017 keeps them out of version_progress) · active · ready ·
23
+ // blocked (backlog with >=1 unshipped dependency) · backlog. A divergence here
24
+ // would show a person different numbers depending on which surface they opened,
25
+ // so tests/goal_sort_staleness.mjs pins the two against each other.
26
+ //
27
+ // LAST ACTIVITY is `max(updated_at, shipped_at)` — the same "any task activity
28
+ // counts" rule the client uses (task 1002965, the owner's call), now expressed
29
+ // once in SQL and once in task-lib.js because the browser still needs it for
30
+ // rows it holds itself.
31
+
32
+ const api = require('../../src/module-api');
33
+ const { pool } = api;
34
+
35
+ // The status vocabularies, kept beside the SQL that uses them so a reader can
36
+ // check them against goals-lib.js in one glance.
37
+ const DONE_STATUSES = ['shipped', 'confirmed', 'completed'];
38
+ const CUT_STATUSES = ['abandoned', 'dropped'];
39
+
40
+ // goalRollups — one row per goal that has at least one task in `versionId`.
41
+ // Returns [{ goal_id, done, active, ready, blocked, backlog, cut, total,
42
+ // last_activity_at, disciplines[], next_ready_id, next_ready_title }].
43
+ //
44
+ // `blocked` needs the dependency edges, so the CTE resolves each backlog task's
45
+ // unshipped-dependency count once rather than per row. deps.pool honors the #538
46
+ // test seam (NODE_ENV==='test' only), like every other helper in this module.
47
+ // VISIBILITY. A rollup is a COUNT, and counts on a private goal are not the
48
+ // secret — its prose is (ADR 0112's leak rule: a non-member must not learn a
49
+ // private goal's title or scope). But `next_ready_title` is a real task title
50
+ // off a real goal, so this returns it ONLY to a member or an Archon. Everyone
51
+ // else gets the counters and a null title: the board still renders the ring and
52
+ // the readiness line, and the "Claim next" affordance simply has no name to show
53
+ // — which is correct, because they cannot claim it either (task 1003137's
54
+ // membership gate). Raised in review; the pre-existing GET /goals listing has
55
+ // the same unfiltered shape, and tightening THAT is its own task, not something
56
+ // to smuggle in here.
57
+ async function goalRollups({ versionId = null, viewerId = null, viewerIsArchon = false } = {}, deps = {}) {
58
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
59
+ const { rows } = await activePool.query(
60
+ `WITH scoped AS (
61
+ SELECT t.id, t.goal_id, t.status, t.title, t.priority, t.discipline,
62
+ GREATEST(COALESCE(t.updated_at, 'epoch'::timestamptz),
63
+ COALESCE(t.shipped_at, 'epoch'::timestamptz)) AS activity_at,
64
+ EXISTS (
65
+ SELECT 1 FROM dependencies d
66
+ JOIN tasks dep ON dep.id = d.to_id
67
+ WHERE d.from_kind = 'task' AND d.from_id = t.id
68
+ AND d.to_kind = 'task' AND dep.status <> 'shipped'
69
+ ) AS has_unshipped_dep
70
+ FROM tasks t
71
+ WHERE t.goal_id IS NOT NULL
72
+ AND ($1::text IS NULL OR t.version_id = $1)
73
+ ), bucketed AS (
74
+ SELECT s.*,
75
+ CASE
76
+ WHEN s.status = ANY($2::text[]) THEN 'done'
77
+ WHEN s.status = ANY($3::text[]) THEN 'cut'
78
+ WHEN s.status = 'active' THEN 'active'
79
+ WHEN s.status = 'ready' THEN 'ready'
80
+ WHEN s.status = 'backlog' AND s.has_unshipped_dep THEN 'blocked'
81
+ ELSE 'backlog'
82
+ END AS bucket
83
+ FROM scoped s
84
+ ), next_ready AS (
85
+ -- The goal's next claimable task, picked the way the board orders work:
86
+ -- priority first (NULLS LAST), then oldest id. DISTINCT ON gives one row
87
+ -- per goal without a second round trip.
88
+ SELECT DISTINCT ON (goal_id) goal_id, id, title
89
+ FROM bucketed
90
+ WHERE bucket = 'ready'
91
+ ORDER BY goal_id, priority NULLS LAST, id
92
+ )
93
+ SELECT b.goal_id::text AS goal_id,
94
+ COUNT(*) FILTER (WHERE b.bucket = 'done')::int AS done,
95
+ COUNT(*) FILTER (WHERE b.bucket = 'active')::int AS active,
96
+ COUNT(*) FILTER (WHERE b.bucket = 'ready')::int AS ready,
97
+ COUNT(*) FILTER (WHERE b.bucket = 'blocked')::int AS blocked,
98
+ COUNT(*) FILTER (WHERE b.bucket = 'backlog')::int AS backlog,
99
+ COUNT(*) FILTER (WHERE b.bucket = 'cut')::int AS cut,
100
+ COUNT(*) FILTER (WHERE b.bucket <> 'cut')::int AS total,
101
+ MAX(b.activity_at) AS last_activity_at,
102
+ ARRAY_AGG(DISTINCT COALESCE(b.discipline, 'unclassified')) AS disciplines,
103
+ MAX(nr.id)::text AS next_ready_id,
104
+ CASE WHEN bool_or(g.visibility IS DISTINCT FROM 'private')
105
+ OR $4::boolean
106
+ OR bool_or(gm.builder_id IS NOT NULL)
107
+ THEN MAX(nr.title) END AS next_ready_title
108
+ FROM bucketed b
109
+ JOIN goals g ON g.id = b.goal_id
110
+ LEFT JOIN next_ready nr ON nr.goal_id = b.goal_id
111
+ LEFT JOIN goal_members gm ON gm.goal_id = b.goal_id AND gm.builder_id = $5::bigint
112
+ GROUP BY b.goal_id`,
113
+ [versionId, DONE_STATUSES, CUT_STATUSES, viewerIsArchon === true, viewerId]
114
+ );
115
+ return rows;
116
+ }
117
+
118
+ module.exports = { goalRollups, DONE_STATUSES, CUT_STATUSES };
@@ -29,6 +29,7 @@ const hierarchy = require('../hierarchy');
29
29
  const goalSuggest = require('../goal-suggest.js'); // task 1003062 — pre-creation goal suggester
30
30
  const goalEdits = require('../goal-edits.js'); // task 1003102 — goal self-edit handlers
31
31
  const goalCategory = require('../goal-category.js'); // task 1003275 — the ADVISORY human category (gates nothing)
32
+ const goalRollup = require('../goal-rollup.js'); // task 1003278 — per-goal rollups computed in SQL, not in the browser
32
33
  const { suggestCriteria } = require('../criterion-suggest');
33
34
  const { classifyTaskKind, VALID_KINDS, VALID_DISCIPLINES, TASK_FIELD_BOUNDS } = require('../task-classifier');
34
35
  const { validateOrRespond, parseId, LIMITS } = api;
@@ -145,6 +146,29 @@ module.exports = function buildGoalsRouter() {
145
146
  }
146
147
  });
147
148
 
149
+ // GET /goals/rollups?version= — one small row per goal: readiness counters, the
150
+ // last-activity timestamp, the craft set, and the next claimable task
151
+ // (task 1003278). It replaces the Goals page's whole-version task fetch, which
152
+ // measured 5,229,455 bytes for ~1,400 tasks on 2026-08-25 to produce ~40 counter
153
+ // objects. Registered BEFORE /goals/:id or Express routes 'rollups' to the param.
154
+ // rank: any-builder — the same read as the goal board it feeds.
155
+ router.get('/goals/rollups', auth.requireBuilder, async (req, res) => {
156
+ try {
157
+ // The viewer decides whether a PRIVATE goal's next-ready task TITLE comes
158
+ // back (counters always do — a count is not the secret, the prose is;
159
+ // ADR 0112). Identity from the authenticated request, never the query.
160
+ const rollups = await goalRollup.goalRollups({
161
+ versionId: req.query.version || null,
162
+ viewerId: req.builder.id,
163
+ viewerIsArchon: req.builder.rank === 'archon',
164
+ });
165
+ return res.json({ rollups });
166
+ } catch (err) {
167
+ log.error('[gds] GET /goals/rollups', err);
168
+ return res.fail('rollups_failed', { status: 500, message: 'internal error' });
169
+ }
170
+ });
171
+
148
172
  // GET /goals?version=&status= — list goals (read).
149
173
  // rank: any-builder — viewing the goal board.
150
174
  router.get('/goals', auth.requireBuilder, async (req, res) => {
@@ -1150,6 +1174,8 @@ module.exports = function buildGoalsRouter() {
1150
1174
  discipline,
1151
1175
  source: 'goal-authored',
1152
1176
  goalId: goal.id,
1177
+ // task 1003278 — attribution from the authenticated request (never the body).
1178
+ createdBy: req.builder.id,
1153
1179
  });
1154
1180
 
1155
1181
  // Attach the sub-task parent (validated above; attachParent re-checks the
@@ -195,6 +195,9 @@ module.exports = function registerTaskWriteRoutes(router) {
195
195
  requiresRank: body.requires_rank ?? null,
196
196
  moduleKey: body.module_key ?? null,
197
197
  goalId: body.goal_id ?? null,
198
+ // task 1003278 — attribution, from the AUTHENTICATED request, never the
199
+ // body: who created a task is a fact about the caller, not an input.
200
+ createdBy: req.builder.id,
198
201
  });
199
202
  // task 438: link resolved criteria (source='planning' — task-create is the
200
203
  // planning vector). The ids were version-checked above, so this is a single
@@ -71,6 +71,25 @@ module.exports = function buildTasksRouter() {
71
71
  const router = express.Router();
72
72
 
73
73
  // rank: any-builder — reads (filtered task list); preserved per CRITICAL §4.
74
+ // task 1003278 — shared by GET /tasks and GET /tasks/claimable, so the two
75
+ // surfaces cannot drift on what a valid creator id or age window is. Returns
76
+ // undefined (absent), a number, or { bad } for the caller to turn into a 400.
77
+ const posInt = (raw, code, max) => {
78
+ if (raw === undefined || raw === '') return undefined;
79
+ const n = Number(raw);
80
+ if (!Number.isInteger(n) || n <= 0 || (max && n > max)) return { bad: code };
81
+ return n;
82
+ };
83
+ // 'none' is a REAL value here — the unattributed set ("what has no owner"), not
84
+ // a missing filter. Anything else must be a positive builder id.
85
+ function parseCreatedBy(raw) {
86
+ if (raw === undefined || raw === '') return undefined;
87
+ if (raw === 'none') return 'none';
88
+ const n = Number(raw);
89
+ return (Number.isInteger(n) && n > 0) ? n : { bad: 'bad_created_by' };
90
+ }
91
+ const CREATED_BY_MSG = "created_by must be a positive builder id, or 'none' for the unattributed.";
92
+
74
93
  router.get('/tasks', auth.requireBuilder, async (req, res) => {
75
94
  const { version, status, kind, discipline, goal_id } = req.query;
76
95
  if (kind && !VALID_KINDS.has(kind)) {
@@ -88,7 +107,25 @@ module.exports = function buildTasksRouter() {
88
107
  return res.fail('bad_goal_id', { status: 400, message: 'goal_id must be a positive integer.' });
89
108
  }
90
109
  }
91
- const tasks = await db.listTasks({ versionId: version, status, kind, discipline, goalId });
110
+ // task 1003278 — created_by / stale_days / limit / offset, so a board can ask
111
+ // the server a narrow question instead of reducing the whole table in the
112
+ // browser. All four are optional; omitting them is exactly today's behaviour.
113
+ const { created_by: createdByRaw, stale_days: staleRaw, limit: limitRaw, offset: offsetRaw } = req.query;
114
+ const createdBy = parseCreatedBy(createdByRaw);
115
+ if (createdBy && createdBy.bad) return res.fail('bad_created_by', { status: 400, message: CREATED_BY_MSG });
116
+ const staleDays = posInt(staleRaw, 'bad_stale_days', 3650);
117
+ if (staleDays && staleDays.bad) return res.fail('bad_stale_days', { status: 400, message: 'stale_days must be a positive integer number of days.' });
118
+ // The cap is the point of the parameter: an unbounded limit would be the
119
+ // whole-table read this axis exists to stop.
120
+ const limit = posInt(limitRaw, 'bad_limit', 1000);
121
+ if (limit && limit.bad) return res.fail('bad_limit', { status: 400, message: 'limit must be a positive integer up to 1000.' });
122
+ const offset = posInt(offsetRaw, 'bad_offset');
123
+ if (offset && offset.bad) return res.fail('bad_offset', { status: 400, message: 'offset must be a positive integer.' });
124
+
125
+ const tasks = await db.listTasks({
126
+ versionId: version, status, kind, discipline, goalId,
127
+ createdBy, staleDays, limit, offset,
128
+ });
92
129
  // ?include=deps (hall redesign task 1002439) — enrich each task with its
93
130
  // dependency summary (`blocked_by[]` with per-target satisfied state, and a
94
131
  // `blocks` count) so the Goals rollup + Tasks dependency view can paint
@@ -117,12 +154,21 @@ module.exports = function buildTasksRouter() {
117
154
  if (fit && !VALID_FITS.has(fit)) {
118
155
  return res.fail('bad_fit', 400, { valid: Array.from(VALID_FITS) });
119
156
  }
157
+ // task 1003278 — the creator + staleness axes, so the Work Board narrows the
158
+ // feed at the SOURCE instead of downloading it and reducing it in the browser.
159
+ // Same validators as GET /tasks: one vocabulary, two surfaces.
160
+ const claimCreatedBy = parseCreatedBy(req.query.created_by);
161
+ if (claimCreatedBy && claimCreatedBy.bad) return res.fail('bad_created_by', { status: 400, message: CREATED_BY_MSG });
162
+ const claimStaleDays = posInt(req.query.stale_days, 'bad_stale_days', 3650);
163
+ if (claimStaleDays && claimStaleDays.bad) return res.fail('bad_stale_days', { status: 400, message: 'stale_days must be a positive integer number of days.' });
120
164
  const [claimable, active] = await Promise.all([
121
165
  db.listClaimableTasks({
122
166
  versionId: version || null,
123
167
  builderId: req.builder.id,
124
168
  discipline: discipline || null,
125
169
  fit: fit || null,
170
+ createdBy: claimCreatedBy ?? null,
171
+ staleDays: claimStaleDays ?? null,
126
172
  }),
127
173
  db.listActiveClaims(),
128
174
  ]);
@@ -2,7 +2,7 @@
2
2
  "key": "onboarding",
3
3
  "title": "Onboarding (admission · setup · newcomer floor)",
4
4
  "description": "The newcomer ADMISSION + ORIENTATION domain — the public access-request → Archon-approval gate, the server-ticked onboarding state machine (the 'Path into the City' checklist that graduates a Xenos to Thetes), the friction analyzer (feedback-without-asking on consented onboarding transcripts), and the evergreen newcomer-chore restock (a fresh first-builder task is always waiting). A core-domain module carved out of the GDS core in BV1.R81 (ADR 0093 §1); default-on because every instance onboards its own builders. The access-requests admission routes mount here; the core lifecycle (db.js claim/ship), auth callbacks, and the me/builders profile readers resolve the `onboarding` kernel PORT for the state-machine + restock surface they need — never importing the module.",
5
- "coreVersion": "^1.9.0",
5
+ "coreVersion": "^1.19.374",
6
6
  "default": true,
7
7
  "maintenance": { "status": "core-maintained" },
8
8
  "contributes": { "routes": ["onboarding"] },