@bongos/core 1.20.4 → 1.20.6

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 +103 -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/0015-task-dependencies-and-auto-promotion.md +2 -0
  10. package/docs/adr/0183-criteria-close-themselves.md +1 -1
  11. package/docs/adr/0310-a-speciality-offers-skills-and-the-adopter-chooses-them.md +1 -1
  12. package/docs/adr/0351-a-criterion-closes-on-a-uat.md +73 -0
  13. package/docs/adr/README.md +28 -0
  14. package/docs/api/openapi.json +385 -5
  15. package/docs/api-reference.md +14 -4
  16. package/docs/architecture.md +7 -0
  17. package/docs/copy-inventory.md +22 -22
  18. package/docs/copy-registry.json +23 -23
  19. package/docs/file-map.md +2 -1
  20. package/docs/module-api-changelog.md +4 -0
  21. package/docs/page-readings.json +3 -3
  22. package/modules/hall-ui/public/goals-page.js +8 -3
  23. package/modules/hall-ui/public/tweak-editor.css +4 -7
  24. package/modules/hall-ui/public/tweak-editor.html +3 -3
  25. package/modules/hall-ui/public/tweak-editor.js +3 -0
  26. package/modules/lifecycle/criterion-uat-db.js +267 -0
  27. package/modules/lifecycle/criterion-uat.js +303 -0
  28. package/modules/lifecycle/db-goals.js +25 -12
  29. package/modules/lifecycle/done-when.js +84 -17
  30. package/modules/lifecycle/migrations/lifecycle_015_criterion_uat.sql +73 -0
  31. package/modules/lifecycle/module.json +1 -0
  32. package/modules/lifecycle/routes/criterion-uat.js +169 -0
  33. package/modules/lifecycle/routes/done-when.js +25 -1
  34. package/modules/npm-release/module.json +3 -1
  35. package/modules/npm-release/routes/task-where.js +18 -1
  36. package/modules/npm-release/work.js +38 -7
  37. package/modules/specialities/routes/specialities.js +8 -1
  38. package/modules/specialities/specialities.js +26 -1
  39. package/package-lock.json +2 -2
  40. package/package.json +1 -1
  41. package/release-notes.json +24 -0
  42. package/scripts/gds/cli-lib.js +4 -1
  43. package/scripts/gds/fitness-checks-write-validation.js +4 -0
  44. package/scripts/gds/status.js +14 -3
  45. package/scripts/gds/uat.js +120 -0
  46. package/src/bongos/route-rank-check.js +9 -0
  47. package/src/module-api.js +1 -1
  48. package/tests/auto_satisfy_criteria.mjs +4 -2
  49. package/tests/criterion_uat.mjs +467 -0
  50. package/tests/criterion_uat_routes.mjs +232 -0
  51. package/tests/fitness.mjs +3 -1
  52. package/tests/goal_achievement.mjs +4 -2
  53. package/tests/goal_routes.mjs +4 -2
  54. package/tests/ideator_full_idea_shapes_space_proof.mjs +3 -2
  55. package/tests/npm_release_where.mjs +18 -2
  56. package/tests/speciality_session_skills.mjs +150 -0
@@ -31,6 +31,20 @@ const { executorFor } = require('./db-executor.js');
31
31
  // here creates no cycle (db-versions.js requires THIS file, which is why the
32
32
  // rule could not live beside closeVersion).
33
33
  const { autoCloseVersionsForGoals } = require('./version-autoclose.js');
34
+ // task 1004392 — the UAT state derivation. Pure and dependency-free, so no cycle.
35
+ const { uatState, UAT_STATES } = require('./criterion-uat.js');
36
+
37
+ // The two UAT columns every criterion read carries, as correlated subqueries on
38
+ // the done_when_criteria row named by `alias`: the kind of its newest sign-off (null =
39
+ // none) and whether it is marked backend-only. Declared once so the /status
40
+ // rollup, the goal page and the review queue read the same two facts.
41
+ function uatReadColumnsSql(alias) {
42
+ return `(SELECT u.kind FROM lifecycle_criterion_uats u
43
+ WHERE u.criterion_id = ${alias}.id
44
+ ORDER BY u.created_at DESC, u.id DESC LIMIT 1) AS uat_kind,
45
+ EXISTS (SELECT 1 FROM lifecycle_criterion_backend_only b
46
+ WHERE b.criterion_id = ${alias}.id) AS backend_only`;
47
+ }
34
48
 
35
49
  // isPendingReview — the ONE derivation of the "met — pending review" auto-flag
36
50
  // (ADR 0086 §6 / BV1.R63): a criterion with >=1 linked task, every one shipped,
