@bongos/core 1.20.5 → 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 (55) hide show
  1. package/.bongos-core.json +102 -52
  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 +3 -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 +18 -0
  41. package/scripts/gds/cli-lib.js +4 -1
  42. package/scripts/gds/fitness-checks-write-validation.js +4 -0
  43. package/scripts/gds/status.js +14 -3
  44. package/scripts/gds/uat.js +120 -0
  45. package/src/bongos/route-rank-check.js +9 -0
  46. package/src/module-api.js +1 -1
  47. package/tests/auto_satisfy_criteria.mjs +4 -2
  48. package/tests/criterion_uat.mjs +467 -0
  49. package/tests/criterion_uat_routes.mjs +232 -0
  50. package/tests/fitness.mjs +3 -1
  51. package/tests/goal_achievement.mjs +4 -2
  52. package/tests/goal_routes.mjs +4 -2
  53. package/tests/ideator_full_idea_shapes_space_proof.mjs +3 -2
  54. package/tests/npm_release_where.mjs +18 -2
  55. package/tests/speciality_session_skills.mjs +150 -0
@@ -204,7 +204,23 @@ function canAdopt(speciality, viewer) {
204
204
  // characters and a session cannot carry that; what the contract carries is the
205
205
  // speciality's terms plus an inventory of what it knows, so the agent knows to
206
206
  // GO AND SEARCH rather than believing the subject is undocumented.
207
- function describeForApi(active, { documentCount = 0, learningCount = 0, selfAuthored = true, authorLabel = null } = {}) {
207
+ // The one sentence naming the enabled skills, or null — SILENT when nothing is on
208
+ // (the common state after part 2), never an empty list. Only a skill still both
209
+ // OFFERED and (when `installed` is known) INSTALLED is named: the PUT checked
210
+ // both, but an author can shrink the offer and a deploy can drop a skill later,
211
+ // and advising a session to reach for a skill it cannot find is noise.
212
+ function enabledSkillsLine(active, installed = null) {
213
+ const enabled = Array.isArray(active && active.enabled_skills) ? active.enabled_skills : [];
214
+ const offered = new Set(Array.isArray(active && active.skills) ? active.skills : []);
215
+ const names = [...new Set(enabled)].filter((n) => typeof n === 'string' && SKILL_NAME_RE.test(n)
216
+ && offered.has(n) && (!installed || installed.has(n)));
217
+ if (!names.length) return null;
218
+ return `In this speciality the builder chose to lean on these skills: ${names.map((n) => `/${n}`).join(', ')}. `
219
+ + 'Reach for them when the work calls for one. This is advice, not permission — '
220
+ + 'each still runs only if the builder\'s rank allows it.';
221
+ }
222
+
223
+ function describeForApi(active, { documentCount = 0, learningCount = 0, selfAuthored = true, authorLabel = null, installed = null } = {}) {
208
224
  if (!active) return null;
209
225
  const parts = [];
210
226
  // Conditional, not unconditional: an unnamed speciality has nothing to announce,
@@ -243,6 +259,15 @@ function describeForApi(active, { documentCount = 0, learningCount = 0, selfAuth
243
259
  );
244
260
  }
245
261
 
262
+ // THE ENABLED SKILLS (task 1004074, ADR 0310 part 3). What THIS builder turned
263
+ // on in the walkthrough, so the speciality changes what the session reaches for.
264
+ // After the provenance fence on purpose: the list is the adopter's own choice,
265
+ // not the author's prose. Advice only — naming a skill grants nothing, and the
266
+ // line says so, because a session told "use X" by a builder who cannot run X
267
+ // should hit the rank gate, not believe the speciality opened it.
268
+ const skillsLine = enabledSkillsLine(active, installed);
269
+ if (skillsLine) parts.push(skillsLine);
270
+
246
271
  const contract = parts.filter(Boolean).join('\n');
247
272
  // inject:false for an empty contract, matching how the wandering knob stays
248
273
  // silent on its permissive levels rather than spending context saying nothing.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.5",
3
+ "version": "1.20.6",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.20.5",
9
+ "version": "1.20.6",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.5",
3
+ "version": "1.20.6",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -8066,9 +8066,27 @@
8066
8066
  }
8067
8067
  ],
8068
8068
  "1.20.5": [
8069
+ {
8070
+ "id": "1002456",
8071
+ "text": "The list of decision records now says plainly which 20 numbers are shared by two documents, and tells everyone to cite a record by its file name so nobody opens the wrong one."
8072
+ },
8073
+ {
8074
+ "id": "1004394",
8075
+ "text": "Code tidy-up in the tweak editor: an out-of-date comment fixed. Nothing changes for artists."
8076
+ },
8077
+ {
8078
+ "id": "1004074",
8079
+ "text": "The skills you switch on for a speciality now follow you into your work sessions. Your assistant is told which ones you chose to lean on, and only in sessions for that craft. Switch nothing on and nothing extra is said. It is"
8080
+ },
8069
8081
  {
8070
8082
  "id": "1004391",
8071
8083
  "text": "Wrote down a rule: when several linked tasks are claimed together, whoever claims them must finish them in order. Nothing enforces it yet, by the owner's choice."
8072
8084
  }
8085
+ ],
8086
+ "1.20.6": [
8087
+ {
8088
+ "id": "1004392",
8089
+ "text": "A goal's checklist item no longer ticks itself off just because some linked work shipped. Once the work is done it now shows \"Awaiting UAT\", and it closes only when someone who did not build it tries it on the live site and up"
8090
+ }
8073
8091
  ]
8074
8092
  }
