@bongos/core 1.20.5 → 1.20.7

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 (58) hide show
  1. package/.bongos-core.json +113 -53
  2. package/.claude/skills/goal-review/SKILL.md +16 -24
  3. package/.claude/skills/goal-uat/SKILL.md +84 -0
  4. package/clients/bongos-client/README.md +1 -1
  5. package/clients/bongos-client/bongos-client.global.js +12 -0
  6. package/clients/bongos-client/index.cjs +12 -0
  7. package/clients/bongos-client/index.d.ts +18 -1
  8. package/clients/bongos-client/index.mjs +12 -0
  9. package/docs/adr/0183-criteria-close-themselves.md +1 -1
  10. package/docs/adr/0310-a-speciality-offers-skills-and-the-adopter-chooses-them.md +1 -1
  11. package/docs/adr/0351-a-criterion-closes-on-a-uat.md +73 -0
  12. package/docs/adr/README.md +28 -0
  13. package/docs/api/openapi.json +385 -5
  14. package/docs/api-reference.md +14 -4
  15. package/docs/architecture.md +7 -0
  16. package/docs/copy-inventory.md +22 -22
  17. package/docs/copy-registry.json +23 -23
  18. package/docs/file-map.md +2 -1
  19. package/docs/module-api-changelog.md +5 -1
  20. package/docs/page-readings.json +3 -3
  21. package/modules/hall-ui/public/goals-page.js +8 -3
  22. package/modules/hall-ui/public/tweak-editor.css +4 -7
  23. package/modules/hall-ui/public/tweak-editor.html +3 -3
  24. package/modules/hall-ui/public/tweak-editor.js +3 -0
  25. package/modules/lifecycle/criterion-uat-db.js +267 -0
  26. package/modules/lifecycle/criterion-uat.js +303 -0
  27. package/modules/lifecycle/db-goals.js +25 -12
  28. package/modules/lifecycle/done-when.js +84 -17
  29. package/modules/lifecycle/migrations/lifecycle_015_criterion_uat.sql +73 -0
  30. package/modules/lifecycle/module.json +1 -0
  31. package/modules/lifecycle/routes/criterion-uat.js +169 -0
  32. package/modules/lifecycle/routes/done-when.js +25 -1
  33. package/modules/npm-release/module.json +3 -1
  34. package/modules/npm-release/routes/task-where.js +18 -1
  35. package/modules/npm-release/work.js +38 -7
  36. package/modules/specialities/routes/specialities.js +8 -1
  37. package/modules/specialities/specialities.js +26 -1
  38. package/package-lock.json +2 -2
  39. package/package.json +1 -1
  40. package/release-notes.json +24 -0
  41. package/scripts/gds/cli-lib.js +4 -1
  42. package/scripts/gds/find-untested.js +371 -0
  43. package/scripts/gds/fitness-checks-write-validation.js +4 -0
  44. package/scripts/gds/newcomer-floor.templates.json +1 -1
  45. package/scripts/gds/status.js +14 -3
  46. package/scripts/gds/uat.js +120 -0
  47. package/src/bongos/route-rank-check.js +9 -0
  48. package/src/module-api.js +1 -1
  49. package/tests/auto_satisfy_criteria.mjs +4 -2
  50. package/tests/criterion_uat.mjs +467 -0
  51. package/tests/criterion_uat_routes.mjs +232 -0
  52. package/tests/find_untested.mjs +137 -0
  53. package/tests/fitness.mjs +3 -1
  54. package/tests/goal_achievement.mjs +4 -2
  55. package/tests/goal_routes.mjs +4 -2
  56. package/tests/ideator_full_idea_shapes_space_proof.mjs +3 -2
  57. package/tests/npm_release_where.mjs +18 -2
  58. package/tests/speciality_session_skills.mjs +150 -0
