@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.
- package/.bongos-core.json +103 -53
- package/.claude/skills/goal-review/SKILL.md +16 -24
- package/.claude/skills/goal-uat/SKILL.md +84 -0
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +12 -0
- package/clients/bongos-client/index.cjs +12 -0
- package/clients/bongos-client/index.d.ts +18 -1
- package/clients/bongos-client/index.mjs +12 -0
- package/docs/adr/0015-task-dependencies-and-auto-promotion.md +2 -0
- package/docs/adr/0183-criteria-close-themselves.md +1 -1
- package/docs/adr/0310-a-speciality-offers-skills-and-the-adopter-chooses-them.md +1 -1
- package/docs/adr/0351-a-criterion-closes-on-a-uat.md +73 -0
- package/docs/adr/README.md +28 -0
- package/docs/api/openapi.json +385 -5
- package/docs/api-reference.md +14 -4
- package/docs/architecture.md +7 -0
- package/docs/copy-inventory.md +22 -22
- package/docs/copy-registry.json +23 -23
- package/docs/file-map.md +2 -1
- package/docs/module-api-changelog.md +4 -0
- package/docs/page-readings.json +3 -3
- package/modules/hall-ui/public/goals-page.js +8 -3
- package/modules/hall-ui/public/tweak-editor.css +4 -7
- package/modules/hall-ui/public/tweak-editor.html +3 -3
- package/modules/hall-ui/public/tweak-editor.js +3 -0
- package/modules/lifecycle/criterion-uat-db.js +267 -0
- package/modules/lifecycle/criterion-uat.js +303 -0
- package/modules/lifecycle/db-goals.js +25 -12
- package/modules/lifecycle/done-when.js +84 -17
- package/modules/lifecycle/migrations/lifecycle_015_criterion_uat.sql +73 -0
- package/modules/lifecycle/module.json +1 -0
- package/modules/lifecycle/routes/criterion-uat.js +169 -0
- package/modules/lifecycle/routes/done-when.js +25 -1
- package/modules/npm-release/module.json +3 -1
- package/modules/npm-release/routes/task-where.js +18 -1
- package/modules/npm-release/work.js +38 -7
- package/modules/specialities/routes/specialities.js +8 -1
- package/modules/specialities/specialities.js +26 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +24 -0
- package/scripts/gds/cli-lib.js +4 -1
- package/scripts/gds/fitness-checks-write-validation.js +4 -0
- package/scripts/gds/status.js +14 -3
- package/scripts/gds/uat.js +120 -0
- package/src/bongos/route-rank-check.js +9 -0
- package/src/module-api.js +1 -1
- package/tests/auto_satisfy_criteria.mjs +4 -2
- package/tests/criterion_uat.mjs +467 -0
- package/tests/criterion_uat_routes.mjs +232 -0
- package/tests/fitness.mjs +3 -1
- package/tests/goal_achievement.mjs +4 -2
- package/tests/goal_routes.mjs +4 -2
- package/tests/ideator_full_idea_shapes_space_proof.mjs +3 -2
- package/tests/npm_release_where.mjs +18 -2
- 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
|
-
|
|
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
|
-
|
|
203
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
385
|
-
//
|
|
386
|
-
//
|
|
387
|
-
//
|
|
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
|
-
|
|
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;
|
|
@@ -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
|
-
|
|
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 };
|
|
@@ -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
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
302
|
-
|
|
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;
|