@@ -680,10 +680,13 @@ async function apiCall(method, urlPath, body = null, opts = {}) {
680
680
  // account billing, so identity is not an auth boundary. anthropic_user_id is
681
681
  // still kept in the session as cosmetic attribution; it is simply not
682
682
  // transmitted as a header.
683
+ // opts.rawBody (a Buffer) + opts.contentType send bytes instead of JSON — a
684
+ // UAT recording upload (task 1004392), whose route reads a raw video body.
685
+ if (opts.rawBody) headers['Content-Type'] = opts.contentType || 'application/octet-stream';
683
686
  const res = await dispatchedFetch(`${base}${urlPath}`, {
684
687
  method,
685
688
  headers,
686
- body: body ? JSON.stringify(body) : null,
689
+ body: opts.rawBody ? opts.rawBody : (body ? JSON.stringify(body) : null),
687
690
  dispatcher: noKeepAliveDispatcher(),
688
691
  });
689
692
  const text = await res.text();
@@ -50,6 +50,10 @@ const WRITE_VALIDATION_EXEMPT = new Set([
50
50
  // the route's express.raw parser and read by module-artifact.verifyModuleArtifact, which
51
51
  // recomputes every hash and refuses anything that is not a well-formed module version.
52
52
  'POST /store/modules/:key/versions',
53
+ // Not a JSON body: the raw UAT recording (task 1004392, ADR 0351), capped by the
54
+ // route's express.raw parser and read by criterion-uat.screenRecording, which
55
+ // refuses anything that is not a bounded mp4/webm whose bytes match its type.
56
+ 'POST /done-when/:criterionId/uat/recording',
53
57
  ]);
54
58
 
55
59
  // KNOWN GAPS, NOT EXEMPTIONS (task 1002551 / BV1.R109b → tracked by task 1002552).
@@ -37,6 +37,17 @@
37
37
  const { API_BASE, cliClient, assertInstanceMatch, cliExit } = require('./cli-lib');
38
38
  const { resolveEnv } = require('../../src/instance-config');
39
39
  const { resolveDefaultVersion } = require('./version-select.js');
40
+ // task 1004392 (ADR 0351): the criterion's UAT state, in the same words the
41
+ // server and /goal-uat use. Dependency-free, so this public CLI pays nothing.
42
+ const { stateLabel } = require('../../modules/lifecycle/criterion-uat.js');
43
+
44
+ // One criterion's state as a short phrase. A server older than the UAT rule
45
+ // sends no uat_state, and then the old satisfied/not answer is the truth.
46
+ function criterionStateText(c) {
47
+ if (!c.uat_state) return c.satisfied ? 'satisfied ✓' : 'not satisfied';
48
+ const label = stateLabel(c.uat_state);
49
+ return c.satisfied ? `${label} ✓` : label;
50
+ }
40
51
  const {
41
52
  looksLikeVersionId,
42
53
  resolveCriterionIn,
@@ -270,7 +281,7 @@ function renderStatusWidget(data, criterion) {
270
281
  return `${STATUS_WIDGET_STYLE}
271
282
  <h2 class="st-sr">${sesc(data.version_id)} criterion ${sesc(criterionLabel(c))} ${c.satisfied ? 'is satisfied' : 'is not yet satisfied'}: ${c.counts.shipped} of ${c.counts.total} gating tasks shipped.</h2>
272
283
  <div class="st-card">
273
- <div class="st-head"><span class="st-title"><i class="ti ti-checklist" aria-hidden="true"></i>${sesc(data.version_id)} · ${sesc(criterionLabel(c))}</span><span class="st-meta">${c.satisfied ? '<span class="st-ok">satisfied</span>' : 'not satisfied'} · ${c.counts.shipped}/${c.counts.total} shipped</span></div>
284
+ <div class="st-head"><span class="st-title"><i class="ti ti-checklist" aria-hidden="true"></i>${sesc(data.version_id)} · ${sesc(criterionLabel(c))}</span><span class="st-meta">${c.satisfied ? `<span class="st-ok">${sesc(criterionStateText(c))}</span>` : sesc(criterionStateText(c))} · ${c.counts.shipped}/${c.counts.total} shipped</span></div>
274
285
  <div class="st-crit">${sesc(c.criterion_md.replace(/\*\*/g, '').slice(0, 160))}</div>
275
286
  ${body}
276
287
  <div class="st-foot">remaining: ${sesc(remainingSummary(c.remaining))}${claimHint}</div>
@@ -314,7 +325,7 @@ function renderStatusMarkdown(data, criterion) {
314
325
  const lines = [];
315
326
  if (criterion) {
316
327
  const c = criterion;
317
- lines.push(`**/status · ${data.version_id} · ${criterionLabel(c)}** — ${c.satisfied ? 'satisfied ✓' : 'not satisfied'}`);
328
+ lines.push(`**/status · ${data.version_id} · ${criterionLabel(c)}** — ${criterionStateText(c)}`);
318
329
  lines.push(`_${c.criterion_md.replace(/\*\*/g, '').slice(0, 160)}_`);
319
330
  lines.push('');
320
331
  if (c.tasks.length) {
@@ -405,7 +416,7 @@ function printAll(data) {
405
416
  }
406
417
 
407
418
  function printOne(data, c) {
408
- console.log(`\n${data.version_id} · ${c.criterion_id} (C${c.cnum}) [${c.satisfied ? 'satisfied ✓' : 'not satisfied'}]`);
419
+ console.log(`\n${data.version_id} · ${c.criterion_id} (C${c.cnum}) [${criterionStateText(c)}]`);
409
420
  console.log(`"${c.criterion_md.replace(/\*\*/g, '').slice(0, 120)}"\n`);
410
421
  if (c.tasks.length === 0) {
411
422
  console.log(' (no tasks linked — this criterion has no structured gating tasks yet)\n');
@@ -0,0 +1,120 @@
1
+ #!/usr/bin/env node
2
+ // scripts/gds/uat.js — see, and sign off, a criterion's UAT from a session
3
+ // (task 1004392, goal 1000089; ADR 0351). The CLI half of /goal-uat.
4
+ //
5
+ // WHY. A criterion no longer closes because its linked tasks shipped. It reads
6
+ // "Awaiting UAT" until someone who did not ship the work performs it on the live
7
+ // site and signs it off with a recording (or, for a criterion marked
8
+ // backend-only, a recording-free sign-off). This is how a session does that
9
+ // before the hall has buttons for it (task 1004402), and what /goal-uat drives.
10
+ //
11
+ // Usage:
12
+ // node scripts/gds/uat.js --queue [--version V] every criterion awaiting UAT
13
+ // node scripts/gds/uat.js <criterion-id> its three checks + sign-offs
14
+ // node scripts/gds/uat.js <criterion-id> --recording <file.mp4|.webm> [--live-attested] [--note "..."]
15
+ // node scripts/gds/uat.js <criterion-id> --backend-signoff [--live-attested] [--note "..."]
16
+ // node scripts/gds/uat.js <criterion-id> --backend-only on|off
17
+ //
18
+ // <criterion-id> is the criterion's numeric id (the `id` /status and the queue
19
+ // print), not its slug. --live-attested is only needed where this site cannot
20
+ // tell on its own whether the work is live; the server says so when it is.
21
+
22
+ const { apiCall, arg, argText, hasFlag, cliExit } = require('./cli-lib');
23
+ const uat = require('../../modules/lifecycle/criterion-uat');
24
+
25
+ const API = '/api/bongos';
26
+
27
+ function refusalText(data) {
28
+ const e = data && data.error;
29
+ if (!e) return 'the server refused it.';
30
+ const msg = typeof e === 'string' ? e : (e.message || e.code);
31
+ const extra = [];
32
+ const details = (typeof e === 'object' && e.details) || data;
33
+ if (details && Array.isArray(details.remaining) && details.remaining.length) {
34
+ extra.push(`still open: ${details.remaining.map((t) => `task ${t.id} (${t.status})`).join(', ')}`);
35
+ }
36
+ if (details && Array.isArray(details.not_live) && details.not_live.length) {
37
+ extra.push(`not live yet: ${details.not_live.map((t) => `task ${t.task_id}${t.stage ? ` (${t.stage})` : ''}`).join(', ')}`);
38
+ }
39
+ return [msg, ...extra].join('\n ');
40
+ }
41
+
42
+ async function showQueue() {
43
+ const version = arg('--version');
44
+ const q = version ? `?version=${encodeURIComponent(version)}` : '';
45
+ const r = await apiCall('GET', `${API}/done-when/pending-review${q}`);
46
+ if (!r.ok) { console.error(`Could not read the queue: ${refusalText(r.data)}`); return 1; }
47
+ const rows = (r.data.criteria || []).filter((c) => c.awaiting_uat);
48
+ if (!rows.length) { console.log('Nothing is awaiting UAT.'); return 0; }
49
+ console.log(`${rows.length} criteri${rows.length === 1 ? 'on' : 'a'} awaiting UAT:\n`);
50
+ for (const c of rows) {
51
+ const tag = c.backend_only ? ' [backend-only]' : '';
52
+ console.log(` criterion ${c.id} ${c.criterion_id}${tag}`);
53
+ console.log(` goal ${c.goal_id ?? '-'}${c.goal_title ? ` "${c.goal_title}"` : ''}${c.is_last_in_goal ? ' (last open criterion: signing it off can achieve the goal)' : ''}`);
54
+ }
55
+ return 0;
56
+ }
57
+
58
+ async function showOne(id) {
59
+ const r = await apiCall('GET', `${API}/done-when/${id}/uat`);
60
+ if (!r.ok) { console.error(`Could not read criterion ${id}: ${refusalText(r.data)}`); return 1; }
61
+ const v = r.data;
62
+ console.log(`criterion ${v.criterion.id} ${v.criterion.criterion_id} — ${v.uat_label}${v.backend_only ? ' [backend-only]' : ''}\n`);
63
+ console.log(String(v.criterion.criterion_md || '').trim());
64
+ console.log('');
65
+ console.log(` Code ${v.checks.code ? 'passed' : 'not yet'} (${v.linked_tasks.length} linked: ${v.linked_tasks.map((t) => `${t.id} ${t.status}`).join(', ') || 'none'})`);
66
+ const live = v.checks.live;
67
+ console.log(` Live ${live == null ? '-' : !live.available ? 'this site cannot tell; the signer confirms it' : live.live ? 'running on the site' : `not yet: ${(live.not_live || []).map((t) => t.task_id).join(', ')}`}`);
68
+ const s = v.checks.uat;
69
+ console.log(` UAT ${s ? `${s.kind === 'uat' ? 'recorded' : 'backend sign-off'} by ${s.signed_off_by_login || s.signed_off_by} on ${String(s.created_at).slice(0, 10)}` : 'not yet'}`);
70
+ return 0;
71
+ }
72
+
73
+ async function signOff(id, kind) {
74
+ let recording = null;
75
+ if (kind === 'uat') {
76
+ const file = arg('--recording');
77
+ const read = uat.readRecordingFile(file);
78
+ if (!read.ok) { console.error(`Recording refused: ${read.message}`); return 1; }
79
+ const up = await apiCall('POST', `${API}/done-when/${id}/uat/recording`, null, { rawBody: read.buf, contentType: read.contentType });
80
+ if (!up.ok) { console.error(`Upload refused: ${refusalText(up.data)}`); return 1; }
81
+ recording = up.data.recording;
82
+ console.log(`Uploaded the recording (${Math.round(up.data.bytes / 1024 / 1024 * 10) / 10} MB).`);
83
+ }
84
+ const body = { kind };
85
+ if (recording) body.recording = recording;
86
+ if (hasFlag('--live-attested')) body.live_attested = true;
87
+ const note = argText('--note');
88
+ if (note) body.note = note;
89
+ const r = await apiCall('POST', `${API}/done-when/${id}/uat`, body);
90
+ if (!r.ok) { console.error(`Sign-off refused: ${refusalText(r.data)}`); return 1; }
91
+ console.log(r.data.criterion_closed ? `Signed off. Criterion ${id} is now closed.` : `Signed off, but criterion ${id} did not close yet.`);
92
+ for (const g of r.data.achieved_goals || []) console.log(` That was the goal's last criterion: goal ${g} is achieved.`);
93
+ return 0;
94
+ }
95
+
96
+ async function setBackendOnly(id, value) {
97
+ if (value !== 'on' && value !== 'off') { console.error('--backend-only takes on or off.'); return 1; }
98
+ const r = await apiCall('PUT', `${API}/done-when/${id}/backend-only`, { backend_only: value === 'on' });
99
+ if (!r.ok) { console.error(`Refused: ${refusalText(r.data)}`); return 1; }
100
+ console.log(`Criterion ${id} is ${r.data.backend_only ? 'now marked backend-only' : 'no longer backend-only'}.`);
101
+ return 0;
102
+ }
103
+
104
+ async function main() {
105
+ if (hasFlag('--queue')) return showQueue();
106
+ const id = process.argv[2];
107
+ if (!id || !/^\d{1,12}$/.test(id)) {
108
+ console.error('usage: node scripts/gds/uat.js <criterion-id> [--recording f | --backend-signoff | --backend-only on|off] | --queue');
109
+ return 2;
110
+ }
111
+ if (arg('--backend-only')) return setBackendOnly(id, arg('--backend-only'));
112
+ if (hasFlag('--backend-signoff')) return signOff(id, 'backend_signoff');
113
+ if (arg('--recording')) return signOff(id, 'uat');
114
+ return showOne(id);
115
+ }
116
+
117
+ main().then((code) => cliExit(code), (err) => {
118
+ console.error(`uat: ${err && err.message}`);
119
+ cliExit(1);
120
+ });
@@ -425,6 +425,15 @@ const MODULE_EXPECTED_RANKS = {
425
425
  // — but it is scope disposition, not an authority grant, so it delegates with the
426
426
  // rest. Pinned by atom so the guarded invariant is the permission, not its floor.
427
427
  'POST /done-when/:criterionId/unsatisfy': 'perm:criterion.unsatisfy',
428
+ // task 1004392 (ADR 0351): a criterion closes on a UAT sign-off. Uploading
429
+ // the recording and signing off ride `criterion.review`, the review queue's
430
+ // own atom, because the sign-off IS that review now; the wall that matters is
431
+ // in-handler (nobody who shipped the linked work may sign it off). Marking a
432
+ // criterion backend-only decides whether a recording is needed at all, so it
433
+ // is an AUTHORING act: `criterion.create` plus authorizeCriterionCreate.
434
+ 'POST /done-when/:criterionId/uat/recording': 'perm:criterion.review',
435
+ 'POST /done-when/:criterionId/uat': 'perm:criterion.review',
436
+ 'PUT /done-when/:criterionId/backend-only': 'perm:criterion.create',
428
437
  // gate-surface PR approval from the hall (task 1179, ADR 0058 amendment). ADR 0157
429
438
  // SPLIT these two: the list route is `gate_approval.read` (metic — the Gate page
430
439
  // opens to the working rank), while APPROVING still clears the gate-review hard
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.20.5'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.6'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -139,12 +139,14 @@ await test('EXEC: shipTask passes its transaction CLIENT, and the reconciler pas
139
139
  await test('SCOPED vs UNSCOPED: the taskId parameter is what narrows the candidate set', async () => {
140
140
  const scoped = fakeExec({ rows: [] });
141
141
  await autoSatisfyShippedCriteria(scoped, { taskId: 42 });
142
- assert.deepEqual(scoped.calls[0].params, [42]);
142
+ // task 1004392 added a second scope ($2, the sign-off route's criterionId); the ship
143
+ // hook leaves it null.
144
+ assert.deepEqual(scoped.calls[0].params, [42, null]);
143
145
  assert.match(scoped.calls[0].sql, /\$1::bigint IS NULL OR c\.id IN/, 'one SQL serves both shapes');
144
146
 
145
147
  const sweep = fakeExec({ rows: [] });
146
148
  await autoSatisfyShippedCriteria(sweep, {});
147
- assert.deepEqual(sweep.calls[0].params, [null], 'unscoped passes NULL, which disables the narrowing clause');
149
+ assert.deepEqual(sweep.calls[0].params, [null, null], 'unscoped passes NULL, which disables the narrowing clause');
148
150
  });
149
151
 
150
152
  await test('NO-OP: nothing closed means no cascade query at all', async () => {