@@ -137,7 +151,8 @@ async function listPendingReviewCriteria({ versionId = null } = {}, deps = {}) {
137
151
  const { rows } = await activePool.query(
138
152
  `WITH pending AS (
139
153
  SELECT c.id, c.version_id, c.criterion_id, c.criterion_md, c.goal_id, c.sort_order,
140
- count(tc.task_id)::int AS task_total
154
+ count(tc.task_id)::int AS task_total,
155
+ count(*) FILTER (WHERE t.status = 'shipped')::int AS shipped_total
141
156
  FROM done_when_criteria c
142
157
  JOIN task_criteria tc ON tc.criterion_id = c.id
143
158
  JOIN tasks t ON t.id = tc.task_id
@@ -152,7 +167,8 @@ async function listPendingReviewCriteria({ versionId = null } = {}, deps = {}) {
152
167
  GROUP BY goal_id
153
168
  )
154
169
  SELECT p.id, p.version_id, p.criterion_id, p.criterion_md, p.goal_id,
155
- p.sort_order, p.task_total,
170
+ p.sort_order, p.task_total, p.shipped_total,
171
+ ${uatReadColumnsSql('p')},
156
172
  g.title AS goal_title,
157
173
  g.status AS goal_status,
158
174
  COALESCE(gu.unsatisfied = 1, false) AS is_last_in_goal
@@ -163,7 +179,16 @@ async function listPendingReviewCriteria({ versionId = null } = {}, deps = {}) {
163
179
  ORDER BY p.version_id, p.sort_order, p.id`,
164
180
  [versionId]
165
181
  );
166
- return rows;
182
+ // task 1004392: every row carries its UAT state. The queue holds two kinds of
183
+ // row now — AWAITING UAT (delivered, waiting for a sign-off: /goal-uat) and the
184
+ // all-abandoned residue (nothing delivered: /goal-review decides it).
185
+ return rows.map((r) => {
186
+ const { uat_kind: latestKind, ...row } = r;
187
+ const uat_state = uatState({
188
+ satisfied: false, linked: r.task_total, unshipped: 0, shipped: r.shipped_total, latestKind,
189
+ });
190
+ return { ...row, backend_only: r.backend_only === true, uat_state, awaiting_uat: uat_state === UAT_STATES.AWAITING_UAT };
191
+ });
167
192
  }
168
193
 
169
194
  // Roll up every criterion for a version together with the tasks that gate it
