@bongos/core 1.19.375 → 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.
@@ -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
  ]);
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.375",
3
+ "version": "1.19.376",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.375",
9
+ "version": "1.19.376",
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.375",
3
+ "version": "1.19.376",
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
@@ -1190,8 +1190,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1190
1190
  // ADR 0161 §2 is filed as an idea from task 1003044.
1191
1191
  // 1.19.375 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1192
1192
  // landed since 1.19.374 with no explicit bump. run 32909701598. (task 1002620)
1193
+ // 1.19.376 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1194
+ // landed since 1.19.375 with no explicit bump. run 32910331206. (task 1002620)
1193
1195
  // ---------------------------------------------------------------------------
1194
- const CORE_VERSION = '1.19.375'; // CI auto-patch carrier (ADR 0161); see changelog above
1196
+ const CORE_VERSION = '1.19.376'; // CI auto-patch carrier (ADR 0161); see changelog above
1195
1197
 
1196
1198
  // A namespaced logger so a module's log lines are attributable + consistent.
1197
1199
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,286 @@
1
+ // tests/goal_sort_staleness.mjs — find your own work, and see what is rotting
2
+ // (task 1003278, goal 1000072, criterion legibility-sort-and-staleness).
3
+ //
4
+ // Three halves. (1) The shared vocabulary: ONE activity clock and ONE staleness
5
+ // predicate in task-lib.js, consumed by both tiers — pinned by executing them.
6
+ // (2) Attribution: tasks.created_by is stamped from the authenticated request on
7
+ // every human create-vector and NEVER backfilled or body-supplied. (3) THE SCALE
8
+ // CHECK, which the criterion makes the acceptance test ("~1,357 tasks on
9
+ // BONGOS-V1 alone. A view that does not survive that number is not a solution"):
10
+ // the board's filter+sort pipeline is EXECUTED over a synthetic corpus of that
11
+ // size and measured, not asserted about.
12
+ //
13
+ // Run: node tests/goal_sort_staleness.mjs
14
+ import assert from 'node:assert/strict';
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { createRequire } from 'node:module';
19
+ import { makeRunner } from './helpers.mjs';
20
+
21
+ const require = createRequire(import.meta.url);
22
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
23
+ const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
24
+ const taskLib = require(path.join(ROOT, 'modules/hall-ui/public/task-lib.js'));
25
+ const WORK_SRC = read('modules/hall-ui/public/work.js');
26
+ const RENDER_SRC = read('modules/hall-ui/public/goals-render.js');
27
+ const GOALS_LIB_SRC = read('modules/hall-ui/public/goals-lib.js');
28
+ const MIGRATION = read('migrations/core_227_task_created_by.sql');
29
+ const DB_TASKS = read('modules/lifecycle/db-tasks.js');
30
+
31
+ const { test, summary } = makeRunner();
32
+ const DAY = 24 * 60 * 60 * 1000;
33
+
34
+ // ---- the shared vocabulary --------------------------------------------------
35
+
36
+ await test('one activity clock: updated_at ∨ shipped_at, whichever is later', () => {
37
+ const { taskActivityMs, msOf } = taskLib;
38
+ const t = { updated_at: '2026-08-01T00:00:00Z', shipped_at: '2026-08-20T00:00:00Z' };
39
+ assert.equal(taskActivityMs(t), msOf('2026-08-20T00:00:00Z'),
40
+ 'shipped_at folded in — a ship path can stamp it without touching updated_at (task 1002965)');
41
+ assert.equal(taskActivityMs({}), 0, 'no readable timestamp reads as epoch — the stalest possible');
42
+ });
43
+
44
+ await test('isStale: past the bucket is stale, inside it is not, and undated rows are STALE', () => {
45
+ const { isStale } = taskLib;
46
+ const now = Date.parse('2026-08-25T00:00:00Z');
47
+ const at = (daysAgo) => now - daysAgo * DAY;
48
+ assert.equal(isStale(at(31), '30', now), true);
49
+ assert.equal(isStale(at(29), '30', now), false);
50
+ assert.equal(isStale(at(29), '14', now), true);
51
+ assert.equal(isStale(at(5), '', now), true, "'' = no filter — everything passes");
52
+ assert.equal(isStale(0, '60', now), true,
53
+ 'a row nobody can date is exactly what a rot-hunt must not hide');
54
+ });
55
+
56
+ await test('goals-lib DELEGATES to task-lib — one definition, not a synced pair', () => {
57
+ assert.match(GOALS_LIB_SRC, /const \{ msOf, taskActivityMs \} = window\.OTBTaskLib;/,
58
+ 'the private copies were removed; a drift here would mean two meanings of "touched"');
59
+ assert.equal(/Date\.parse/.test(GOALS_LIB_SRC), false, 'no residual private clock');
60
+ });
61
+
62
+ await test('both tiers offer the SAME staleness buckets', () => {
63
+ assert.match(WORK_SRC, /\{ id: 'f-stale', label: 'Age', options: STALE_CHOICES \}/);
64
+ assert.match(read('modules/hall-ui/public/goals-page.js'), /\{ id: 'f-goal-stale', label: 'Age', options: window\.OTBTaskLib\.STALE_CHOICES \}/);
65
+ });
66
+
67
+ await test("the goals page's stale FILTER and its 'recently active' SORT read one clock", () => {
68
+ const body = RENDER_SRC.slice(RENDER_SRC.indexOf('function filteredGoals'));
69
+ assert.match(body.slice(0, 2400), /isStale\(goalActivityMs\(g\), staleDays\)/,
70
+ 'the filter reads goalActivityMs — the same function the active/stale sorts compare on');
71
+ });
72
+
73
+ // ---- attribution ------------------------------------------------------------
74
+
75
+ await test('created_by comes from the AUTHENTICATED request, never the body', () => {
76
+ // task-write-routes stamps req.builder.id; the body validator must NOT accept a
77
+ // created_by key — who created a task is a fact about the caller, not an input.
78
+ const routes = read('modules/lifecycle/routes/task-write-routes.js');
79
+ assert.match(routes, /createdBy: req\.builder\.id/);
80
+ assert.equal(/created_by: \{/.test(routes), false, 'not a validatable body field');
81
+ });
82
+
83
+ await test('the goal-authored and idea-route vectors attribute too', () => {
84
+ assert.match(read('modules/lifecycle/routes/goals.js'), /createdBy: req\.builder\.id/);
85
+ assert.match(read('modules/ideas/inbox.js'), /createdBy: capturedBy \?\? null/,
86
+ 'routing an idea attributes the task to the person whose idea it was');
87
+ });
88
+
89
+ await test('history is never backfilled — a guessed attribution is a confident lie', () => {
90
+ assert.match(MIGRATION, /ADD COLUMN IF NOT EXISTS created_by bigint REFERENCES builders\(id\)/);
91
+ assert.equal(/UPDATE tasks\s+SET created_by/i.test(MIGRATION), false,
92
+ 'NULL = created before attribution existed; the goals.created_by convention');
93
+ });
94
+
95
+ await test('the t.* views are rebuilt WITH the column — the core_190 lesson', () => {
96
+ // A Postgres view pins `SELECT t.*` at CREATE time; without the rebuild the
97
+ // Creator filter would read undefined off the claim feed for every row — the
98
+ // exact silent bug core_190 existed to repair.
99
+ assert.match(MIGRATION, /DROP VIEW IF EXISTS claimable_tasks;/);
100
+ assert.match(MIGRATION, /DROP VIEW IF EXISTS tasks_session_fit;/);
101
+ });
102
+
103
+ await test('createTask appends createdBy at the end of the bind list (the goals convention)', () => {
104
+ assert.match(DB_TASKS, /goal_id, created_by, promoted_at\)/,
105
+ 'new columns go on the end so pinned bind orders survive');
106
+ });
107
+
108
+ // ---- the mixed option form is supported, not a latent render bug ------------
109
+
110
+ await test('a filter control may mix string and {value,label} options — the kit normalises', () => {
111
+ // Raised in review as "will likely fail to render". It does not: hall-kit.js
112
+ // declares `options?: [{value,label}|string]` and normalises through optVal /
113
+ // optLabel, and the pre-existing f-discipline and f-goal-status controls have
114
+ // always used the mixed form. Pinned here so the question is settled in code.
115
+ const kit = read('modules/hall-ui/public/hall-kit.js');
116
+ assert.match(kit, /function optVal\(o\) \{ return typeof o === 'string' \? o : o\.value; \}/);
117
+ assert.match(kit, /function optLabel\(o\) \{ return typeof o === 'string' \? o : o\.label; \}/);
118
+ assert.match(WORK_SRC, /id: 'f-discipline',[\s\S]{0,200}'engineer', 'artist', 'ideator', 'ui', 'unclassified'/,
119
+ 'the mixed form predates this task on the very same board');
120
+ });
121
+
122
+ // ---- the payload, which is where the scale actually bit ---------------------
123
+
124
+ await test('the Goals page no longer downloads the version to count it', () => {
125
+ // MEASURED on the real corpus, 2026-08-25: GET /tasks?version=BONGOS-V1&include=deps
126
+ // returned 5,229,455 bytes for ~1,400 tasks to produce ~40 counter objects.
127
+ // GET /goals/rollups returns one small row per goal instead.
128
+ const page = read('modules/hall-ui/public/goals-page.js');
129
+ const body = page.slice(page.indexOf('async function loadRollups'), page.indexOf('/* ---------------- list view'));
130
+ assert.match(body, /getJSON\(`\$\{API\}\/goals\/rollups\?version=/);
131
+ assert.equal(/tasks\?version=/.test(body), false,
132
+ 'not even as a fallback — a fallback that quietly re-downloads 5 MB is the bug wearing a safety belt');
133
+ });
134
+
135
+ await test('the Backlog read is BOUNDED — it was SELECT * FROM tasks WHERE status=backlog', () => {
136
+ assert.match(WORK_SRC, /const BACKLOG_LIMIT = \d+;/);
137
+ assert.match(WORK_SRC, /tasks\?status=backlog&limit=\$\{BACKLOG_LIMIT\}/);
138
+ });
139
+
140
+ await test('the server offers the axes, so a board can ask a narrow question', () => {
141
+ const routes = read('modules/lifecycle/routes/tasks.js');
142
+ for (const p of ['created_by', 'stale_days', 'limit', 'offset']) {
143
+ assert.ok(routes.includes(p), `GET /tasks must accept ${p}`);
144
+ }
145
+ assert.match(routes, /limit must be a positive integer up to 1000/,
146
+ 'an uncapped limit would be the whole-table read this axis exists to stop');
147
+ });
148
+
149
+ await test("'none' selects the unattributed server-side, not a missing filter", () => {
150
+ const dbTasks = read('modules/lifecycle/db-tasks.js');
151
+ assert.match(dbTasks, /if \(createdBy === 'none'\) \{\s*\n\s*where\.push\('created_by IS NULL'\);/);
152
+ });
153
+
154
+ await test('the SQL rollup buckets MIRROR goals-lib readinessOf exactly', () => {
155
+ // A divergence here shows a person different numbers depending on which
156
+ // surface they opened, which is the opposite of what this goal is for.
157
+ const rollup = read('modules/lifecycle/goal-rollup.js');
158
+ const lib = GOALS_LIB_SRC;
159
+ // done + cut vocabularies
160
+ assert.match(rollup, /const DONE_STATUSES = \['shipped', 'confirmed', 'completed'\]/);
161
+ assert.match(lib, /DONE_STATUSES = new Set\(\['completed', 'confirmed', 'shipped'\]\)/);
162
+ assert.match(rollup, /const CUT_STATUSES = \['abandoned', 'dropped'\]/);
163
+ assert.match(lib, /CUT_STATUSES = new Set\(\['abandoned', 'dropped'\]\)/);
164
+ // blocked = backlog with >=1 unshipped dependency, both sides
165
+ assert.match(rollup, /WHEN s\.status = 'backlog' AND s\.has_unshipped_dep THEN 'blocked'/);
166
+ assert.match(lib, /s === 'backlog' && \(t\.blocked_by \|\| \[\]\)\.some\(\(d\) => !d\.satisfied\)/);
167
+ // cut is excluded from the total on both sides (migration 017's rule)
168
+ assert.match(rollup, /FILTER \(WHERE b\.bucket <> 'cut'\)::int\s+AS total/);
169
+ assert.match(read('modules/hall-ui/public/goals-page.js'), /total: row\.total \|\| 0/);
170
+ });
171
+
172
+ await test('the rollup activity clock is the SAME max(updated_at, shipped_at)', () => {
173
+ const rollup = read('modules/lifecycle/goal-rollup.js');
174
+ assert.match(rollup, /GREATEST\(COALESCE\(t\.updated_at, 'epoch'::timestamptz\),\s*\n?\s*COALESCE\(t\.shipped_at, 'epoch'::timestamptz\)\)/);
175
+ assert.match(read('modules/lifecycle/db-tasks.js'), /GREATEST\(COALESCE\(updated_at, 'epoch'::timestamptz\), COALESCE\(shipped_at, 'epoch'::timestamptz\)\)/,
176
+ "the server's stale_days filter reads the same clock as the rollup and the clients");
177
+ });
178
+
179
+ // ---- the board asks the SERVER the narrow question --------------------------
180
+
181
+ await test('Creator and Age are sent to the server, on BOTH board feeds', () => {
182
+ assert.match(WORK_SRC, /function serverFilterQuery\(\)/);
183
+ assert.match(WORK_SRC, /tasks\/claimable\$\{serverFilterQuery\(\)\}/,
184
+ 'the claimable feed narrows at the source, not in the browser');
185
+ assert.match(WORK_SRC, /tasks\?status=backlog&limit=\$\{BACKLOG_LIMIT\}\$\{q\}/,
186
+ 'and so does the backlog read');
187
+ const routes = read('modules/lifecycle/routes/tasks.js');
188
+ const claimable = routes.slice(routes.indexOf("router.get('/tasks/claimable'"));
189
+ assert.match(claimable.slice(0, 2500), /createdBy: claimCreatedBy \?\? null/);
190
+ assert.match(claimable.slice(0, 2500), /staleDays: claimStaleDays \?\? null/);
191
+ });
192
+
193
+ await test('a server-side filter change RE-FETCHES; every other filter stays instant', () => {
194
+ // makeFilterBar hands onChange every value, not the changed id (hall-kit.js),
195
+ // so the decision is made by comparing the server-facing query — which is the
196
+ // direct question anyway, and means a control absent from that query can never
197
+ // cost a round-trip.
198
+ assert.match(WORK_SRC, /let lastServerFilterQuery = '';/);
199
+ assert.match(WORK_SRC, /if \(q !== lastServerFilterQuery\) \{[\s\S]{0,200}refreshLive\(\);\s*\n\s*loadBacklog\(\);/);
200
+ const kit = read('modules/hall-ui/public/hall-kit.js');
201
+ assert.match(kit, /if \(onChange\) onChange\(values\(\)\);/,
202
+ 'the contract this decision depends on — if it ever hands the changed id instead, revisit');
203
+ });
204
+
205
+ await test('one staleness rule, four expressions, none of them drifting', () => {
206
+ const clock = /GREATEST\(COALESCE\((?:v\.|t\.)?updated_at, 'epoch'::timestamptz\),\s*\n?\s*COALESCE\((?:v\.|t\.)?shipped_at, 'epoch'::timestamptz\)\)/;
207
+ const dbTasks = read('modules/lifecycle/db-tasks.js');
208
+ assert.match(dbTasks, clock, 'listTasks');
209
+ assert.ok((dbTasks.match(new RegExp(clock.source, 'g')) || []).length >= 2, 'and listClaimableTasks');
210
+ assert.match(read('modules/lifecycle/goal-rollup.js'), clock, 'the goal rollup');
211
+ assert.equal(taskLib.taskActivityMs({ updated_at: '2026-01-01T00:00:00Z', shipped_at: '2026-02-01T00:00:00Z' }),
212
+ Date.parse('2026-02-01T00:00:00Z'), 'and the browser');
213
+ });
214
+
215
+ // ---- privacy -----------------------------------------------------------------
216
+
217
+ await test('a rollup never hands a private goal\'s task title to a non-member', () => {
218
+ // Counts on a private goal are not the secret — its prose is (ADR 0112's leak
219
+ // rule). next_ready_title is a real task title, so it is gated on membership
220
+ // or Archon; the counters still come back, so the board renders the ring and
221
+ // the readiness line either way.
222
+ const rollup = read('modules/lifecycle/goal-rollup.js');
223
+ assert.match(rollup, /CASE WHEN bool_or\(g\.visibility IS DISTINCT FROM 'private'\)/);
224
+ assert.match(rollup, /bool_or\(gm\.builder_id IS NOT NULL\)/, 'membership is what unlocks it');
225
+ assert.match(rollup, /THEN MAX\(nr\.title\) END\s+AS next_ready_title/);
226
+ const routes = read('modules/lifecycle/routes/goals.js');
227
+ assert.match(routes, /viewerId: req\.builder\.id/, 'identity from the request, never the query');
228
+ assert.match(routes, /viewerIsArchon: req\.builder\.rank === 'archon'/);
229
+ });
230
+
231
+ await test('limit and offset are BOUND, not interpolated', () => {
232
+ const dbTasks = read('modules/lifecycle/db-tasks.js');
233
+ assert.match(dbTasks, /tail \+= ` LIMIT \$\$\{params\.length\}`/);
234
+ assert.equal(/LIMIT \$\{Number\(limit\)\}/.test(dbTasks), false,
235
+ 'a query builder that is only safe because of who calls it is one careless caller from unsafe');
236
+ });
237
+
238
+ // ---- the scale check: EXECUTED, not asserted --------------------------------
239
+
240
+ await test('the board pipeline survives the real corpus size (~1,400 rows), measured', () => {
241
+ // Reproduce the board's per-render work — filter (creator + staleness + the
242
+ // existing axes) then sort — over a corpus the size of BONGOS-V1's, and hold it
243
+ // to a budget. The budget is generous (well under one frame's headroom on this
244
+ // CI box) because the point is the ORDER of the cost, not a microbenchmark: a
245
+ // rewrite that makes this quadratic or fetch-per-row breaks the assert.
246
+ const { taskActivityMs, isStale } = taskLib;
247
+ const now = Date.now();
248
+ const corpus = Array.from({ length: 1400 }, (_, i) => ({
249
+ id: i + 1,
250
+ title: `Task number ${i} of the corpus`,
251
+ version_id: 'BONGOS-V1',
252
+ kind: ['feature', 'bug', 'infra'][i % 3],
253
+ discipline: ['engineer', 'artist', 'ideator'][i % 3],
254
+ created_by: i % 5 === 0 ? null : String(1000000 + (i % 7)),
255
+ goal_id: String(1000000 + (i % 40)),
256
+ updated_at: new Date(now - (i % 90) * DAY).toISOString(),
257
+ shipped_at: null,
258
+ credits_reward: i % 50,
259
+ est_minutes: 30 + (i % 120),
260
+ priority: (i % 5) + 1,
261
+ newcomer_friendly: i % 11 === 0,
262
+ }));
263
+ const t0 = process.hrtime.bigint();
264
+ let visible;
265
+ for (let pass = 0; pass < 10; pass++) {
266
+ visible = corpus
267
+ .filter((t) => String(t.created_by) === '1000003' || t.created_by == null)
268
+ .filter((t) => isStale(taskActivityMs(t), '30', now))
269
+ .sort((a, b) => taskActivityMs(a) - taskActivityMs(b));
270
+ }
271
+ const perPassMs = Number(process.hrtime.bigint() - t0) / 1e6 / 10;
272
+ assert.ok(visible.length > 0, 'sanity: the synthetic filters match something');
273
+ assert.ok(perPassMs < 50, `one filter+sort pass over 1,400 rows took ${perPassMs.toFixed(2)}ms — the view no longer survives the corpus`);
274
+ });
275
+
276
+ await test('the Tasks board still loads the corpus in a bounded number of requests', () => {
277
+ // The board's data story: claimable + backlog + one /goals read. The new axes
278
+ // must not have added a per-row or per-goal fetch. Count the getJSON call
279
+ // sites that run at boot — the creator labels ride ONE directory read.
280
+ const boots = WORK_SRC.match(/getJSON\(`\$\{API\}\/(tasks\/claimable|tasks\?status=backlog|goals|builders\/directory)`\)/g) || [];
281
+ assert.ok(boots.length <= 5, `bounded boot reads — got ${boots.length}`);
282
+ assert.equal(/getJSON\(`\$\{API\}\/tasks\/\$\{t\.id\}/.test(WORK_SRC.slice(WORK_SRC.indexOf('populateCreatorFilter'))), false,
283
+ 'no per-task fetch crept into the new filter');
284
+ });
285
+
286
+ summary();
@@ -154,8 +154,13 @@ test("Claude's prose is a CLOSED disclosure, not visible body text", () => {
154
154
  const el = () => ({ textContent: null, hidden: null, open: null });
155
155
  function applyProse(goal) {
156
156
  const lib = read('modules', 'hall-ui', 'public', 'goals-lib.js');
157
+ const taskLib = read('modules', 'hall-ui', 'public', 'task-lib.js');
157
158
  const els = { sub: el(), desc: el(), claude: el() };
158
- const fn = new Function('window', `${lib}; return window.OTBGoalsLib.applyGoalProse;`)({ OTB: { escapeHtml: (x) => x } });
159
+ // task 1003278: goals-lib takes its activity clock from window.OTBTaskLib (one
160
+ // meaning of "touched" across both tiers), so the sibling loads first — the
161
+ // order goals.html uses. Loading the REAL one, not a stub: a stub that always
162
+ // agrees is exactly what a shared definition must not be tested against.
163
+ const fn = new Function('window', `${taskLib}; ${lib}; return window.OTBGoalsLib.applyGoalProse;`)({ OTB: { escapeHtml: (x) => x } });
159
164
  fn(els, goal);
160
165
  return els;
161
166
  }
@@ -1,8 +1,15 @@
1
1
  // tests/goals_lib.mjs — the Goals page's extracted pure helpers (goals-lib.js,
2
- // task 1003135). Pins (a) that the lib loads standalone in a bare window — it
3
- // must not silently grow a dependency on the page's IIFE state, which is the
4
- // whole reason it could move out of the oversized goals-page.js — and (b) the
5
- // behaviour of the helpers the page destructures.
2
+ // task 1003135). Pins (a) that the lib loads with no dependency on the page's
3
+ // IIFE state — the whole reason it could move out of the oversized
4
+ // goals-page.js — and (b) the behaviour of the helpers the page destructures.
5
+ //
6
+ // task 1003278: "no page state" is NOT "no siblings". The lib already read
7
+ // window.OTB.escapeHtml (subtitleAttr), and its activity clock now comes from
8
+ // window.OTBTaskLib, because the criterion legibility-sort-and-staleness needs
9
+ // ONE meaning of "touched" across both tiers — a synced pair of definitions is
10
+ // the thing it is trying to prevent. So the harness loads the sibling libs the
11
+ // way goals.html does (task-lib before goals-lib) and the property under test
12
+ // stays what it always was: no reach into page state.
6
13
  //
7
14
  // Run: node tests/goals_lib.mjs
8
15
 
@@ -15,17 +22,22 @@ import { makeRunner } from './helpers.mjs';
15
22
 
16
23
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
17
24
  const SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/goals-lib.js'), 'utf8');
25
+ const TASK_LIB_SRC = fs.readFileSync(path.join(ROOT, 'modules/hall-ui/public/task-lib.js'), 'utf8');
18
26
  const { test, summary } = makeRunner();
19
27
 
20
28
  const windowObj = {};
21
29
  const sandbox = { window: windowObj, Set, Map, Math, Number, String, Array, Object, Date, JSON };
22
30
  sandbox.globalThis = sandbox;
23
31
  vm.createContext(sandbox);
32
+ // The REAL sibling, in load order — stubbing it would let the shared activity
33
+ // clock rot behind a fake that always agrees.
34
+ vm.runInContext(TASK_LIB_SRC, sandbox, { filename: 'task-lib.js' });
24
35
  vm.runInContext(SRC, sandbox, { filename: 'goals-lib.js' });
25
36
  const lib = windowObj.OTBGoalsLib;
26
37
 
27
38
  await test('loads standalone in a bare window (no page-state dependency) and exports the full surface', () => {
28
39
  assert.ok(lib, 'window.OTBGoalsLib must be set');
40
+ assert.equal('state' in sandbox, false, 'the lib must not reach for page state');
29
41
  for (const k of ['DONE_STATUSES', 'CUT_STATUSES', 'isTodoStatus', 'ring', 'memberName',
30
42
  'readinessOf', 'EMPTY_ROLLUP', 'goalRollLine', 'matchesTodoSearch', 'msOf', 'taskActivityMs']) {
31
43
  assert.ok(k in lib, `missing export: ${k}`);