@@ -0,0 +1,267 @@
1
+ 'use strict';
2
+
3
+ // criterion-uat-db.js — the SQL and the one transaction behind a UAT sign-off
4
+ // (task 1004392, goal 1000089; ADR 0351). The decisions are pure in
5
+ // criterion-uat.js; this file gathers the facts they need, asks, and writes.
6
+ //
7
+ // THE SIGN-OFF IS ONE TRANSACTION: lock the criterion, re-read its linked work
8
+ // under the lock, decide, insert the sign-off row, and close the criterion (and
9
+ // cascade its goal and version) through the SAME autoSatisfyShippedCriteria the
10
+ // ship hook and the reconciler sweep use. One close path, so a criterion closed
11
+ // by a sign-off and one closed by the sweep can never mean different things.
12
+ //
13
+ // THE LIVE READING is taken BEFORE the transaction, because it can reach the npm
14
+ // registry and a network call has no business holding a row lock. It is keyed on
15
+ // the linked shipped task ids read at that moment; the transaction re-reads them
16
+ // under the lock and refuses (`linked_work_changed`) if the set moved in between,
17
+ // so a reading can never be applied to work it did not look at.
18
+
19
+ const api = require('../../src/module-api');
20
+ const doneWhen = require('./done-when.js');
21
+ const uat = require('./criterion-uat.js');
22
+
23
+ // The optional port a deploy-reading module provides (npm-release on the Bongos
24
+ // hall): { readTaskWhere(taskId) → { stage, partial } | null }. Most instances
25
+ // have none, and then the signer attests (the owner's 2026-09-29 decision).
26
+ const DEPLOY_PORT = 'deploy.taskWhere';
27
+
28
+ // Linked tasks with what the ladder needs: status, who shipped it, and when.
29
+ async function linkedTasks(exec, criterionId) {
30
+ const { rows } = await exec.query(
31
+ `SELECT t.id, t.status, t.shipped_by, t.shipped_at
32
+ FROM task_criteria tc
33
+ JOIN tasks t ON t.id = tc.task_id
34
+ WHERE tc.criterion_id = $1
35
+ ORDER BY t.id`,
36
+ [criterionId]
37
+ );
38
+ return rows;
39
+ }
40
+
41
+ // PURE — fold linked task rows into the counts + the open ones + the shippers.
42
+ function summarizeLinked(tasks) {
43
+ const terminal = (s) => s === 'shipped' || s === 'abandoned';
44
+ const shipped = tasks.filter((t) => t.status === 'shipped');
45
+ return {
46
+ counts: {
47
+ linked: tasks.length,
48
+ unshipped: tasks.filter((t) => !terminal(t.status)).length,
49
+ shipped: shipped.length,
50
+ },
51
+ remaining: tasks.filter((t) => !terminal(t.status)),
52
+ shipperIds: [...new Set(shipped.map((t) => t.shipped_by).filter((b) => b != null).map(String))],
53
+ shippedIds: shipped.map((t) => String(t.id)),
54
+ // The newest linked ship: a sign-off older than this is stale (the same rule
55
+ // the auto-close SQL applies with candidate.last_ship).
56
+ lastShip: shipped.reduce((m, t) => (t.shipped_at && (!m || new Date(t.shipped_at) > m) ? new Date(t.shipped_at) : m), null),
57
+ };
58
+ }
59
+
60
+ // PURE — split the sign-offs into the CURRENT one (what counts) and a STALE one
61
+ // (the newest sign-off that predates the newest linked ship). A reader must never
62
+ // see a sign-off beside 'Awaiting UAT' without being told it no longer counts.
63
+ function currentSignoff(signoffs, lastShip) {
64
+ const real = (signoffs || []).filter((r) => r.kind === 'uat' || r.kind === 'backend_signoff');
65
+ const current = real.find((r) => !lastShip || new Date(r.created_at) >= lastShip) || null;
66
+ return { current, stale: current ? null : real[0] || null };
67
+ }
68
+
69
+ async function isBackendOnly(exec, criterionId) {
70
+ const { rows } = await exec.query(
71
+ 'SELECT 1 FROM lifecycle_criterion_backend_only WHERE criterion_id = $1', [criterionId]
72
+ );
73
+ return rows.length > 0;
74
+ }
75
+
76
+ // The deploy readings for these shipped tasks, or null when this instance has
77
+ // no deploy reading (no provider). A provider that throws answers a PARTIAL
78
+ // reading, which foldLiveReadings treats as unavailable — an outage must not
79
+ // block a person, it only moves "is it live" back to their word.
80
+ //
81
+ // The BATCH form (readTasksWhere) is preferred: the registry and release-notes
82
+ // half of the reading does not depend on the task, so a per-task loop re-fetched
83
+ // it once per linked task. The per-task form stays as the fallback for a
84
+ // provider that only offers it.
85
+ async function readLive(taskIds, deps = {}) {
86
+ const port = deps.deployPort !== undefined ? deps.deployPort : api.resolveOptional(DEPLOY_PORT);
87
+ if (!port) return null;
88
+ if (typeof port.readTasksWhere === 'function') {
89
+ let byId;
90
+ try {
91
+ byId = (await port.readTasksWhere(taskIds)) || {};
92
+ } catch {
93
+ return taskIds.map((id) => ({ task_id: id, stage: null, partial: true }));
94
+ }
95
+ return taskIds.map((id) => {
96
+ const r = byId[String(id)];
97
+ return r ? { task_id: id, stage: r.stage, partial: !!r.partial } : { task_id: id, stage: null, partial: true };
98
+ });
99
+ }
100
+ if (typeof port.readTaskWhere !== 'function') return null;
101
+ const readings = [];
102
+ for (const id of taskIds) {
103
+ try {
104
+ const r = await port.readTaskWhere(id);
105
+ readings.push(r ? { task_id: id, stage: r.stage, partial: !!r.partial } : { task_id: id, stage: null, partial: true });
106
+ } catch {
107
+ readings.push({ task_id: id, stage: null, partial: true });
108
+ }
109
+ }
110
+ return readings;
111
+ }
112
+
113
+ // signOff — record a UAT (kind 'uat') or a backend sign-off, and close the
114
+ // criterion if that makes it done. Returns { ok:true, signoff, closed } or
115
+ // { ok:false, refusal } (never throws on a refusal).
116
+ async function signOff({ criterionId, kind, signerId, recording = null, liveAttested = false, note = null }, deps = {}) {
117
+ const id = Number(criterionId);
118
+ if (!uat.SIGNOFF_KINDS.includes(kind) || kind === 'override') {
119
+ return { ok: false, refusal: { code: 'bad_kind', status: 400, message: 'A sign-off is either a UAT or a backend sign-off.' } };
120
+ }
121
+ if (recording != null) {
122
+ if (uat.criterionIdFromName(recording) !== id) {
123
+ return { ok: false, refusal: { code: 'recording_not_for_this_criterion', status: 400, message: 'That recording was uploaded for a different criterion.' } };
124
+ }
125
+ }
126
+
127
+ // Test seams: a fake pool + transaction runner (NODE_ENV=test only, the #538
128
+ // precedent), so the whole ladder and the write run in a DB-free test.
129
+ const testing = process.env.NODE_ENV === 'test';
130
+ const pool = (testing && deps.pool) || api.pool;
131
+ const withTx = (testing && deps.withTx) || api.withTx;
132
+
133
+ // Before the lock: the linked set as it stands now, and its deploy reading.
134
+ const before = summarizeLinked(await linkedTasks(pool, id));
135
+ const readings = await readLive(before.shippedIds, deps);
136
+ const live = uat.foldLiveReadings(readings);
137
+ const owner = deps.ownerId !== undefined ? { id: deps.ownerId } : await api.getFoundingBuilder();
138
+
139
+ return withTx(async (client) => {
140
+ const { rows: crit } = await client.query(
141
+ 'SELECT id, satisfied, goal_id, version_id FROM done_when_criteria WHERE id = $1 FOR UPDATE', [id]
142
+ );
143
+ const criterion = crit[0] || null;
144
+ const now = criterion ? summarizeLinked(await linkedTasks(client, id)) : before;
145
+ if (criterion && now.shippedIds.join(',') !== before.shippedIds.join(',')) {
146
+ return { ok: false, refusal: { code: 'linked_work_changed', status: 409, message: 'The work linked to this criterion changed while you were signing off. Try again.' } };
147
+ }
148
+ const backendOnly = criterion ? await isBackendOnly(client, id) : false;
149
+ const refusal = uat.signoffRefusal({
150
+ criterion, counts: now.counts, remaining: now.remaining, backendOnly, kind,
151
+ signerId, ownerId: owner ? owner.id : null, shipperIds: now.shipperIds,
152
+ recording, live, liveAttested,
153
+ });
154
+ if (refusal) return { ok: false, refusal };
155
+
156
+ const { rows: ins } = await client.query(
157
+ `INSERT INTO lifecycle_criterion_uats (criterion_id, kind, signed_off_by, recording_file, live_basis, note)
158
+ VALUES ($1, $2, $3, $4, $5, $6)
159
+ RETURNING id, criterion_id, kind, signed_off_by, recording_file, live_basis, note, created_at`,
160
+ [id, kind, signerId, recording, uat.liveBasis(live), uat.normalizeNote(note)]
161
+ );
162
+ const closed = await doneWhen.autoSatisfyShippedCriteria(client, { criterionId: id });
163
+ return { ok: true, signoff: ins[0], closed };
164
+ }, { pool });
165
+ }
166
+
167
+ // recordOverride — the /satisfy escape hatch, which now has to give a reason and
168
+ // leaves a row saying so (the state reads "Satisfied (override)", never UAT).
169
+ async function recordOverride(exec, { criterionId, signerId, reason }) {
170
+ const { rows } = await exec.query(
171
+ `INSERT INTO lifecycle_criterion_uats (criterion_id, kind, signed_off_by, live_basis, note)
172
+ VALUES ($1, 'override', $2, 'not_checked', $3)
173
+ RETURNING id, kind, created_at`,
174
+ [criterionId, signerId, uat.normalizeNote(reason)]
175
+ );
176
+ return rows[0];
177
+ }
178
+
179
+ // Every sign-off on a criterion, newest first, with who signed.
180
+ async function listSignoffs(criterionId, deps = {}) {
181
+ const exec = deps.pool || api.pool;
182
+ const { rows } = await exec.query(
183
+ `SELECT u.id, u.kind, u.signed_off_by, b.github_login AS signed_off_by_login,
184
+ u.recording_file, u.live_basis, u.note, u.created_at
185
+ FROM lifecycle_criterion_uats u
186
+ LEFT JOIN builders b ON b.id = u.signed_off_by
187
+ WHERE u.criterion_id = $1
188
+ ORDER BY u.created_at DESC, u.id DESC`,
189
+ [criterionId]
190
+ );
191
+ return rows;
192
+ }
193
+
194
+ // The criterion's full UAT picture for GET /done-when/:id/uat and the skill.
195
+ async function uatView(criterionId, deps = {}) {
196
+ const exec = deps.pool || api.pool;
197
+ const { rows: crit } = await exec.query(
198
+ 'SELECT id, criterion_id, criterion_md, satisfied, goal_id, version_id FROM done_when_criteria WHERE id = $1', [criterionId]
199
+ );
200
+ if (!crit.length) return null;
201
+ const tasks = await linkedTasks(exec, criterionId);
202
+ const s = summarizeLinked(tasks);
203
+ const [signoffs, backendOnly] = await Promise.all([listSignoffs(criterionId, { pool: exec }), isBackendOnly(exec, criterionId)]);
204
+ const state = uat.uatState({ ...s.counts, satisfied: crit[0].satisfied, latestKind: signoffs[0] ? signoffs[0].kind : null });
205
+ return {
206
+ criterion: crit[0],
207
+ uat_state: state,
208
+ uat_label: uat.stateLabel(state),
209
+ backend_only: backendOnly,
210
+ checks: {
211
+ code: uat.codeMet(s.counts),
212
+ live: state === uat.UAT_STATES.AWAITING_UAT ? uat.foldLiveReadings(await readLive(s.shippedIds, deps)) : null,
213
+ // Only a CURRENT sign-off is the UAT check; one that predates newer linked
214
+ // work is reported separately as stale, never as passed.
215
+ uat: currentSignoff(signoffs, s.lastShip).current,
216
+ stale_uat: currentSignoff(signoffs, s.lastShip).stale,
217
+ },
218
+ linked_tasks: tasks.map((t) => ({ id: String(t.id), status: t.status, shipped_by: t.shipped_by == null ? null : String(t.shipped_by) })),
219
+ signoffs,
220
+ };
221
+ }
222
+
223
+ // Mark or unmark backend-only. `authorize(client, criterion)` is the caller's
224
+ // authority gate (the criterion-authoring wall), run INSIDE this transaction with
225
+ // the criterion locked so the decision and the write see the same rows; it
226
+ // returns null to allow or a refusal. This refuses once the criterion is past
227
+ // OPEN, because the flag decides whether a recording is required and must not be
228
+ // chosen by whoever is closing it.
229
+ async function setBackendOnly({ criterionId, builderId, backendOnly, authorize }, deps = {}) {
230
+ const testing = process.env.NODE_ENV === 'test';
231
+ const withTx = (testing && deps.withTx) || api.withTx;
232
+ return withTx(async (client) => {
233
+ const { rows: crit } = await client.query(
234
+ 'SELECT id, satisfied, goal_id, version_id FROM done_when_criteria WHERE id = $1 FOR UPDATE', [criterionId]
235
+ );
236
+ if (!crit.length) return { ok: false, refusal: { code: 'criterion_not_found', status: 404, message: 'No such criterion.' } };
237
+ const denied = await authorize(client, crit[0]);
238
+ if (denied) return { ok: false, refusal: denied };
239
+ const s = summarizeLinked(await linkedTasks(client, criterionId));
240
+ if (crit[0].satisfied || uat.codeMet(s.counts)) {
241
+ return { ok: false, refusal: { code: 'backend_only_locked', status: 409, message: 'Backend-only is set while a criterion is still open. This one is already awaiting UAT or closed, so it can no longer change.' } };
242
+ }
243
+ if (backendOnly) {
244
+ await client.query(
245
+ `INSERT INTO lifecycle_criterion_backend_only (criterion_id, marked_by) VALUES ($1, $2)
246
+ ON CONFLICT (criterion_id) DO NOTHING`, [criterionId, builderId]
247
+ );
248
+ } else {
249
+ await client.query('DELETE FROM lifecycle_criterion_backend_only WHERE criterion_id = $1', [criterionId]);
250
+ }
251
+ return { ok: true, backend_only: !!backendOnly };
252
+ }, { pool: (testing && deps.pool) || api.pool });
253
+ }
254
+
255
+ module.exports = {
256
+ DEPLOY_PORT,
257
+ linkedTasks,
258
+ summarizeLinked,
259
+ currentSignoff,
260
+ isBackendOnly,
261
+ readLive,
262
+ signOff,
263
+ recordOverride,
264
+ listSignoffs,
265
+ uatView,
266
+ setBackendOnly,
267
+ };
@@ -0,0 +1,303 @@
1
+ 'use strict';
2
+
3
+ // criterion-uat.js — the UAT a criterion must pass before it closes, as pure
4
+ // decisions plus the recording store (task 1004392, goal 1000089; ADR 0351).
5
+ //
6
+ // WHY. ADR 0183 let a criterion close the moment every task linked to it
7
+ // shipped. That checked that SOME work shipped, never that the work did what the
8
+ // criterion's words say, or that it reached the live site. wa7-government closed
9
+ // on six Board Room navigation tasks while the configurable ranks and project-
10
+ // type recommendations it describes did not exist on the live site. So shipped
11
+ // code is now the first of three checks:
12
+ //
13
+ // Code every linked task is done (>=1 shipped, none still open) — automatic
14
+ // Live the shipped work is running on the deployed site — read where the
15
+ // instance can tell, otherwise attested by the signer
16
+ // UAT a signed-in person performed the criterion on the live site and a
17
+ // recording is stored; a BACKEND-ONLY criterion has no screen, so it takes
18
+ // a recording-free sign-off instead
19
+ //
20
+ // A criterion whose Code check passes reads AWAITING UAT (the owner's
21
+ // 2026-09-29 ask: people must see that the baseline looks met). It closes only
22
+ // when a CURRENT sign-off exists — one no older than the newest linked ship,
23
+ // because work shipped after a sign-off is work nobody checked.
24
+ //
25
+ // WHO MAY SIGN OFF. Anyone who holds the review permission AND did not ship any
26
+ // of the criterion's linked tasks. The project owner is the one exception, so a
27
+ // solo project can never deadlock on its own work (the owner's "any other
28
+ // builder or the owner").
29
+ //
30
+ // This file has NO database and NO express: the state derivation and the
31
+ // refusal ladder are pure so every answer is pinned by a DB-free test, and the
32
+ // store unit-tests against a temp dir. The SQL lives in criterion-uat-db.js.
33
+ //
34
+ // THE RECORDING STORE mirrors task-visuals.js (the same allowlist + magic-byte
35
+ // sniff + server-minted name + resolveSafe shape, re-stated rather than shared
36
+ // because the two differ in kind and cap), for video instead of images.
37
+
38
+ const path = require('node:path');
39
+ const fs = require('node:fs/promises');
40
+ const fsSync = require('node:fs');
41
+ const crypto = require('node:crypto');
42
+
43
+ // ── the states ──────────────────────────────────────────────────────────────
44
+
45
+ // One vocabulary for every reader (the goal page, /status, the sky, the skill).
46
+ const UAT_STATES = Object.freeze({
47
+ OPEN: 'open', // linked work still open, or none linked
48
+ AWAITING_UAT: 'awaiting_uat', // Code passed; no current sign-off
49
+ SATISFIED_UAT: 'satisfied_uat', // closed on a recorded UAT
50
+ SATISFIED_BACKEND: 'satisfied_backend', // closed on a backend-only sign-off
51
+ SATISFIED_OVERRIDE: 'satisfied_override', // closed through /satisfy with a reason
52
+ CLOSED_BEFORE_UAT: 'closed_before_uat', // satisfied before this rule existed
53
+ });
54
+
55
+ const SIGNOFF_KINDS = Object.freeze(['uat', 'backend_signoff', 'override']);
56
+
57
+ // PURE — has the Code check passed? Every linked task is terminal and at least
58
+ // one actually SHIPPED. The second clause is ADR 0183's delivery guard, kept: a
59
+ // criterion whose linked tasks were all ABANDONED delivered nothing, so it is not
60
+ // awaiting a UAT — it is waiting for a person to decide whether it still means
61
+ // anything (the /goal-review residue).
62
+ function codeMet({ linked, unshipped, shipped }) {
63
+ return Number(linked) > 0 && Number(unshipped) === 0 && Number(shipped) >= 1;
64
+ }
65
+
66
+ // PURE — the one derivation of a criterion's state. `latestKind` is the kind of
67
+ // its newest sign-off row (null when it has none). A satisfied criterion with no
68
+ // row closed before this rule existed, which the owner chose to leave closed.
69
+ function uatState({ satisfied, linked, unshipped, shipped, latestKind = null }) {
70
+ if (satisfied) {
71
+ if (latestKind === 'uat') return UAT_STATES.SATISFIED_UAT;
72
+ if (latestKind === 'backend_signoff') return UAT_STATES.SATISFIED_BACKEND;
73
+ if (latestKind === 'override') return UAT_STATES.SATISFIED_OVERRIDE;
74
+ return UAT_STATES.CLOSED_BEFORE_UAT;
75
+ }
76
+ return codeMet({ linked, unshipped, shipped }) ? UAT_STATES.AWAITING_UAT : UAT_STATES.OPEN;
77
+ }
78
+
79
+ // PURE — the plain-words label for a state, so the CLI, the skill and the API
80
+ // say the same thing.
81
+ function stateLabel(state) {
82
+ return {
83
+ open: 'Open',
84
+ awaiting_uat: 'Awaiting UAT',
85
+ satisfied_uat: 'Satisfied (UAT)',
86
+ satisfied_backend: 'Satisfied (backend sign-off)',
87
+ satisfied_override: 'Satisfied (override)',
88
+ closed_before_uat: 'Closed before UAT',
89
+ }[state] || String(state);
90
+ }
91
+
92
+ // ── the Live check ──────────────────────────────────────────────────────────
93
+
94
+ // The deploy stages (modules/npm-release/work.js placeTasks) that mean "this
95
+ // task's code is running on this site".
96
+ const LIVE_STAGES = Object.freeze(['live_unreleased', 'released']);
97
+
98
+ // PURE — fold per-task deploy readings into one answer. `readings` is
99
+ // [{ task_id, stage, partial }] for every linked SHIPPED task, or null when the
100
+ // instance has no deploy reading at all. Answers:
101
+ // { available:false } nothing to read here → the signer attests
102
+ // { available:true, live:true } every shipped task runs here
103
+ // { available:true, live:false, not_live } the tasks that do not, yet
104
+ // A PARTIAL reading (the notes or the registry could not be read) is treated as
105
+ // unavailable rather than as "not live": refusing a sign-off because a reading
106
+ // failed would block a person on an outage, and attestation is the honest
107
+ // fallback the owner chose for instances that cannot read it at all.
108
+ function foldLiveReadings(readings) {
109
+ if (!Array.isArray(readings)) return { available: false };
110
+ if (readings.some((r) => !r || r.partial)) return { available: false };
111
+ const notLive = readings.filter((r) => !LIVE_STAGES.includes(r.stage)).map((r) => ({ task_id: String(r.task_id), stage: r.stage || null }));
112
+ return notLive.length ? { available: true, live: false, not_live: notLive } : { available: true, live: true };
113
+ }
114
+
115
+ // ── the sign-off refusal ladder ─────────────────────────────────────────────
116
+
117
+ // PURE — may this person sign this criterion off now? Returns null (allowed) or
118
+ // { code, status, message, ...detail }. The ORDER is the order a person would
119
+ // want to be told: is there anything to sign, is it the right kind of sign-off,
120
+ // are you allowed to, is the evidence there, is it live.
121
+ //
122
+ // criterion { satisfied }
123
+ // counts { linked, unshipped, shipped }
124
+ // remaining [{ id, status }] linked tasks still open (named in the refusal)
125
+ // backendOnly boolean
126
+ // kind 'uat' | 'backend_signoff'
127
+ // signerId the authenticated builder
128
+ // ownerId the project owner (getFoundingBuilder), or null
129
+ // shipperIds builder ids that shipped a linked task
130
+ // recording a stored recording name, or null
131
+ // live foldLiveReadings(...) output
132
+ // liveAttested the signer ticked "this is on the live site"
133
+ function signoffRefusal({ criterion, counts, remaining = [], backendOnly, kind, signerId, ownerId = null, shipperIds = [], recording = null, live = { available: false }, liveAttested = false }) {
134
+ if (!criterion) return { code: 'criterion_not_found', status: 404, message: 'No such criterion.' };
135
+ if (criterion.satisfied) {
136
+ return { code: 'criterion_already_satisfied', status: 409, message: 'This criterion is already closed.' };
137
+ }
138
+ if (!codeMet(counts)) {
139
+ return {
140
+ code: 'criterion_not_awaiting_uat', status: 409,
141
+ message: Number(counts.linked) === 0
142
+ ? 'This criterion has no linked tasks, so there is no shipped work to test yet. Link the tasks that deliver it first.'
143
+ : Number(counts.shipped) === 0 && Number(counts.unshipped) === 0
144
+ ? 'Every task linked to this criterion was abandoned, so nothing was delivered to test. Decide in /goal-review whether it still means anything.'
145
+ : 'Some linked work is still open. A UAT tests finished work, so it opens once every linked task is done.',
146
+ remaining: remaining.map((t) => ({ id: String(t.id), status: t.status })),
147
+ };
148
+ }
149
+ if (kind === 'uat' && backendOnly) {
150
+ return { code: 'criterion_is_backend_only', status: 409, message: 'This criterion is marked backend-only, so it takes a backend sign-off, not a recording.' };
151
+ }
152
+ if (kind === 'backend_signoff' && !backendOnly) {
153
+ return { code: 'criterion_needs_recording', status: 409, message: 'This criterion is user-facing, so it needs a UAT recording. Only a criterion marked backend-only when it was written can be signed off without one.' };
154
+ }
155
+ const signer = String(signerId);
156
+ const isOwner = ownerId != null && String(ownerId) === signer;
157
+ if (!isOwner && shipperIds.map(String).includes(signer)) {
158
+ return { code: 'signer_shipped_this_work', status: 403, message: 'You shipped work linked to this criterion, so someone else has to sign it off. Any other reviewer, or the project owner, can.' };
159
+ }
160
+ if (kind === 'uat' && !recording) {
161
+ return { code: 'uat_recording_required', status: 400, message: 'Upload the recording of you doing this on the live site first.' };
162
+ }
163
+ if (live.available && !live.live) {
164
+ return { code: 'work_not_live', status: 409, message: 'Some of this work is shipped but not running on the site yet, so it cannot be tested there. Sign off once it is deployed.', not_live: live.not_live || [] };
165
+ }
166
+ if (!live.available && !liveAttested) {
167
+ return { code: 'live_attestation_required', status: 400, message: 'This site cannot tell on its own whether the work is live. Confirm that you tested it on the live site.' };
168
+ }
169
+ return null;
170
+ }
171
+
172
+ // PURE — how "it is live" was established, for the stored row.
173
+ function liveBasis(live) {
174
+ return live && live.available ? 'deploy_reading' : 'attested';
175
+ }
176
+
177
+ // ── the recording store ─────────────────────────────────────────────────────
178
+
179
+ const EXT_BY_TYPE = Object.freeze({ 'video/mp4': 'mp4', 'video/webm': 'webm' });
180
+ const TYPE_BY_EXT = Object.freeze({ mp4: 'video/mp4', webm: 'video/webm' });
181
+ const MAX_RECORDING_BYTES = 100 * 1024 * 1024; // the owner's cap, 2026-09-29
182
+ const MAX_NOTE_LENGTH = 2000;
183
+
184
+ // Server-minted, carries the criterion id. This regex is the whole path-traversal
185
+ // wall, and it matches the column CHECK in lifecycle_015.
186
+ const NAME_RE = /^uat-(\d{1,12})-[0-9a-f]{16}\.(mp4|webm)$/;
187
+
188
+ // Outside the git tree so a deploy never wipes it; env override for tests.
189
+ function recordingsDir() {
190
+ return process.env.UAT_RECORDINGS_DIR || path.join(__dirname, '..', '..', 'var', 'uat-recordings');
191
+ }
192
+
193
+ function isSafeName(name) {
194
+ return typeof name === 'string' && NAME_RE.test(name);
195
+ }
196
+
197
+ function criterionIdFromName(name) {
198
+ const m = typeof name === 'string' ? NAME_RE.exec(name) : null;
199
+ return m ? Number(m[1]) : null;
200
+ }
201
+
202
+ function resolveSafe(name, { dir = recordingsDir() } = {}) {
203
+ if (!isSafeName(name)) return null;
204
+ const root = path.resolve(dir);
205
+ const p = path.resolve(root, name);
206
+ if (p !== path.join(root, name) || !p.startsWith(root + path.sep)) return null;
207
+ return p;
208
+ }
209
+
210
+ function contentTypeForName(name) {
211
+ return TYPE_BY_EXT[String(name).split('.').pop().toLowerCase()] || 'application/octet-stream';
212
+ }
213
+
214
+ // PURE — are these bytes really the claimed container? mp4 carries an `ftyp` box
215
+ // at offset 4; webm is EBML (1A 45 DF A3).
216
+ function sniffMatches(buf, ext) {
217
+ if (!buf || buf.length < 12) return false;
218
+ if (ext === 'mp4') return buf[4] === 0x66 && buf[5] === 0x74 && buf[6] === 0x79 && buf[7] === 0x70;
219
+ if (ext === 'webm') return buf[0] === 0x1a && buf[1] === 0x45 && buf[2] === 0xdf && buf[3] === 0xa3;
220
+ return false;
221
+ }
222
+
223
+ // PURE — screen one upload. { ok:true, ext } or { ok:false, reason }; never throws.
224
+ function screenRecording(buf, contentType) {
225
+ const ext = EXT_BY_TYPE[String(contentType || '').split(';')[0].trim().toLowerCase()];
226
+ if (!ext) return { ok: false, reason: 'bad_type' };
227
+ if (!buf || buf.length === 0) return { ok: false, reason: 'empty' };
228
+ if (buf.length > MAX_RECORDING_BYTES) return { ok: false, reason: 'too_big', bytes: buf.length };
229
+ if (!sniffMatches(buf, ext)) return { ok: false, reason: 'sniff_mismatch' };
230
+ return { ok: true, ext };
231
+ }
232
+
233
+ // The words for a refused upload, shared by the route and the CLI.
234
+ function screenMessage(reason) {
235
+ return {
236
+ bad_type: 'A UAT recording must be an mp4 or webm video.',
237
+ empty: 'The file is empty.',
238
+ too_big: `The recording is over the ${MAX_RECORDING_BYTES / 1024 / 1024} MB cap. Record a shorter pass, or lower the resolution.`,
239
+ sniff_mismatch: 'The file does not look like the video type it claims to be.',
240
+ }[reason] || 'The recording was refused. Try recording it again as an mp4 or webm.';
241
+ }
242
+
243
+ async function persistRecording(buf, { criterionId, contentType, dir = recordingsDir() } = {}) {
244
+ const id = Number(criterionId);
245
+ if (!Number.isInteger(id) || id <= 0 || String(id).length > 12) return { ok: false, reason: 'bad_criterion_id' };
246
+ const screened = screenRecording(buf, contentType);
247
+ if (!screened.ok) return screened;
248
+ const file = `uat-${id}-${crypto.randomBytes(8).toString('hex')}.${screened.ext}`;
249
+ await fs.mkdir(dir, { recursive: true });
250
+ await fs.writeFile(path.join(dir, file), buf, { mode: 0o640 });
251
+ return { ok: true, file, bytes: buf.length };
252
+ }
253
+
254
+ // The CLI intake: read a recording off disk and screen it exactly as the server
255
+ // will. { ok:true, buf, contentType } or { ok:false, message }.
256
+ function readRecordingFile(file) {
257
+ const resolved = path.resolve(String(file || ''));
258
+ let buf;
259
+ try {
260
+ if (!fsSync.statSync(resolved).isFile()) return { ok: false, message: 'that path is not a file.' };
261
+ buf = fsSync.readFileSync(resolved);
262
+ } catch (err) {
263
+ return { ok: false, message: err && err.code === 'ENOENT' ? 'no such file.' : (err && err.message) || 'could not be read.' };
264
+ }
265
+ const contentType = TYPE_BY_EXT[path.extname(resolved).slice(1).toLowerCase()];
266
+ if (!contentType) return { ok: false, message: 'a UAT recording must be an .mp4 or .webm file.' };
267
+ const screened = screenRecording(buf, contentType);
268
+ if (!screened.ok) return { ok: false, message: screenMessage(screened.reason) };
269
+ return { ok: true, buf, contentType };
270
+ }
271
+
272
+ // PURE — a note: trimmed, bounded; empty → null.
273
+ function normalizeNote(note) {
274
+ const s = String(note == null ? '' : note).trim().slice(0, MAX_NOTE_LENGTH);
275
+ return s || null;
276
+ }
277
+
278
+ module.exports = {
279
+ UAT_STATES,
280
+ SIGNOFF_KINDS,
281
+ LIVE_STAGES,
282
+ codeMet,
283
+ uatState,
284
+ stateLabel,
285
+ foldLiveReadings,
286
+ signoffRefusal,
287
+ liveBasis,
288
+ EXT_BY_TYPE,
289
+ TYPE_BY_EXT,
290
+ MAX_RECORDING_BYTES,
291
+ MAX_NOTE_LENGTH,
292
+ recordingsDir,
293
+ isSafeName,
294
+ criterionIdFromName,
295
+ resolveSafe,
296
+ contentTypeForName,
297
+ sniffMatches,
298
+ screenRecording,
299
+ screenMessage,
300
+ persistRecording,
301
+ readRecordingFile,
302
+ normalizeNote,
303
+ };
@@ -819,9 +819,10 @@ async function listCriteriaForGoal(goalId, deps = {}) {
819
819
  // unchanged apart from the two additive count columns.
820
820
  const { rows } = await activePool.query(
821
821
  `WITH crit AS (
822
- SELECT id, version_id, criterion_id, criterion_md, satisfied, sort_order, goal_id
823
- FROM done_when_criteria
824
- WHERE goal_id = $1
822
+ SELECT d.id, d.version_id, d.criterion_id, d.criterion_md, d.satisfied, d.sort_order, d.goal_id,
823
+ ${doneWhen.uatReadColumnsSql('d')}
824
+ FROM done_when_criteria d
825
+ WHERE d.goal_id = $1
825
826
  ),
826
827
  counts AS (
827
828
  SELECT tc.criterion_id,
@@ -829,27 +830,39 @@ async function listCriteriaForGoal(goalId, deps = {}) {
829
830
  -- task 1002651 (migration 017's family): abandoned is a terminal
830
831
  -- state (deliberately cut), not pending work — it must not hold a
831
832
  -- criterion's "met — pending review" flip hostage forever.
832
- count(*) FILTER (WHERE ${nonTerminalSql()})::int AS unshipped_tasks
833
+ count(*) FILTER (WHERE ${nonTerminalSql()})::int AS unshipped_tasks,
834
+ count(*) FILTER (WHERE t.status = 'shipped')::int AS shipped_tasks
833
835
  FROM task_criteria tc
834
836
  JOIN tasks t ON t.id = tc.task_id
835
837
  WHERE tc.criterion_id IN (SELECT id FROM crit)
836
838
  GROUP BY tc.criterion_id
837
839
  )
838
840
  SELECT c.id, c.version_id, c.criterion_id, c.criterion_md, c.satisfied,
839
- c.sort_order, c.goal_id,
841
+ c.sort_order, c.goal_id, c.uat_kind, c.backend_only,
840
842
  COALESCE(n.linked_tasks, 0) AS linked_tasks,
841
- COALESCE(n.unshipped_tasks, 0) AS unshipped_tasks
843
+ COALESCE(n.unshipped_tasks, 0) AS unshipped_tasks,
844
+ COALESCE(n.shipped_tasks, 0) AS shipped_tasks
842
845
  FROM crit c
843
846
  LEFT JOIN counts n ON n.criterion_id = c.id
844
847
  ORDER BY sort_order, id`,
845
848
  [goalId]
846
849
  );
847
- return rows.map((r) => ({
848
- ...r,
849
- pending_review: doneWhen.isPendingReview({
850
- satisfied: r.satisfied, linked: r.linked_tasks, unshipped: r.unshipped_tasks,
851
- }),
852
- }));
850
+ // task 1004392: the goal page reads each criterion's UAT state beside the
851
+ // older pending-review flag (piece 2 relabels the page; the fact is here now).
852
+ return rows.map(({ uat_kind: latestKind, ...r }) => {
853
+ const uat_state = doneWhen.uatState({
854
+ satisfied: r.satisfied, linked: r.linked_tasks, unshipped: r.unshipped_tasks, shipped: r.shipped_tasks, latestKind,
855
+ });
856
+ return {
857
+ ...r,
858
+ backend_only: r.backend_only === true,
859
+ pending_review: doneWhen.isPendingReview({
860
+ satisfied: r.satisfied, linked: r.linked_tasks, unshipped: r.unshipped_tasks,
861
+ }),
862
+ uat_state,
863
+ awaiting_uat: uat_state === 'awaiting_uat',
864
+ };
865
+ });
853
866
  }
854
867
 
855
868
  // listCriterionTextForGoals — every goal's done-when criterion prose, keyed by