@@ -197,12 +222,14 @@ async function criterionProgress(versionId) {
197
222
  const [{ rows }, { rows: unattr }, goalRows] = await Promise.all([
198
223
  pool.query(
199
224
  `WITH ranked AS (
200
- SELECT id, version_id, criterion_id, criterion_md, satisfied, goal_id,
201
- ROW_NUMBER() OVER (ORDER BY sort_order, criterion_id) AS cnum
202
- FROM done_when_criteria
203
- WHERE version_id = $1
225
+ SELECT d.id, d.version_id, d.criterion_id, d.criterion_md, d.satisfied, d.goal_id,
226
+ ROW_NUMBER() OVER (ORDER BY d.sort_order, d.criterion_id) AS cnum,
227
+ ${uatReadColumnsSql('d')}
228
+ FROM done_when_criteria d
229
+ WHERE d.version_id = $1
204
230
  )
205
231
  SELECT r.cnum, r.id, r.criterion_id, r.criterion_md, r.satisfied, r.goal_id,
232
+ r.uat_kind, r.backend_only,
206
233
  t.id AS task_id, t.title AS task_title, t.status AS task_status
207
234
  FROM ranked r
208
235
  LEFT JOIN task_criteria tc ON tc.criterion_id = r.id
@@ -275,6 +302,8 @@ function buildCriterionProgress(versionId, rows, unattributedCount = 0, goalRows
275
302
  criterion_md: row.criterion_md,
276
303
  satisfied: row.satisfied,
277
304
  goal_id: row.goal_id ?? null,
305
+ backend_only: row.backend_only === true,
306
+ uat_kind: row.uat_kind ?? null,
278
307
  tasks: [],
279
308
  };
280
309
  byCrit.set(row.id, c);
@@ -300,7 +329,14 @@ function buildCriterionProgress(versionId, rows, unattributedCount = 0, goalRows
300
329
  const pending_review = isPendingReview({
301
330
  satisfied: c.satisfied, linked: c.tasks.length, unshipped: remaining.length,
302
331
  });
303
- return { ...c, counts: { ...counts, shipped, remaining: remaining.length }, remaining, pending_review };
332
+ // task 1004392: the UAT state (Awaiting UAT once the Code check passes).
333
+ // NARROWER than pending_review on purpose — an all-abandoned criterion is
334
+ // flagged for a person but is not awaiting a UAT, since nothing was delivered.
335
+ const { uat_kind: uatKind, ...rest } = c;
336
+ const uat_state = uatState({
337
+ satisfied: c.satisfied, linked: c.tasks.length, unshipped: remaining.length, shipped, latestKind: uatKind,
338
+ });
339
+ return { ...rest, counts: { ...counts, shipped, remaining: remaining.length }, remaining, pending_review, uat_state, awaiting_uat: uat_state === UAT_STATES.AWAITING_UAT };
304
340
  });
305
341
 
306
342
  // Version → Goals → Criteria (ADR 0086 §2). A version is "done" when it HAS
@@ -331,7 +367,10 @@ function buildCriterionProgress(versionId, rows, unattributedCount = 0, goalRows
331
367
  // fulfilled the criterion (UI improvement, not required by the gate).
332
368
  //
333
369
  // Returns the updated row, or null if no row matched.
334
- async function markCriterion(criterionId, satisfiedByTaskId = null) {
370
+ // deps.client (task 1004392): the override route writes its override row and
371
+ // this flip in ONE transaction, so an override can never be recorded without
372
+ // the criterion it overrode actually closing.
373
+ async function markCriterion(criterionId, satisfiedByTaskId = null, deps = {}) {
335
374
  const id = Number(criterionId);
336
375
  if (!Number.isFinite(id)) return null;
337
376
  const taskId =
@@ -341,7 +380,7 @@ async function markCriterion(criterionId, satisfiedByTaskId = null) {
341
380
  if (taskId != null && !Number.isFinite(taskId)) {
342
381
  throw new Error('markCriterion: satisfiedByTaskId must be numeric');
343
382
  }
344
- const { rows } = await pool.query(
383
+ const { rows } = await executorFor(deps, pool).query(
345
384
  `UPDATE done_when_criteria
346
385
  SET satisfied = true,
347
386
  satisfied_by_task_id = $2,
@@ -381,10 +420,10 @@ async function unmarkCriterion(criterionId) {
381
420
  // -------------------------------------------------------------------------
382
421
 
383
422
  // autoSatisfyShippedCriteria — flip every criterion whose gating set is fully
384
- // DELIVERED to satisfied=true, then cascade any goal whose criteria are now all
385
- // satisfied to `achieved`. This retires the manual /goal-review confirm step as
386
- // the ONLY way a criterion closes (owner request 2026-07-05): the review queue
387
- // becomes a place to look, not a gate to pass.
423
+ // DELIVERED and carries a current UAT sign-off to satisfied=true, then cascade
424
+ // any goal whose criteria are now all satisfied to `achieved`. (Until task
425
+ // 1004392 delivery alone closed it — owner request 2026-07-05, ADR 0183; the
426
+ // owner reversed that on 2026-09-29, see the UAT GATE note below.)
388
427
  //
389
428
  // `exec` is a pg client OR the pool — the caller decides. That is the whole
390
429
  // design: shipTask passes its OPEN TRANSACTION CLIENT so the criterion flip and
@@ -418,24 +457,46 @@ async function unmarkCriterion(criterionId) {
418
457
  //
419
458
  // Returns { criteria: [...], goals: [...] } — the rows actually changed, so a
420
459
  // caller can log or broadcast exactly what closed (empty arrays on a no-op).
421
- async function autoSatisfyShippedCriteria(exec, { taskId = null } = {}) {
460
+ //
461
+ // ⚠ THE UAT GATE (task 1004392, ADR 0351 — supersedes ADR 0183's "shipped is
462
+ // enough"). Delivery is now only the CODE check. A criterion closes here only
463
+ // when it ALSO carries a CURRENT sign-off in lifecycle_criterion_uats: a `uat`
464
+ // (recorded on the live site) or a `backend_signoff`, created no earlier than the
465
+ // newest linked ship. The recency clause is the point, not a detail: a sign-off
466
+ // watched the work as it stood; a task linked and shipped after it is work
467
+ // nobody checked, so that criterion waits for a fresh UAT. An `override` row is
468
+ // not a candidate here because the override route satisfies directly.
469
+ //
470
+ // So the ship hook and the reconciler sweep keep running and keep their goal and
471
+ // version cascade, and simply find nothing to close until someone signs off. The
472
+ // third scope, { criterionId }, is the sign-off route's own call, inside its
473
+ // transaction, so the criterion (and its goal) close in the same commit as the
474
+ // sign-off that closed them.
475
+ async function autoSatisfyShippedCriteria(exec, { taskId = null, criterionId = null } = {}) {
422
476
  const scopedTaskId = taskId == null ? null : Number(taskId);
423
477
  if (scopedTaskId != null && !Number.isFinite(scopedTaskId)) {
424
478
  throw new Error('autoSatisfyShippedCriteria: taskId must be numeric');
425
479
  }
480
+ const scopedCriterionId = criterionId == null ? null : Number(criterionId);
481
+ if (scopedCriterionId != null && !Number.isFinite(scopedCriterionId)) {
482
+ throw new Error('autoSatisfyShippedCriteria: criterionId must be numeric');
483
+ }
426
484
  const { rows: criteria } = await exec.query(
427
485
  `WITH candidate AS (
428
486
  SELECT c.id,
429
487
  -- Attribute the close to a task that actually shipped. In scoped
430
488
  -- mode that is the shipping task; unscoped, the highest shipped id
431
489
  -- (deterministic, and the most recent contributor).
432
- max(tc.task_id) FILTER (WHERE t.status = 'shipped') AS by_task
490
+ max(tc.task_id) FILTER (WHERE t.status = 'shipped') AS by_task,
491
+ -- The newest linked ship: a sign-off older than this is stale.
492
+ max(t.shipped_at) FILTER (WHERE t.status = 'shipped') AS last_ship
433
493
  FROM done_when_criteria c
434
494
  JOIN task_criteria tc ON tc.criterion_id = c.id
435
495
  JOIN tasks t ON t.id = tc.task_id
436
496
  WHERE c.satisfied = false
437
497
  AND ($1::bigint IS NULL OR c.id IN (
438
498
  SELECT criterion_id FROM task_criteria WHERE task_id = $1::bigint))
499
+ AND ($2::bigint IS NULL OR c.id = $2::bigint)
439
500
  GROUP BY c.id
440
501
  HAVING count(*) FILTER (WHERE ${nonTerminalSql()}) = 0
441
502
  AND count(*) FILTER (WHERE t.status = 'shipped') >= 1
@@ -446,9 +507,13 @@ async function autoSatisfyShippedCriteria(exec, { taskId = null } = {}) {
446
507
  checked_at = now()
447
508
  FROM candidate
448
509
  WHERE c.id = candidate.id
510
+ AND EXISTS (SELECT 1 FROM lifecycle_criterion_uats u
511
+ WHERE u.criterion_id = candidate.id
512
+ AND u.kind IN ('uat', 'backend_signoff')
513
+ AND u.created_at >= COALESCE(candidate.last_ship, '-infinity'::timestamptz))
449
514
  RETURNING c.id, c.version_id, c.criterion_id, c.criterion_md, c.goal_id,
450
515
  c.satisfied_by_task_id`,
451
- [scopedTaskId]
516
+ [scopedTaskId, scopedCriterionId]
452
517
  );
453
518
  if (criteria.length === 0) return { criteria: [], goals: [] };
454
519
 
@@ -558,6 +623,8 @@ module.exports = {
558
623
  buildCriterionProgress,
559
624
  buildGoals,
560
625
  isPendingReview,
626
+ uatState,
627
+ uatReadColumnsSql,
561
628
  autoSatisfyShippedCriteria,
562
629
  describeAutoClose,
563
630
  closeCompletedWorkForShip,
@@ -0,0 +1,73 @@
1
+ -- lifecycle_015_criterion_uat.sql — a criterion closes on a UAT, not on its
2
+ -- linked tasks shipping (task 1004392, goal 1000089; ADR 0351).
3
+ --
4
+ -- WHY. ADR 0183 let a criterion close itself the moment every task linked to it
5
+ -- shipped. Nothing read the criterion's words: link any tasks, ship them, and it
6
+ -- closed. wa7-government did exactly that on six Board Room navigation tasks
7
+ -- while the thing it describes (configurable ranks, recommended governments by
8
+ -- project type and size) was not on the live site at all. Shipped code is now
9
+ -- only the FIRST of three checks; the criterion reads "Awaiting UAT" until a
10
+ -- person who did not ship the work signs it off on the live site.
11
+ --
12
+ -- TWO MODULE TABLES, and neither alters a core table. ADR 0083 lets a module
13
+ -- migration create only its own lifecycle_-namespaced tables, so the
14
+ -- backend-only flag is a row beside the criterion (present = backend-only)
15
+ -- rather than a column on done_when_criteria. Both reference the core table the
16
+ -- way lifecycle_010 references tasks.
17
+ --
18
+ -- THE SIGN-OFF IS APPEND-ONLY. A row is never updated or deleted by the app: it
19
+ -- is the record that a named person checked the criterion's words against the
20
+ -- live site on a given day. Whether it still COUNTS is derived at read time (a
21
+ -- sign-off older than the newest linked ship is stale, because the work it
22
+ -- watched has since changed), never by rewriting the row.
23
+ --
24
+ -- PORTABLE and replay-safe: IF NOT EXISTS throughout, no instance data. Already
25
+ -- satisfied criteria are untouched (the owner's 2026-09-29 decision: they stay
26
+ -- closed, and read as "closed before UAT" because they carry no row here).
27
+
28
+ BEGIN;
29
+
30
+ CREATE TABLE IF NOT EXISTS lifecycle_criterion_uats (
31
+ id bigserial PRIMARY KEY,
32
+ criterion_id bigint NOT NULL REFERENCES done_when_criteria(id) ON DELETE CASCADE,
33
+
34
+ -- What kind of sign-off this is:
35
+ -- uat a person performed the criterion on the live site and a
36
+ -- recording of it is stored (recording_file is required)
37
+ -- backend_signoff the criterion is backend-only, so there is nothing to
38
+ -- record; a non-shipper still signs that it is done and live
39
+ -- override POST /done-when/:id/satisfy, the escape hatch, which now
40
+ -- has to say why (note is required) and is recorded as such
41
+ kind text NOT NULL CHECK (kind IN ('uat', 'backend_signoff', 'override')),
42
+ signed_off_by bigint NOT NULL REFERENCES builders(id),
43
+
44
+ -- The stored recording's server-minted name (uat-<criterion>-<16 hex>.<ext>),
45
+ -- served by GET /criterion-uats/recordings/:name. Never a user-supplied path.
46
+ recording_file text CHECK (recording_file IS NULL OR recording_file ~ '^uat-[0-9]{1,12}-[0-9a-f]{16}\.(mp4|webm)$'),
47
+
48
+ -- How "it is live" was established:
49
+ -- deploy_reading the instance read it (every linked shipped task runs here)
50
+ -- attested the instance cannot read it, so the signer said so
51
+ -- not_checked an override, which checks nothing
52
+ live_basis text NOT NULL CHECK (live_basis IN ('deploy_reading', 'attested', 'not_checked')),
53
+ note text CHECK (note IS NULL OR length(note) <= 2000),
54
+ created_at timestamptz NOT NULL DEFAULT now(),
55
+
56
+ CHECK (kind <> 'uat' OR recording_file IS NOT NULL),
57
+ CHECK (kind <> 'override' OR (note IS NOT NULL AND length(btrim(note)) > 0))
58
+ );
59
+
60
+ CREATE INDEX IF NOT EXISTS lifecycle_criterion_uats_criterion
61
+ ON lifecycle_criterion_uats (criterion_id, created_at DESC);
62
+
63
+ -- A criterion marked backend-only: it has no screen, so it cannot be checked by
64
+ -- a recording. Set while the criterion is still open (the route refuses it once
65
+ -- the criterion is awaiting UAT or closed), so the person closing it cannot
66
+ -- choose to skip the recording at close time.
67
+ CREATE TABLE IF NOT EXISTS lifecycle_criterion_backend_only (
68
+ criterion_id bigint PRIMARY KEY REFERENCES done_when_criteria(id) ON DELETE CASCADE,
69
+ marked_by bigint NOT NULL REFERENCES builders(id),
70
+ marked_at timestamptz NOT NULL DEFAULT now()
71
+ );
72
+
73
+ COMMIT;
@@ -14,6 +14,7 @@
14
14
  "claims",
15
15
  "versions",
16
16
  "done-when",
17
+ "criterion-uat",
17
18
  "goals",
18
19
  "dependencies",
19
20
  "gate-approvals",
@@ -0,0 +1,169 @@
1
+ // modules/lifecycle/routes/criterion-uat.js — the UAT a criterion must pass
2
+ // before it closes (task 1004392, goal 1000089; ADR 0351). The rules are pure in
3
+ // ../criterion-uat.js; the transaction is ../criterion-uat-db.js.
4
+ //
5
+ // POST /done-when/:criterionId/uat/recording upload the recording (raw mp4/webm body)
6
+ // POST /done-when/:criterionId/uat sign it off (a UAT, or a backend sign-off)
7
+ // GET /done-when/:criterionId/uat its three checks + every sign-off
8
+ // PUT /done-when/:criterionId/backend-only mark it backend-only while it is still open
9
+ // GET /criterion-uats/recordings/:name play a stored recording
10
+ //
11
+ // WHO. Signing off and uploading reuse `criterion.review` — the permission the
12
+ // criterion review queue already runs on (Metic+), because a UAT sign-off IS that
13
+ // review now. The in-handler wall on top of it is the one that matters: nobody who
14
+ // shipped the linked work may sign it off (the project owner excepted). Marking
15
+ // backend-only is a criterion-AUTHORING act (the owner: "set when the criterion is
16
+ // written"), so it takes `criterion.create` plus the same per-goal authoring wall
17
+ // as creating or editing a criterion (authorizeCriterionCreate, ADR 0154).
18
+ //
19
+ // Reading the UAT picture and playing a recording need only a signed-in builder:
20
+ // a criterion and its goal are readable by any builder (ADR 0112 privacy is
21
+ // join-gating, not read-hiding), and the recording is evidence ABOUT that criterion.
22
+
23
+ const express = require('express');
24
+ const fs = require('node:fs/promises');
25
+ const api = require('../../../src/module-api');
26
+ const db = require('../db');
27
+ const doneWhen = require('../done-when');
28
+ const uat = require('../criterion-uat');
29
+ const uatDb = require('../criterion-uat-db');
30
+ const { authorizeCriterionCreate } = require('./done-when');
31
+ const { validateOrRespond, parseId, asyncHandler } = api;
32
+ const log = api.logger('lifecycle');
33
+
34
+ // The recording arrives as a RAW body, not base64 JSON: at the 100 MB cap a
35
+ // base64 JSON body would be ~134 MB held twice in memory. One byte over the cap
36
+ // so screenRecording, not the body parser, gives the human-worded refusal.
37
+ const recordingBody = express.raw({
38
+ type: Object.keys(uat.EXT_BY_TYPE),
39
+ limit: uat.MAX_RECORDING_BYTES + 1,
40
+ });
41
+
42
+ // A refusal's extra facts (the open tasks, the tasks not live yet) ride the
43
+ // envelope's `details` — res.fail drops any other key, and a refusal that says
44
+ // "some work is not live" without naming it leaves the signer guessing.
45
+ function sendRefusal(res, refusal) {
46
+ const { code, status, message, ...detail } = refusal;
47
+ return res.fail(code, { status, message, ...(Object.keys(detail).length ? { details: detail } : {}) });
48
+ }
49
+
50
+ module.exports = function buildCriterionUatRouter() {
51
+ const router = express.Router();
52
+
53
+ // rank: metic+archon — perm criterion.review: evidence for a UAT sign-off.
54
+ router.post('/done-when/:criterionId/uat/recording', api.requireBuilder, api.requirePermission('criterion.review'), (req, res, next) => {
55
+ recordingBody(req, res, (err) => {
56
+ if (err && err.type === 'entity.too.large') {
57
+ return res.fail('too_big', { status: 413, message: uat.screenMessage('too_big') });
58
+ }
59
+ return next(err);
60
+ });
61
+ }, asyncHandler('POST /done-when/:criterionId/uat/recording', async (req, res) => {
62
+ const id = parseId(req, res, { param: 'criterionId', code: 'bad_criterion_id' });
63
+ if (id === null) return;
64
+ if (!(await doneWhen.getCriterion(id))) return res.fail('criterion_not_found', 404);
65
+ const buf = Buffer.isBuffer(req.body) ? req.body : null;
66
+ const stored = await uat.persistRecording(buf, { criterionId: id, contentType: req.headers['content-type'] });
67
+ if (!stored.ok) return res.fail(stored.reason, { status: 400, message: uat.screenMessage(stored.reason) });
68
+ res.status(201).json({ ok: true, recording: stored.file, bytes: stored.bytes });
69
+ }, { errorCode: 'uat_recording_failed' }));
70
+
71
+ // rank: metic+archon — perm criterion.review; the non-shipper wall is in-handler.
72
+ router.post('/done-when/:criterionId/uat', api.requireBuilder, api.requirePermission('criterion.review'), asyncHandler('POST /done-when/:criterionId/uat', async (req, res) => {
73
+ if (validateOrRespond(req, res, {
74
+ kind: { required: true, type: 'string', maxLength: 32 },
75
+ recording: { type: 'string', maxLength: 80 },
76
+ live_attested: { type: 'boolean' },
77
+ note: { type: 'string', maxLength: uat.MAX_NOTE_LENGTH },
78
+ })) return;
79
+ const id = parseId(req, res, { param: 'criterionId', code: 'bad_criterion_id' });
80
+ if (id === null) return;
81
+ const recording = req.body.recording || null;
82
+ if (recording && !uat.resolveSafe(recording)) return res.fail('bad_recording', 400);
83
+ if (recording) {
84
+ try { await fs.access(uat.resolveSafe(recording)); } catch {
85
+ return res.fail('recording_not_found', { status: 400, message: 'That recording is not on the server. Upload it again.' });
86
+ }
87
+ }
88
+ const out = await uatDb.signOff({
89
+ criterionId: id,
90
+ kind: req.body.kind,
91
+ signerId: req.builder.id,
92
+ recording,
93
+ liveAttested: req.body.live_attested === true,
94
+ note: req.body.note ?? null,
95
+ });
96
+ if (!out.ok) return sendRefusal(res, out.refusal);
97
+ const closed = out.closed || {};
98
+ log.info({ criterionId: id, kind: req.body.kind, signer: req.builder.id }, 'criterion UAT signed off');
99
+ res.status(201).json({
100
+ ok: true,
101
+ signoff: out.signoff,
102
+ criterion_closed: (closed.criteria || []).some((c) => Number(c.id) === id),
103
+ achieved_goals: (closed.goals || []).map((g) => g.id),
104
+ closed_versions: (closed.versionsClosed || []).map((v) => (v && v.id) || v),
105
+ });
106
+ }, { errorCode: 'uat_signoff_failed' }));
107
+
108
+ // rank: any-builder — a criterion and its goal are readable by any builder.
109
+ router.get('/done-when/:criterionId/uat', api.requireBuilder, asyncHandler('GET /done-when/:criterionId/uat', async (req, res) => {
110
+ const id = parseId(req, res, { param: 'criterionId', code: 'bad_criterion_id' });
111
+ if (id === null) return;
112
+ const view = await uatDb.uatView(id);
113
+ if (!view) return res.fail('criterion_not_found', 404);
114
+ res.json(view);
115
+ }, { errorCode: 'uat_read_failed' }));
116
+
117
+ // rank: metic+archon — perm criterion.create + the per-goal authoring wall (ADR 0154).
118
+ router.put('/done-when/:criterionId/backend-only', api.requireBuilder, api.requirePermission('criterion.create'), asyncHandler('PUT /done-when/:criterionId/backend-only', async (req, res) => {
119
+ if (validateOrRespond(req, res, { backend_only: { required: true, type: 'boolean' } })) return;
120
+ const id = parseId(req, res, { param: 'criterionId', code: 'bad_criterion_id' });
121
+ if (id === null) return;
122
+ const out = await uatDb.setBackendOnly({
123
+ criterionId: id,
124
+ builderId: req.builder.id,
125
+ backendOnly: req.body.backend_only,
126
+ // The authoring wall, resolved exactly as PATCH /done-when/:id resolves it,
127
+ // on the transaction client so the decision and the write see one snapshot.
128
+ authorize: async (client, criterion) => {
129
+ const version = await db.getVersion(criterion.version_id, { client });
130
+ const goal = criterion.goal_id != null ? await db.getGoal(criterion.goal_id, { client }) : null;
131
+ const actorMembership = (goal && req.builder.rank !== 'archon')
132
+ ? await db.getGoalMember({ goalId: goal.id, builderId: req.builder.id }, { client })
133
+ : null;
134
+ const decision = authorizeCriterionCreate({ goal, version, actorId: req.builder.id, actorRank: req.builder.rank, actorMembership });
135
+ return decision.ok ? null : { code: decision.body.error, status: decision.status, message: decision.body.message };
136
+ },
137
+ });
138
+ if (!out.ok) return sendRefusal(res, out.refusal);
139
+ res.json({ ok: true, criterion_id: id, backend_only: out.backend_only });
140
+ }, { errorCode: 'backend_only_failed' }));
141
+
142
+ // rank: any-builder — evidence about a criterion any builder can read. The
143
+ // audience is the criterion's: any signed-in builder of THIS instance (criteria
144
+ // are readable by every builder, ADR 0112). One instance is one project, so
145
+ // this never crosses a tenant; the server-minted name is not the access control.
146
+ router.get('/criterion-uats/recordings/:name', api.requireBuilder, async (req, res) => {
147
+ const filePath = uat.resolveSafe(req.params.name);
148
+ if (!filePath) return res.fail('bad_name', 400);
149
+ try {
150
+ await fs.access(filePath);
151
+ } catch {
152
+ return res.fail('not_found', 404);
153
+ }
154
+ res.setHeader('X-Content-Type-Options', 'nosniff');
155
+ res.setHeader('Content-Disposition', 'inline');
156
+ res.setHeader('Content-Security-Policy', "default-src 'none'; sandbox");
157
+ res.setHeader('Cache-Control', 'private, max-age=86400');
158
+ // sendFile honours Range requests, so a browser can seek a long recording.
159
+ res.type(uat.contentTypeForName(req.params.name));
160
+ res.sendFile(filePath, (err) => {
161
+ if (err && !res.headersSent) {
162
+ log.error({ name: req.params.name, err: err.message }, 'uat recording read failed');
163
+ res.fail('read_failed', 500);
164
+ }
165
+ });
166
+ });
167
+
168
+ return router;
169
+ };
@@ -21,6 +21,8 @@ const db = require('../db');
21
21
  const { isGoalOwnerOrManager } = require('../goal-authz');
22
22
  // task 1003102 — prose correction + the satisfied-criterion refusal decision.
23
23
  const goalEdits = require('../goal-edits.js');
24
+ // task 1004392 — /satisfy is the override now and records itself as one.
25
+ const uatDb = require('../criterion-uat-db.js');
24
26
  const { validateOrRespond, parseId, asyncHandler, LIMITS } = api;
25
27
 
26
28
  // authorizeCriterionCreate — the ORDERED, FAIL-CLOSED decision for a non-Archon
@@ -212,9 +214,19 @@ module.exports = function buildDoneWhenRouter() {
212
214
  // pinned in route-rank-check.MODULE_EXPECTED_RANKS.lifecycle so a further
213
215
  // downgrade is caught.
214
216
  // rank: metic+archon — criterion confirmation + goal auto-achieve (the closing loop).
217
+ //
218
+ // ⚠ SINCE TASK 1004392 (ADR 0351) THIS IS THE OVERRIDE, NOT THE CLOSE. A
219
+ // criterion closes on a UAT sign-off (POST /done-when/:id/uat); flipping it here
220
+ // skips the UAT, which is exactly how a criterion gets closed without anyone
221
+ // checking its words. So it now REQUIRES `override_reason`, writes an `override`
222
+ // sign-off row carrying it, and the criterion then reads "Satisfied (override)"
223
+ // everywhere — never as a UAT. The escape hatch stays (an all-abandoned
224
+ // criterion that was met another way still needs one); it is just no longer
225
+ // silent.
215
226
  router.post('/done-when/:criterionId/satisfy', auth.requireBuilder, auth.requirePermission('criterion.satisfy'), asyncHandler('POST /done-when/:criterionId/satisfy', async (req, res) => {
216
227
  if (validateOrRespond(req, res, {
217
228
  satisfied_by_task_id: { type: 'integer', min: 1 },
229
+ override_reason: { type: 'string', maxLength: 2000 },
218
230
  })) return;
219
231
  const id = parseId(req, res, { param: 'criterionId', code: 'bad_criterion_id' });
220
232
  if (id === null) return;
@@ -222,8 +234,20 @@ module.exports = function buildDoneWhenRouter() {
222
234
  // 200-with-null-body (acceptance criterion #5).
223
235
  const existing = await doneWhen.getCriterion(id);
224
236
  if (!existing) return res.fail('criterion_not_found', 404);
237
+ const reason = String(req.body?.override_reason || '').trim();
238
+ if (!reason) {
239
+ return res.fail('override_reason_required', {
240
+ status: 400,
241
+ message: 'A criterion now closes on a UAT: a person who did not ship the work tests it on the live site and signs it off (POST /done-when/:id/uat, or /goal-uat). Closing it here skips that, so say why in override_reason.',
242
+ });
243
+ }
225
244
  const taskId = req.body?.satisfied_by_task_id ?? null;
226
- const updated = await doneWhen.markCriterion(id, taskId);
245
+ // ONE transaction: an override row with no flip behind it would claim a
246
+ // close that never happened, so the two commit together or not at all.
247
+ const updated = await api.withTx(async (client) => {
248
+ await uatDb.recordOverride(client, { criterionId: id, signerId: req.builder.id, reason });
249
+ return doneWhen.markCriterion(id, taskId, { client });
250
+ }, { pool: api.pool });
227
251
  // Auto-achieve: confirming a goal's last criterion flips it open→achieved.
228
252
  // A criterion with no goal (goal_id NULL) completes no goal.
229
253
  let goalResult = { achieved: false, goal: null };
@@ -22,6 +22,8 @@
22
22
  "task-where"
23
23
  ]
24
24
  },
25
- "provides": [],
25
+ "provides": [
26
+ "deploy.taskWhere"
27
+ ],
26
28
  "consumes": []
27
29
  }
@@ -17,13 +17,30 @@
17
17
 
18
18
  const express = require('express');
19
19
  const api = require('../../../src/module-api');
20
- const { readTaskWhere } = require('../work');
20
+ const { readTaskWhere, readTasksWhere } = require('../work');
21
21
 
22
22
  const log = api.logger('npm-release');
23
23
 
24
+ // THE `deploy.taskWhere` PORT (task 1004392, ADR 0351). A criterion's UAT has a
25
+ // LIVE check, and on this hall the answer is exactly this reading: a linked task
26
+ // whose stage is live_unreleased or released is running here. The lifecycle
27
+ // module resolves it OPTIONALLY (it may not require a sibling module, ADR 0083),
28
+ // so an instance without this module simply has no reading and the UAT signer
29
+ // attests instead. Registered from the route factory — the one boot hook that
30
+ // also runs when a test rebuilds the router — and hasProvider-guarded.
31
+ const DEPLOY_PORT = 'deploy.taskWhere';
32
+
24
33
  module.exports = function buildNpmReleaseTaskWhereRouter({ read = readTaskWhere } = {}) {
25
34
  const router = express.Router();
26
35
 
36
+ if (!api.hasProvider(DEPLOY_PORT)) {
37
+ api.registerProvider(DEPLOY_PORT, {
38
+ readTaskWhere: (id) => read({ pool: api.pool, id }),
39
+ // The batch form reads the registry and notes ONCE for every id.
40
+ readTasksWhere: (ids) => readTasksWhere({ pool: api.pool, ids }),
41
+ });
42
+ }
43
+
27
44
  router.get(
28
45
  '/npm-release/task/:id',
29
46
  api.requireBuilder,
@@ -293,13 +293,44 @@ async function readTaskWhere({ pool, id, pkg = configuredPackage(), live, instal
293
293
  [taskId],
294
294
  );
295
295
  if (!rows.length) return null;
296
- const r = rows[0];
297
- const task = { id: String(r.id), status: r.status, summary: r.value_summary || null, at: r.at instanceof Date ? r.at.toISOString() : r.at };
298
- const base = { task_id: task.id, package: pkg, status: task.status };
299
- if (!['completed', 'confirmed', 'shipped'].includes(task.status)) return { ...base, stage: null };
296
+ const task = taskFromRow(rows[0]);
297
+ if (!['completed', 'confirmed', 'shipped'].includes(task.status)) return { task_id: task.id, package: pkg, status: task.status, stage: null };
298
+ return whereFrom(task, pkg, await readVersions({ pkg, live, installedNotes, get, now, store }));
299
+ }
300
300
 
301
- const { liveVersion, registry, versions, notesError, coversTo, published, released } =
302
- await readVersions({ pkg, live, installedNotes, get, now, store });
301
+ function taskFromRow(r) {
302
+ return { id: String(r.id), status: r.status, summary: r.value_summary || null, at: r.at instanceof Date ? r.at.toISOString() : r.at };
303
+ }
304
+
305
+ /**
306
+ * readTasksWhere — the same reading for MANY tasks, with the versions read ONCE (task
307
+ * 1004392). A criterion's UAT Live check asks where every linked shipped task is; calling
308
+ * readTaskWhere per task re-read the registry and the release notes for each one, though
309
+ * that half of the reading does not depend on the task at all. Returns { [taskId]: where }
310
+ * for the ids that exist; an unknown id is simply absent.
311
+ */
312
+ async function readTasksWhere({ pool, ids, pkg = configuredPackage(), live, installedNotes = readInstalledNotes, get, now, store } = {}) {
313
+ const clean = [...new Set((ids || []).map(String).filter((id) => /^\d{1,12}$/.test(id)))];
314
+ if (!clean.length) return {};
315
+ const { rows } = await pool.query(
316
+ `SELECT id, status, value_summary, COALESCE(shipped_at, updated_at) AS at FROM tasks WHERE id = ANY($1::bigint[])`,
317
+ [clean],
318
+ );
319
+ if (!rows.length) return {};
320
+ const reading = await readVersions({ pkg, live, installedNotes, get, now, store });
321
+ const out = {};
322
+ for (const r of rows) {
323
+ const task = taskFromRow(r);
324
+ out[task.id] = ['completed', 'confirmed', 'shipped'].includes(task.status)
325
+ ? whereFrom(task, pkg, reading)
326
+ : { task_id: task.id, package: pkg, status: task.status, stage: null };
327
+ }
328
+ return out;
329
+ }
330
+
331
+ // Place one task against a versions reading — the shared tail of both readers.
332
+ function whereFrom(task, pkg, { liveVersion, registry, versions, notesError, coversTo, published, released }) {
333
+ const base = { task_id: task.id, package: pkg, status: task.status };
303
334
  const stages = placeTasks({ tasks: [task], versions, live: liveVersion, released, coveredAt: coversTo ? published[coversTo] || null : null });
304
335
  const stage = Object.keys(stages).find((k) => stages[k].length) || null;
305
336
  const row = stage ? stages[stage][0] : {};
@@ -316,4 +347,4 @@ async function readTaskWhere({ pool, id, pkg = configuredPackage(), live, instal
316
347
  };
317
348
  }
318
349
 
319
- module.exports = { readWork, readTaskWhere };
350
+ module.exports = { readWork, readTaskWhere, readTasksWhere };
@@ -86,6 +86,13 @@ const port = {
86
86
  // which knows the craft of the claim in hand; this just reports them all.
87
87
  async describeActiveFor(builderId) {
88
88
  const rows = await db.listAdoptions(builderId);
89
+ // The enabled-skills line names only skills this instance still has. A
90
+ // failed skills read must not cost the builder their whole contract, so it
91
+ // degrades to "unknown" (offered-only filter) rather than throwing.
92
+ let installed = null;
93
+ if (rows.some((r) => r.active && Array.isArray(r.enabled_skills) && r.enabled_skills.length)) {
94
+ try { installed = installedSkills(); } catch { installed = null; }
95
+ }
89
96
  const out = [];
90
97
  for (const row of rows) {
91
98
  if (!row.active) continue;
@@ -95,7 +102,7 @@ const port = {
95
102
  const full = await db.getSpeciality(row.id);
96
103
  const selfAuthored = !!full && full.owner_kind === 'builder'
97
104
  && String(full.owner_builder_id) === String(builderId);
98
- const described = S.describeForApi(row, { ...training, selfAuthored });
105
+ const described = S.describeForApi(row, { ...training, selfAuthored, installed });
99
106
  if (described) out.push({ ...described, discipline: row.discipline, name: row.name });
100
107
  }
101
108
  return out;