@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
@@ -0,0 +1,232 @@
1
+ // tests/criterion_uat_routes.mjs — task 1004392 / ADR 0351, the HTTP half.
2
+ //
3
+ // tests/criterion_uat.mjs proves the rules and the sign-off transaction. This
4
+ // file proves the ROUTES consult them and say what they decided, through the
5
+ // REAL Express routers and the REAL error envelope (res.fail drops unknown keys,
6
+ // which is exactly the kind of thing only a route-level test catches: a refusal
7
+ // that names the tasks not yet live must still name them on the wire).
8
+ //
9
+ // What it pins:
10
+ // 1. /satisfy is the override now: no reason → refused and NOTHING written;
11
+ // with a reason → an override row is recorded BEFORE the criterion flips.
12
+ // 2. A sign-off refusal reaches the caller with its code, status and details.
13
+ // 3. The recording upload screens type + bytes, stores under a server-minted
14
+ // name, and a sign-off naming a recording that is not on disk is refused.
15
+ // 4. Backend-only runs the criterion-authoring wall, and its refusal is the
16
+ // wall's own words.
17
+ // 5. Each write asks for the permission route-rank-check pins it to.
18
+ //
19
+ // No DB, no network, no session: the data layer is stubbed at the module
20
+ // boundary (the tests/criterion_goal_attach.mjs pattern).
21
+ //
22
+ // Run: node tests/criterion_uat_routes.mjs
23
+
24
+ import { strict as assert } from 'node:assert';
25
+ import { createRequire } from 'node:module';
26
+ import { mkdtempSync, rmSync, existsSync } from 'node:fs';
27
+ import { tmpdir } from 'node:os';
28
+ import { join } from 'node:path';
29
+ import http from 'node:http';
30
+ import { makeRunner } from './helpers.mjs';
31
+
32
+ process.env.NODE_ENV = 'test';
33
+ const recDir = mkdtempSync(join(tmpdir(), 'uat-routes-'));
34
+ process.env.UAT_RECORDINGS_DIR = recDir;
35
+
36
+ const require = createRequire(import.meta.url);
37
+ const express = require('express');
38
+ const api = require('../src/module-api.js');
39
+ const { attachFail } = require('../src/bongos/middleware/error-envelope.js');
40
+ const { test, summary } = makeRunner();
41
+
42
+ // ---- the world ---------------------------------------------------------------
43
+ const METIC = { id: 700, rank: 'metic' };
44
+ let actor = METIC;
45
+ const asked = []; // every permission a route asked for, in build order
46
+ const log = []; // what the stubbed data layer was asked to do
47
+
48
+ // A pool whose connect() hands out a recording client, so withTx runs for real
49
+ // and a test can assert that the override's two writes share ONE transaction.
50
+ const txLog = [];
51
+ Object.defineProperty(api, 'pool', {
52
+ configurable: true,
53
+ value: {
54
+ query: async () => ({ rows: [] }),
55
+ connect: async () => ({
56
+ query: async (sql) => { txLog.push(String(sql).trim()); return { rows: [] }; },
57
+ release: () => {},
58
+ }),
59
+ },
60
+ });
61
+
62
+ api.requireBuilder = (req, _res, next) => { req.builder = actor; next(); };
63
+ api.requirePermission = (...perms) => {
64
+ asked.push(perms.join('+'));
65
+ return (_req, _res, next) => next();
66
+ };
67
+
68
+ const db = require('../modules/lifecycle/db.js');
69
+ const doneWhen = require('../modules/lifecycle/done-when.js');
70
+ const uatDb = require('../modules/lifecycle/criterion-uat-db.js');
71
+
72
+ doneWhen.getCriterion = async (id) => (Number(id) === 5 ? { id: 5, version_id: 'V', goal_id: 3, satisfied: false } : null);
73
+ doneWhen.markCriterion = async (id, _task, deps = {}) => { log.push(['mark', Number(id), deps.client || null]); return { id: Number(id), goal_id: null, satisfied: true }; };
74
+ db.achieveGoalIfComplete = async () => ({ achieved: false, goal: null });
75
+ db.getVersion = async (id) => ({ id, status: 'building' });
76
+ db.getGoal = async (id) => ({ id, version_id: 'V', status: 'open', created_by: 999 }); // not the actor's
77
+ db.getGoalMember = async () => null;
78
+ uatDb.recordOverride = async (exec, row) => { log.push(['override', row, exec]); return { id: 1, kind: 'override' }; };
79
+ let signOffResult = null;
80
+ uatDb.signOff = async (args) => { log.push(['signOff', args]); return signOffResult; };
81
+ uatDb.setBackendOnly = async ({ authorize, ...rest }) => {
82
+ log.push(['setBackendOnly', rest]);
83
+ const denied = await authorize({}, { id: rest.criterionId, goal_id: 3, version_id: 'V' });
84
+ return denied ? { ok: false, refusal: denied } : { ok: true, backend_only: rest.backendOnly };
85
+ };
86
+
87
+ const app = express();
88
+ app.use(attachFail);
89
+ app.use(express.json());
90
+ app.use(require('../modules/lifecycle/routes/done-when.js')());
91
+ app.use(require('../modules/lifecycle/routes/criterion-uat.js')());
92
+ const server = http.createServer(app);
93
+ await new Promise((r) => server.listen(0, '127.0.0.1', r));
94
+ const base = `http://127.0.0.1:${server.address().port}`;
95
+
96
+ async function call(method, path, { json, raw, type } = {}) {
97
+ const res = await fetch(`${base}${path}`, {
98
+ method,
99
+ headers: raw ? { 'content-type': type } : { 'content-type': 'application/json' },
100
+ body: raw || (json === undefined ? undefined : JSON.stringify(json)),
101
+ });
102
+ return { status: res.status, body: await res.json().catch(() => ({})) };
103
+ }
104
+ const errCode = (body) => (body && body.error && (body.error.code || body.error)) || null;
105
+ const reset = () => { log.length = 0; txLog.length = 0; actor = METIC; signOffResult = null; };
106
+ const mp4 = () => { const b = Buffer.alloc(64); b.write('ftyp', 4, 'latin1'); return b; };
107
+
108
+ // ---- 1. the override --------------------------------------------------------
109
+
110
+ await test('SATISFY: without override_reason it is refused and nothing is written', async () => {
111
+ reset();
112
+ const r = await call('POST', '/done-when/5/satisfy', { json: {} });
113
+ assert.equal(r.status, 400);
114
+ assert.equal(errCode(r.body), 'override_reason_required');
115
+ assert.match(JSON.stringify(r.body), /UAT/, 'the refusal explains that criteria close on a UAT');
116
+ assert.deepEqual(log, [], 'no override row, no flip');
117
+ });
118
+
119
+ await test('SATISFY: with a reason, the override row is recorded BEFORE the criterion flips', async () => {
120
+ reset();
121
+ const r = await call('POST', '/done-when/5/satisfy', { json: { override_reason: 'met by the old importer, owner confirmed' } });
122
+ assert.equal(r.status, 200, JSON.stringify(r.body));
123
+ assert.deepEqual(log.map((e) => e[0]), ['override', 'mark']);
124
+ assert.equal(log[0][1].reason, 'met by the old importer, owner confirmed');
125
+ assert.equal(log[0][1].signerId, METIC.id);
126
+ // ONE transaction: both writes ran on the same client, between BEGIN and COMMIT.
127
+ assert.ok(log[0][2] && log[0][2] === log[1][2], 'the override row and the flip share one transaction client');
128
+ assert.equal(txLog[0], 'BEGIN');
129
+ assert.equal(txLog[txLog.length - 1], 'COMMIT');
130
+ });
131
+
132
+ // ---- 2. the sign-off --------------------------------------------------------
133
+
134
+ await test('SIGNOFF: a refusal reaches the caller with its code, status AND details', async () => {
135
+ reset();
136
+ signOffResult = { ok: false, refusal: { code: 'work_not_live', status: 409, message: 'not deployed yet', not_live: [{ task_id: '12', stage: 'not_live' }] } };
137
+ const r = await call('POST', '/done-when/5/uat', { json: { kind: 'backend_signoff', live_attested: true } });
138
+ assert.equal(r.status, 409);
139
+ assert.equal(errCode(r.body), 'work_not_live');
140
+ assert.deepEqual(r.body.error.details, { not_live: [{ task_id: '12', stage: 'not_live' }] }, 'the tasks not yet live must be named on the wire');
141
+ });
142
+
143
+ await test('SIGNOFF: the route hands signOff the authenticated signer, never a body field', async () => {
144
+ reset();
145
+ signOffResult = { ok: true, signoff: { id: 9 }, closed: { criteria: [{ id: 5 }], goals: [{ id: 3 }] } };
146
+ const r = await call('POST', '/done-when/5/uat', { json: { kind: 'backend_signoff', live_attested: true, note: 'checked' } });
147
+ assert.equal(r.status, 201, JSON.stringify(r.body));
148
+ assert.equal(r.body.criterion_closed, true);
149
+ assert.deepEqual(r.body.achieved_goals, [3]);
150
+ const args = log.find((e) => e[0] === 'signOff')[1];
151
+ assert.equal(args.signerId, METIC.id);
152
+ assert.equal(args.liveAttested, true);
153
+ const smuggle = await call('POST', '/done-when/5/uat', { json: { kind: 'uat', signer_id: 1 } });
154
+ assert.equal(smuggle.status, 400, 'an unknown field is refused, so a signer id cannot ride the body');
155
+ });
156
+
157
+ // ---- 3. the recording -------------------------------------------------------
158
+
159
+ await test('UPLOAD: a real mp4 is stored under a server-minted name for THIS criterion', async () => {
160
+ reset();
161
+ const r = await call('POST', '/done-when/5/uat/recording', { raw: mp4(), type: 'video/mp4' });
162
+ assert.equal(r.status, 201, JSON.stringify(r.body));
163
+ assert.match(r.body.recording, /^uat-5-[0-9a-f]{16}\.mp4$/);
164
+ assert.ok(existsSync(join(recDir, r.body.recording)));
165
+ });
166
+
167
+ await test('UPLOAD: bytes that are not the claimed video are refused in words', async () => {
168
+ reset();
169
+ const r = await call('POST', '/done-when/5/uat/recording', { raw: Buffer.alloc(64, 1), type: 'video/mp4' });
170
+ assert.equal(r.status, 400);
171
+ assert.equal(errCode(r.body), 'sniff_mismatch');
172
+ const png = await call('POST', '/done-when/5/uat/recording', { raw: Buffer.alloc(64, 1), type: 'image/png' });
173
+ assert.equal(png.status, 400, 'a non-video type never reaches the store');
174
+ });
175
+
176
+ await test('UPLOAD: an unknown criterion is a 404 and stores nothing', async () => {
177
+ reset();
178
+ const r = await call('POST', '/done-when/6/uat/recording', { raw: mp4(), type: 'video/mp4' });
179
+ assert.equal(r.status, 404);
180
+ });
181
+
182
+ await test('SIGNOFF: a recording that is not on disk is refused before signOff runs', async () => {
183
+ reset();
184
+ const r = await call('POST', '/done-when/5/uat', { json: { kind: 'uat', recording: 'uat-5-0123456789abcdef.mp4' } });
185
+ assert.equal(r.status, 400);
186
+ assert.equal(errCode(r.body), 'recording_not_found');
187
+ assert.ok(!log.some((e) => e[0] === 'signOff'));
188
+ const bad = await call('POST', '/done-when/5/uat', { json: { kind: 'uat', recording: '../../etc/passwd' } });
189
+ assert.equal(bad.status, 400);
190
+ assert.equal(errCode(bad.body), 'bad_recording');
191
+ });
192
+
193
+ await test('PLAY: a stored recording is served with its type; a bad name is refused', async () => {
194
+ reset();
195
+ const up = await call('POST', '/done-when/5/uat/recording', { raw: mp4(), type: 'video/mp4' });
196
+ const res = await fetch(`${base}/criterion-uats/recordings/${up.body.recording}`);
197
+ assert.equal(res.status, 200);
198
+ assert.match(res.headers.get('content-type'), /^video\/mp4/);
199
+ assert.equal(res.headers.get('x-content-type-options'), 'nosniff');
200
+ const bad = await fetch(`${base}/criterion-uats/recordings/..%2F..%2Fsecret.mp4`);
201
+ assert.equal(bad.status, 400);
202
+ });
203
+
204
+ // ---- 4. backend-only --------------------------------------------------------
205
+
206
+ await test('BACKEND-ONLY: the authoring wall runs, and its refusal is the wall\'s own words', async () => {
207
+ reset();
208
+ const r = await call('PUT', '/done-when/5/backend-only', { json: { backend_only: true } });
209
+ assert.equal(r.status, 403);
210
+ assert.equal(errCode(r.body), 'goal_not_yours', 'a Metic who does not own the goal may not decide whether it needs a recording');
211
+ actor = { id: 900, rank: 'archon' };
212
+ const ok = await call('PUT', '/done-when/5/backend-only', { json: { backend_only: true } });
213
+ assert.equal(ok.status, 200, JSON.stringify(ok.body));
214
+ assert.equal(ok.body.backend_only, true);
215
+ });
216
+
217
+ // ---- 5. the permissions each write asks for --------------------------------
218
+
219
+ await test('GATES: the writes ask for exactly the atoms route-rank-check pins', () => {
220
+ const { MODULE_EXPECTED_RANKS } = require('../src/bongos/route-rank-check.js');
221
+ const pins = MODULE_EXPECTED_RANKS.lifecycle;
222
+ assert.equal(pins['POST /done-when/:criterionId/uat/recording'], 'perm:criterion.review');
223
+ assert.equal(pins['POST /done-when/:criterionId/uat'], 'perm:criterion.review');
224
+ assert.equal(pins['PUT /done-when/:criterionId/backend-only'], 'perm:criterion.create');
225
+ // criterion-uat.js is built after done-when.js; its three gated routes asked, in
226
+ // order, for review · review · create.
227
+ assert.deepEqual(asked.slice(-3), ['criterion.review', 'criterion.review', 'criterion.create']);
228
+ });
229
+
230
+ server.close();
231
+ rmSync(recDir, { recursive: true, force: true });
232
+ summary();
package/tests/fitness.mjs CHANGED
@@ -928,7 +928,9 @@ test('write-route validation exemptions are the known dynamic-key prefs routes,
928
928
  // own parser and read by a checker that refuses anything malformed (docx.readUpload,
929
929
  // module-artifact.verifyModuleArtifact). Named here so each raw-body exemption is a
930
930
  // reviewed diff.
931
- const RAW_FILE_UPLOADS = ['POST /copy-desk/pages/:pageId/draft.docx', 'POST /store/modules/:key/versions'];
931
+ // The UAT recording (task 1004392) is the third: raw mp4/webm bytes, read by
932
+ // criterion-uat.screenRecording.
933
+ const RAW_FILE_UPLOADS = ['POST /copy-desk/pages/:pageId/draft.docx', 'POST /store/modules/:key/versions', 'POST /done-when/:criterionId/uat/recording'];
932
934
  const exempt = [...fitness.WRITE_VALIDATION_EXEMPT];
933
935
  assert.ok(exempt.length >= 1 && exempt.every((e) => e.startsWith('PATCH /me/') || RAW_FILE_UPLOADS.includes(e)),
934
936
  'exemptions are all /me/* prefs patches or a named raw-file upload: ' + exempt.join(', '));
@@ -118,14 +118,16 @@ await test('achieveGoalIfComplete: unknown goal id → { achieved:false, goal:nu
118
118
  // =========================================================================
119
119
  const PENDING_ROW = {
120
120
  id: 10, version_id: 'BONGOS-V1', criterion_id: 'c-one', criterion_md: 'md',
121
- goal_id: 7, sort_order: 1, task_total: 2, goal_title: 'Goal hierarchy',
121
+ goal_id: 7, sort_order: 1, task_total: 2, shipped_total: 2, goal_title: 'Goal hierarchy',
122
122
  goal_status: 'open', is_last_in_goal: true,
123
123
  };
124
124
 
125
125
  await test('listPendingReviewCriteria: returns the queue rows; no version → param [null]', async () => {
126
126
  const pool = fakePool((s) => (/WITH pending AS/.test(s) ? { rows: [PENDING_ROW] } : null));
127
127
  const out = await listPendingReviewCriteria({}, { pool });
128
- assert.deepEqual(out, [PENDING_ROW]);
128
+ // task 1004392: each row carries its UAT state; delivered work with no sign-off
129
+ // is Awaiting UAT (the /goal-uat queue).
130
+ assert.deepEqual(out, [{ ...PENDING_ROW, backend_only: false, uat_state: 'awaiting_uat', awaiting_uat: true }]);
129
131
  const q = find(pool.queries, /WITH pending AS/);
130
132
  assert.deepEqual(q.params, [null], 'an unscoped call binds versionId=null (no filter)');
131
133
  });
@@ -144,7 +144,8 @@ await test('builder_id coercion contract: Number() accepts int + numeric string,
144
144
  // task 1002164: the projection now carries the per-criterion link counts and the
145
145
  // DERIVED pending_review flag, so the hall's goal-detail page can render the
146
146
  // third criterion state (met — ready to confirm) without a second round-trip.
147
- const CRIT_SQL_RE = /FROM done_when_criteria WHERE goal_id = \$1/;
147
+ // task 1004392 aliased the table (d) to read each criterion's UAT columns.
148
+ const CRIT_SQL_RE = /FROM done_when_criteria d WHERE d\.goal_id = \$1/;
148
149
  const critRow = (over = {}) => ({
149
150
  id: 1, version_id: 'V1', criterion_id: 'c1', criterion_md: 'md', satisfied: false,
150
151
  sort_order: 6, goal_id: 3, linked_tasks: 0, unshipped_tasks: 0, ...over,
@@ -155,7 +156,8 @@ await test('listCriteriaForGoal: SELECT by goal_id, ordered, returns rows', asyn
155
156
  const ROW = critRow();
156
157
  const pool = critPool([ROW]);
157
158
  const out = await listCriteriaForGoal(3, { pool });
158
- assert.deepEqual(out, [{ ...ROW, pending_review: false }]);
159
+ // task 1004392: every row also carries its UAT state (no linked work → open).
160
+ assert.deepEqual(out, [{ ...ROW, backend_only: false, pending_review: false, uat_state: 'open', awaiting_uat: false }]);
159
161
  const sel = find(pool.queries, CRIT_SQL_RE);
160
162
  assert.equal(sel.params[0], 3);
161
163
  assert.match(sel.sql, /ORDER BY sort_order, id/);
@@ -216,11 +216,12 @@ function answer(sql, params) {
216
216
  // was answering a question the read had stopped asking.
217
217
  return { rows: g ? [project(g, s)] : [] };
218
218
  }
219
- if (/WITH crit AS \( SELECT id, version_id, criterion_id, criterion_md, satisfied, sort_order, goal_id FROM done_when_criteria WHERE goal_id = \$1/i.test(s)) {
219
+ // task 1004392: the read aliases the table (d) and carries the UAT columns.
220
+ if (/WITH crit AS \( SELECT d\.id, d\.version_id, d\.criterion_id, d\.criterion_md, d\.satisfied, d\.sort_order, d\.goal_id, .*FROM done_when_criteria d WHERE d\.goal_id = \$1/i.test(s)) {
220
221
  const rows = db.criteria
221
222
  .filter((c) => c.goal_id === String(params[0]))
222
223
  .sort((a, b) => a.sort_order - b.sort_order || String(a.id).localeCompare(String(b.id)))
223
- .map((c) => ({ ...c, linked_tasks: 0, unshipped_tasks: 0 }));
224
+ .map((c) => ({ ...c, uat_kind: null, backend_only: false, linked_tasks: 0, unshipped_tasks: 0, shipped_tasks: 0 }));
224
225
  return { rows };
225
226
  }
226
227
 
@@ -22,7 +22,7 @@ import { npmTarball, npmPackument, fakeHttpsGet } from './helpers.mjs';
22
22
 
23
23
  const require = createRequire(import.meta.url);
24
24
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
25
- const { readTaskWhere } = require('../modules/npm-release/work.js');
25
+ const { readTaskWhere, readTasksWhere } = require('../modules/npm-release/work.js');
26
26
  const buildRouter = require('../modules/npm-release/routes/task-where.js');
27
27
 
28
28
  const ORIGIN = 'https://registry.npmjs.org';
@@ -61,6 +61,9 @@ function fakePool() {
61
61
  const t = TASKS[params[0]];
62
62
  return { rows: t ? [{ id: params[0], ...t }] : [] };
63
63
  }
64
+ if (/FROM tasks WHERE id = ANY\(\$1::bigint\[\]\)/.test(sql)) {
65
+ return { rows: params[0].filter((id) => TASKS[id]).map((id) => ({ id, ...TASKS[id] })) };
66
+ }
64
67
  throw new Error(`unexpected query: ${sql}`);
65
68
  },
66
69
  };
@@ -77,7 +80,7 @@ function setup({ latest = '1.0.1', pkStatus = 200 } = {}) {
77
80
  const pool = fakePool();
78
81
  const store = freshStore();
79
82
  const read = (id, over = {}) => readTaskWhere({ pool, id, pkg: PKG, live: '1.0.2', get: fg.get, store, installedNotes: () => null, now: () => Date.parse('2026-09-26T13:00:00Z'), ...over });
80
- return { read, pool, asked: fg.asked };
83
+ return { read, pool, asked: fg.asked, get: fg.get };
81
84
  }
82
85
 
83
86
  // ---- the reading ---------------------------------------------------------------------
@@ -135,6 +138,19 @@ test('the offered flag follows the deploy page: published before this hall ran i
135
138
  assert.equal(w.offered, true);
136
139
  });
137
140
 
141
+ test('the batch reading (task 1004392) places many tasks with ONE query and ONE versions read, and agrees with the single reading', async () => {
142
+ const single = setup();
143
+ const batch = setup();
144
+ const ids = [11, 12, 13, 30, 999];
145
+ const out = await readTasksWhere({ pool: batch.pool, ids, pkg: PKG, live: '1.0.2', get: (...a) => batch.get(...a), store: freshStore(), installedNotes: () => null, now: () => Date.parse('2026-09-26T13:00:00Z') }).catch((e) => { throw e; });
146
+ assert.equal(batch.pool.queries.length, 1, 'one task query for every id');
147
+ assert.deepEqual(batch.asked.map(String).sort(), [`${ORIGIN}/@bongos%2fcore`, `${ORIGIN}/@bongos/core/-/core-1.0.4.tgz`].sort(),
148
+ 'the packument and the notes are fetched once for all three shipped tasks');
149
+ for (const id of [11, 12, 13, 30]) assert.deepEqual(out[String(id)], await single.read(id), 'task ' + id);
150
+ assert.ok(!('999' in out), 'an unknown id is absent, not an error');
151
+ assert.deepEqual(await readTasksWhere({ pool: batch.pool, ids: ['1; DROP', ''] }), {}, 'bad ids are never queried');
152
+ });
153
+
138
154
  // ---- the route -----------------------------------------------------------------------
139
155
 
140
156
  function layers(read) {
@@ -0,0 +1,150 @@
1
+ // tests/speciality_session_skills.mjs — a builder's ENABLED skills reach their
2
+ // session through the existing injection path (task 1004074, part 3 of 3 of
3
+ // ADR 0310).
4
+ //
5
+ // The whole path, not its middle: the adoption row as db.listAdoptions returns
6
+ // it → the real kernel port (describeActiveFor, registered by the real router
7
+ // factory) → the real Conductor's evaluateTriggers. A hand-built contract fed
8
+ // straight to the Conductor would stay green if the port stopped reading
9
+ // enabled_skills, so the port is always in the loop here.
10
+ //
11
+ // Run: node tests/speciality_session_skills.mjs
12
+
13
+ import { strict as assert } from 'node:assert';
14
+ import { createRequire } from 'node:module';
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { makeRunner } from './helpers.mjs';
19
+
20
+ const require = createRequire(import.meta.url);
21
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
22
+ const S = require('../modules/specialities/specialities.js');
23
+ const skills = require('../modules/specialities/skills.js');
24
+ const db = require('../modules/specialities/db.js');
25
+ const api = require('../src/module-api.js');
26
+ const conductor = require(path.join(ROOT, '.claude', 'hooks', 'conductor.js'));
27
+ const { test, summary } = makeRunner();
28
+
29
+ const LEAN = /chose to lean on these skills/;
30
+
31
+ // ---- the pure line ----------------------------------------------------------
32
+ const row = (over = {}) => ({ id: 9, slug: 'economics', name: 'Economics', discipline: 'ideator', summary: '', contract_md: '', skills: ['recall', 'status'], enabled_skills: [], active: true, ...over });
33
+
34
+ await test('enabled skills are named in the contract', () => {
35
+ const d = S.describeForApi(row({ enabled_skills: ['recall', 'status'] }));
36
+ assert.match(d.contract, LEAN);
37
+ assert.match(d.contract, /\/recall, \/status/);
38
+ });
39
+
40
+ await test('nothing enabled says nothing — silent, not an empty list', () => {
41
+ const none = S.describeForApi(row({ enabled_skills: [] }));
42
+ const absent = S.describeForApi(row({ enabled_skills: undefined }));
43
+ const baseline = S.describeForApi(row({ skills: [], enabled_skills: [] }));
44
+ assert.doesNotMatch(none.contract, LEAN);
45
+ assert.equal(none.contract, baseline.contract, 'an adoption with nothing on reads exactly like one with no skills at all');
46
+ assert.equal(absent.contract, baseline.contract);
47
+ });
48
+
49
+ await test('a skill no longer offered, or no longer installed, is not named', () => {
50
+ const d = S.describeForApi(row({ skills: ['recall'], enabled_skills: ['recall', 'status'] }));
51
+ assert.match(d.contract, /\/recall\./);
52
+ assert.doesNotMatch(d.contract, /\/status/, 'the author dropped it from the offer');
53
+ const gone = S.describeForApi(row({ enabled_skills: ['recall', 'status'] }), { installed: new Map([['status', {}]]) });
54
+ assert.match(gone.contract, /\/status\./);
55
+ assert.doesNotMatch(gone.contract, /\/recall/, 'a deploy removed it');
56
+ const allGone = S.describeForApi(row({ enabled_skills: ['recall'] }), { installed: new Map() });
57
+ assert.doesNotMatch(allGone.contract, LEAN, 'filtered to nothing is silent too');
58
+ });
59
+
60
+ await test('the line is advice and says so — it grants nothing', () => {
61
+ const d = S.describeForApi(row({ enabled_skills: ['recall'] }));
62
+ assert.match(d.contract, /advice, not permission/);
63
+ assert.match(d.contract, /only if the builder's rank allows it/);
64
+ });
65
+
66
+ await test('the skills line sits AFTER the provenance fence — it is the adopter\'s choice, not the author\'s prose', () => {
67
+ const d = S.describeForApi(row({ contract_md: 'Speak in elasticities.', enabled_skills: ['recall'] }), { selfAuthored: false, authorLabel: 'Ada' });
68
+ const lines = d.contract.split('\n');
69
+ assert.match(lines[0], /stated working preferences of Ada/);
70
+ assert.match(lines[lines.length - 1], LEAN);
71
+ });
72
+
73
+ await test('a malformed name in the stored set is never relayed', () => {
74
+ const d = S.describeForApi(row({ skills: ['recall', 'Bad Name; rm -rf'], enabled_skills: ['recall', 'Bad Name; rm -rf'] }));
75
+ assert.doesNotMatch(d.contract, /rm -rf/);
76
+ });
77
+
78
+ // ---- the whole path: row → real port → real Conductor -----------------------
79
+ const store = { adoptions: [] };
80
+ db.listAdoptions = async () => store.adoptions.map((a) => ({ ...a }));
81
+ db.countTraining = async () => ({ documentCount: 0, learningCount: 0 });
82
+ db.getSpeciality = async (id) => ({ id, owner_kind: 'builder', owner_builder_id: 1 });
83
+ skills.installedSkillsCached = () => new Map([['recall', {}], ['status', {}]]);
84
+ require('../modules/specialities/routes/specialities.js')();
85
+ const port = api.resolveOptional('specialities');
86
+ const claim = (discipline) => ({ task_id: '1', title: 't', discipline });
87
+
88
+ async function sessionFor(adoptions, claims) {
89
+ store.adoptions = adoptions;
90
+ const specialities = await port.describeActiveFor(1);
91
+ return conductor.evaluateTriggers({ activeClaims: claims, specialities });
92
+ }
93
+ const specEntries = (out) => out.filter((a) => String(a.id).startsWith('speciality:'));
94
+
95
+ await test('the port is the one the router registered', () => {
96
+ assert.equal(typeof port.describeActiveFor, 'function');
97
+ });
98
+
99
+ await test('an adoption with enabled skills reaches the session in its craft', async () => {
100
+ const out = await sessionFor([row({ enabled_skills: ['recall'] })], [claim('ideator')]);
101
+ const sp = specEntries(out);
102
+ assert.equal(sp.length, 1);
103
+ assert.match(sp[0].text, /Economics/);
104
+ assert.match(sp[0].text, /\/recall/);
105
+ });
106
+
107
+ await test('with nothing enabled the session gets the contract and not a word more', async () => {
108
+ const out = await sessionFor([row({ enabled_skills: [] })], [claim('ideator')]);
109
+ const sp = specEntries(out);
110
+ assert.equal(sp.length, 1, 'the speciality itself is still there');
111
+ assert.doesNotMatch(sp[0].text, LEAN);
112
+ });
113
+
114
+ await test('craft-scoped like the contract: an engineering session hears nothing of an ideator\'s skills', async () => {
115
+ const out = await sessionFor([row({ enabled_skills: ['recall'] })], [claim('engineer')]);
116
+ assert.equal(specEntries(out).length, 0);
117
+ });
118
+
119
+ await test('an inactive adoption relays nothing, skills included', async () => {
120
+ const out = await sessionFor([row({ active: false, enabled_skills: ['recall'] })], [claim('ideator')]);
121
+ assert.equal(specEntries(out).length, 0);
122
+ });
123
+
124
+ await test('a failed skills read degrades to the offered filter, never a lost contract', async () => {
125
+ const saved = skills.installedSkillsCached;
126
+ skills.installedSkillsCached = () => { throw new Error('disk gone'); };
127
+ try {
128
+ const out = await sessionFor([row({ enabled_skills: ['recall'] })], [claim('ideator')]);
129
+ assert.match(specEntries(out)[0].text, /\/recall/);
130
+ } finally { skills.installedSkillsCached = saved; }
131
+ });
132
+
133
+ await test('the real adoption read selects both sets the line needs', () => {
134
+ // listAdoptions is stubbed above, so pin the SQL it stands in for: drop either
135
+ // column and the line goes silent for everyone, with nothing erroring.
136
+ const sql = fs.readFileSync(path.join(ROOT, 'modules/specialities/db.js'), 'utf8');
137
+ const read = sql.slice(sql.indexOf('async function listAdoptions'), sql.indexOf('async function taskDiscipline'));
138
+ assert.match(read, /s\.skills/);
139
+ assert.match(read, /a\.enabled_skills/);
140
+ });
141
+
142
+ // ---- grants nothing: nothing that decides what runs reads the enabled set ----
143
+ await test('no permission or materialisation surface reads enabled_skills', () => {
144
+ for (const rel of ['scripts/gds/claude-materialize.js', 'src/bongos/auth.js', 'src/bongos/permission-path-check.js', '.claude/hooks/conductor.js']) {
145
+ const text = fs.readFileSync(path.join(ROOT, rel), 'utf8');
146
+ assert.doesNotMatch(text, /enabled_skills/, `${rel} must not key anything off the enabled set`);
147
+ }
148
+ });
149
+
150
+ summary();