@bongos/core 1.19.811 → 1.19.813

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 (48) hide show
  1. package/.bongos-core.json +108 -43
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +24 -0
  4. package/clients/bongos-client/index.cjs +24 -0
  5. package/clients/bongos-client/index.d.ts +39 -0
  6. package/clients/bongos-client/index.mjs +24 -0
  7. package/docs/adr/0296-a-role-speciality-is-data-over-three-existing-surfaces.md +71 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +867 -73
  10. package/docs/api-reference.md +19 -3
  11. package/docs/copy-inventory.md +48 -20
  12. package/docs/copy-registry.json +275 -23
  13. package/docs/module-api-changelog.md +4 -0
  14. package/modules/dev-box/app/src/vendor/bongos-client.cjs +24 -0
  15. package/modules/hall-ui/public/settings-specialities.js +241 -0
  16. package/modules/hall-ui/public/settings.css +56 -0
  17. package/modules/hall-ui/public/settings.html +65 -0
  18. package/modules/lifecycle/db-claim-reads.js +5 -1
  19. package/modules/lifecycle/db-executor.js +45 -0
  20. package/modules/lifecycle/db-goals.js +29 -7
  21. package/modules/lifecycle/db-tasks.js +11 -5
  22. package/modules/lifecycle/db-versions.js +17 -3
  23. package/modules/lifecycle/done-when.js +5 -2
  24. package/modules/lifecycle/goal-edits.js +5 -1
  25. package/modules/lifecycle/routes/done-when.js +150 -86
  26. package/modules/memory/learnings.js +21 -2
  27. package/modules/provisioning/starter-bundles.js +1 -0
  28. package/modules/specialities/CLAUDE.md +15 -0
  29. package/modules/specialities/db.js +234 -0
  30. package/modules/specialities/migrations/specialities_001_specialities.sql +143 -0
  31. package/modules/specialities/module.json +12 -0
  32. package/modules/specialities/routes/specialities.js +379 -0
  33. package/modules/specialities/specialities.js +198 -0
  34. package/modules/ui-design/kit/serve.js +1 -1
  35. package/package-lock.json +2 -2
  36. package/package.json +1 -1
  37. package/scripts/gds/publish-manifest.js +1 -0
  38. package/scripts/gds/search-ingest-db.js +37 -0
  39. package/src/bongos/routes/me.js +17 -0
  40. package/src/module-api.js +1 -1
  41. package/tests/criterion_authoring_gate.mjs +22 -1
  42. package/tests/criterion_goal_attach.mjs +233 -8
  43. package/tests/module_loader.mjs +1 -1
  44. package/tests/own_scope_guard.mjs +10 -0
  45. package/tests/specialities.mjs +214 -0
  46. package/tests/speciality_corpus_scope.mjs +97 -0
  47. package/tests/speciality_db_shape.mjs +124 -0
  48. package/tests/speciality_injection.mjs +107 -0
@@ -130,40 +130,58 @@ module.exports = function buildDoneWhenRouter() {
130
130
  goal_id: { type: 'integer', min: 1 },
131
131
  sort_order: { type: 'integer', min: 0 },
132
132
  })) return;
133
- const version = await db.getVersion(versionId);
134
- if (!version) return res.fail('version_not_found', { status: 404, message: `No version '${versionId}'.` });
135
133
  // bad_goal_id now means exactly what it says: the goal is absent or belongs
136
134
  // to another version. (The unauthorized case rendered through this same
137
135
  // helper until task 1002614 — see the mask note in authorizeCriterionCreate.)
138
136
  const badGoalId = () => res.fail('bad_goal_id', { status: 400, message: `goal_id ${req.body.goal_id} does not exist on version '${versionId}'.` });
139
- // A goal_id, if given, must exist AND belong to this version.
140
- let goal = null;
141
- if (req.body.goal_id != null) {
142
- goal = await db.getGoal(req.body.goal_id);
143
- if (!goal || String(goal.version_id) !== String(versionId)) return badGoalId();
144
- }
145
- // ADR 0154: the authority wall. Checked AFTER the goal's version linkage so a
146
- // bad ref is still the clearer 400. Membership is only read when a goal was
147
- // named and the caller isn't an Archon — a primary-key hit on
148
- // goal_members (goal_id, builder_id) (migration 160), on a low-frequency route.
149
- const actorMembership = (goal && req.builder.rank !== 'archon')
150
- ? await db.getGoalMember({ goalId: goal.id, builderId: req.builder.id })
151
- : null;
152
- const decision = authorizeCriterionCreate({
153
- goal, version, actorId: req.builder.id, actorRank: req.builder.rank, actorMembership,
154
- });
155
- if (!decision.ok) {
156
- return res.status(decision.status).json(decision.body);
157
- }
158
137
  try {
159
- const criterion = await db.createCriterion({
160
- versionId,
161
- criterionId: req.body.criterion_id,
162
- criterionMd: req.body.criterion_md,
163
- goalId: req.body.goal_id ?? null,
164
- sortOrder: req.body.sort_order ?? null,
165
- });
166
- res.status(201).json({ ok: true, criterion });
138
+ // RESOLVE, DECIDE and WRITE in ONE transaction, with the goal row LOCKED
139
+ // (task 1003840). Before this, the three facts the decision rests on — the
140
+ // version's status, the goal's owner/version linkage, the actor's
141
+ // membership — were read on the pool, and the INSERT landed afterwards on
142
+ // another connection. Between the two, the goal could be transferred or
143
+ // closed, the membership revoked, or the version frozen, and the write
144
+ // would land on authority that no longer held.
145
+ //
146
+ // The LOCK is the part that closes it. A bare transaction around the same
147
+ // sequence would still read the stale rows; the lock makes a concurrent
148
+ // transfer wait behind this one instead of racing it.
149
+ const outcome = await api.withTx(async (client) => {
150
+ const version = await db.getVersion(versionId, { client, forUpdate: true });
151
+ if (!version) return { fail: ['version_not_found', { status: 404, message: `No version '${versionId}'.` }] };
152
+ // A goal_id, if given, must exist AND belong to this version.
153
+ let goal = null;
154
+ if (req.body.goal_id != null) {
155
+ goal = await db.getGoal(req.body.goal_id, { client, forUpdate: true });
156
+ if (!goal || String(goal.version_id) !== String(versionId)) return { badGoal: true };
157
+ }
158
+ // ADR 0154: the authority wall. Checked AFTER the goal's version linkage so a
159
+ // bad ref is still the clearer 400. Membership is only read when a goal was
160
+ // named and the caller isn't an Archon — a primary-key hit on
161
+ // goal_members (goal_id, builder_id) (migration 160), on a low-frequency route.
162
+ const actorMembership = (goal && req.builder.rank !== 'archon')
163
+ ? await db.getGoalMember({ goalId: goal.id, builderId: req.builder.id }, { client })
164
+ : null;
165
+ const decision = authorizeCriterionCreate({
166
+ goal, version, actorId: req.builder.id, actorRank: req.builder.rank, actorMembership,
167
+ });
168
+ if (!decision.ok) return { decision };
169
+ const criterion = await db.createCriterion({
170
+ versionId,
171
+ criterionId: req.body.criterion_id,
172
+ criterionMd: req.body.criterion_md,
173
+ goalId: req.body.goal_id ?? null,
174
+ sortOrder: req.body.sort_order ?? null,
175
+ }, { client });
176
+ return { criterion };
177
+ }, { pool: api.pool });
178
+ // Responses are sent AFTER the transaction resolves, never from inside it:
179
+ // a refusal must roll back rather than commit a half-written unit of work,
180
+ // and a 201 must not be promised before the COMMIT succeeds.
181
+ if (outcome.badGoal) return badGoalId();
182
+ if (outcome.fail) return res.fail(outcome.fail[0], outcome.fail[1]);
183
+ if (outcome.decision) return res.status(outcome.decision.status).json(outcome.decision.body);
184
+ res.status(201).json({ ok: true, criterion: outcome.criterion });
167
185
  } catch (err) {
168
186
  if (err && err.code === '23505') {
169
187
  return res.fail('criterion_exists', { status: 409, message: `criterion_id '${req.body.criterion_id}' already exists on version '${versionId}'.` });
@@ -294,64 +312,82 @@ module.exports = function buildDoneWhenRouter() {
294
312
  }
295
313
  const id = parseId(req, res, { param: 'criterionId', code: 'bad_criterion_id' });
296
314
  if (id === null) return;
297
- const existing = await doneWhen.getCriterion(id);
298
- if (!existing) return res.fail('criterion_not_found', 404);
299
- const version = await db.getVersion(existing.version_id);
300
- // The ownership wall, resolved exactly as the CREATE route resolves it: the
301
- // goal + version, and membership read only for a non-Archon. One closure, so
302
- // both ends of a move are decided by the same code rather than a copy.
303
- const authorizeFor = async (goal) => {
304
- const actorMembership = (goal && req.builder.rank !== 'archon')
305
- ? await db.getGoalMember({ goalId: goal.id, builderId: req.builder.id })
306
- : null;
307
- return authorizeCriterionCreate({
308
- goal, version, actorId: req.builder.id, actorRank: req.builder.rank, actorMembership,
309
- });
310
- };
311
- const currentGoal = existing.goal_id != null ? await db.getGoal(existing.goal_id) : null;
312
- const authority = await authorizeFor(currentGoal);
313
- if (!authority.ok) return res.status(authority.status).json(authority.body);
315
+ // Resolve, authorize and write inside ONE transaction, both goal rows LOCKED
316
+ // (task 1003840) — the same window the CREATE route above had, and the same
317
+ // cure. A move reads authority at BOTH ends, so both ends are locked: losing
318
+ // the race at either would move a criterion under authority that had just
319
+ // been revoked. Locking in a stable order (source before destination) is what
320
+ // keeps two concurrent moves from deadlocking on each other's rows.
321
+ const outcome = await api.withTx(async (client) => {
322
+ const existing = await doneWhen.getCriterion(id, { client });
323
+ if (!existing) return { fail: ['criterion_not_found', 404] };
324
+ const version = await db.getVersion(existing.version_id, { client, forUpdate: true });
325
+ // The ownership wall, resolved exactly as the CREATE route resolves it: the
326
+ // goal + version, and membership read only for a non-Archon. One closure, so
327
+ // both ends of a move are decided by the same code rather than a copy.
328
+ const authorizeFor = async (goal) => {
329
+ const actorMembership = (goal && req.builder.rank !== 'archon')
330
+ ? await db.getGoalMember({ goalId: goal.id, builderId: req.builder.id }, { client })
331
+ : null;
332
+ return authorizeCriterionCreate({
333
+ goal, version, actorId: req.builder.id, actorRank: req.builder.rank, actorMembership,
334
+ });
335
+ };
336
+ const targetGoalId = movingGoal ? (req.body.goal_id ?? null) : null;
337
+
338
+ // Lock BOTH ends in ASCENDING GOAL ID, never in the order the route happens
339
+ // to need them (task 1003840, round 2 — the first cut locked source then
340
+ // destination, which is call order, not a canonical one). Two opposite
341
+ // moves, 5→10 and 10→5, would then take the two locks in opposite orders
342
+ // and deadlock; Postgres aborts one rather than corrupting anything, but an
343
+ // avoidable abort is still an outage for whoever loses. A single agreed
344
+ // order means they queue instead.
345
+ const lockIds = [...new Set([
346
+ existing.goal_id != null ? Number(existing.goal_id) : null,
347
+ targetGoalId !== null ? Number(targetGoalId) : null,
348
+ ].filter((v) => v != null))].sort((a, b) => a - b);
349
+ const locked = new Map();
350
+ for (const gid of lockIds) locked.set(gid, await db.getGoal(gid, { client, forUpdate: true }));
351
+
352
+ const currentGoal = existing.goal_id != null ? locked.get(Number(existing.goal_id)) ?? null : null;
353
+ const authority = await authorizeFor(currentGoal);
354
+ if (!authority.ok) return { decision: authority };
314
355
 
315
- const targetGoalId = movingGoal ? (req.body.goal_id ?? null) : null;
316
- if (movingGoal) {
317
- let targetGoal = null;
318
- if (targetGoalId !== null) {
319
- targetGoal = await db.getGoal(targetGoalId);
320
- // Same refusal the CREATE route gives, for the same reason: a criterion may
321
- // never cross versions, which would put it in one version's rollup and the
322
- // other's goal at once.
323
- if (!targetGoal || String(targetGoal.version_id) !== String(existing.version_id)) {
324
- return res.fail('bad_goal_id', { status: 400, message: `goal_id ${targetGoalId} does not exist on version '${existing.version_id}'.` });
356
+ if (movingGoal) {
357
+ let targetGoal = null;
358
+ if (targetGoalId !== null) {
359
+ targetGoal = locked.get(Number(targetGoalId)) ?? null;
360
+ // Same refusal the CREATE route gives, for the same reason: a criterion may
361
+ // never cross versions, which would put it in one version's rollup and the
362
+ // other's goal at once.
363
+ if (!targetGoal || String(targetGoal.version_id) !== String(existing.version_id)) {
364
+ return { fail: ['bad_goal_id', { status: 400, message: `goal_id ${targetGoalId} does not exist on version '${existing.version_id}'.` }] };
365
+ }
325
366
  }
367
+ const destination = await authorizeFor(targetGoal);
368
+ if (!destination.ok) return { decision: destination };
326
369
  }
327
- const destination = await authorizeFor(targetGoal);
328
- if (!destination.ok) return res.status(destination.status).json(destination.body);
329
- }
330
370
 
331
- let criterion = existing;
332
- let deltas = [];
333
- let unchanged = false;
334
- if (changingProse) {
335
- const refusal = goalEdits.criterionProseRefusal(existing);
336
- if (refusal) return res.fail(refusal.code, refusal.status, refusal.message ? { message: refusal.message } : undefined);
337
- const result = await goalEdits.updateCriterionProse({ criterionId: id, criterionMd: req.body.criterion_md });
338
- if (!result) return res.fail('criterion_not_found', 404);
339
- await goalEdits.recordProseEdit('criterion', id, req, result.deltas);
340
- criterion = result.criterion;
341
- deltas = result.deltas;
342
- unchanged = !!result.unchanged;
343
- }
344
- const achievedGoals = [];
345
- const fromGoalId = existing.goal_id ?? null;
371
+ let criterion = existing;
372
+ let deltas = [];
373
+ let unchanged = false;
374
+ if (changingProse) {
375
+ const refusal = goalEdits.criterionProseRefusal(existing);
376
+ if (refusal) return { refusal };
377
+ const result = await goalEdits.updateCriterionProse({ criterionId: id, criterionMd: req.body.criterion_md }, { client });
378
+ if (!result) return { fail: ['criterion_not_found', 404] };
379
+ criterion = result.criterion;
380
+ deltas = result.deltas;
381
+ unchanged = !!result.unchanged;
382
+ }
383
+ const achievedGoals = [];
384
+ const fromGoalId = existing.goal_id ?? null;
346
385
  // A move to where it already is writes nothing at all — not the row, not the
347
386
  // audit, not a re-derive. Nothing about the world changed.
348
- if (movingGoal && fromGoalId !== targetGoalId) {
349
- const moved = await db.updateCriterionGoal(id, targetGoalId);
350
- if (!moved) return res.fail('criterion_not_found', 404);
351
- criterion = moved;
352
- await goalEdits.recordCriterionGoalMove(
353
- { criterionId: id, fromGoalId, toGoalId: targetGoalId }, req
354
- );
387
+ if (movingGoal && fromGoalId !== targetGoalId) {
388
+ const moved = await db.updateCriterionGoal(id, targetGoalId, { client });
389
+ if (!moved) return { fail: ['criterion_not_found', 404] };
390
+ criterion = moved;
355
391
  // BOTH ENDS are re-derived, because a move changes what each of them still
356
392
  // needs. The destination is the obvious half — adopting an already-satisfied
357
393
  // criterion can be the thing that completes a goal. The SOURCE is the half
@@ -361,13 +397,41 @@ module.exports = function buildDoneWhenRouter() {
361
397
  // will ever close again — no later ship re-checks it, because no task under
362
398
  // it changed. achieveGoalIfComplete is conservative and idempotent (open
363
399
  // goals only, and it never un-achieves), so asking twice is two reads.
364
- for (const goalId of [fromGoalId, targetGoalId]) {
365
- if (goalId === null) continue;
366
- const outcome = await db.achieveGoalIfComplete({ goalId });
367
- if (outcome && outcome.achieved) achievedGoals.push(goalId);
400
+ for (const goalId of [fromGoalId, targetGoalId]) {
401
+ if (goalId === null) continue;
402
+ const achieved = await db.achieveGoalIfComplete({ goalId }, { client });
403
+ if (achieved && achieved.achieved) achievedGoals.push(goalId);
404
+ }
368
405
  }
406
+ return { criterion, deltas, unchanged, achievedGoals, moved: movingGoal && fromGoalId !== targetGoalId, fromGoalId, targetGoalId };
407
+ }, { pool: api.pool });
408
+
409
+ // Everything below runs only after COMMIT. The audit writes in particular
410
+ // belong out here: they are announcements of a change that happened, so
411
+ // recording one for a unit of work that rolled back would put an edit in the
412
+ // ledger that never landed — the ledger lying, which is the one thing it
413
+ // must not do.
414
+ if (outcome.fail) return res.fail(outcome.fail[0], outcome.fail[1]);
415
+ if (outcome.decision) return res.status(outcome.decision.status).json(outcome.decision.body);
416
+ if (outcome.refusal) {
417
+ const r = outcome.refusal;
418
+ return res.fail(r.code, r.status, r.message ? { message: r.message } : undefined);
369
419
  }
370
- return res.json({ ok: true, criterion, deltas, unchanged, achieved_goals: achievedGoals });
420
+ if (outcome.deltas && outcome.deltas.length) {
421
+ await goalEdits.recordProseEdit('criterion', id, req, outcome.deltas);
422
+ }
423
+ if (outcome.moved) {
424
+ await goalEdits.recordCriterionGoalMove(
425
+ { criterionId: id, fromGoalId: outcome.fromGoalId, toGoalId: outcome.targetGoalId }, req
426
+ );
427
+ }
428
+ return res.json({
429
+ ok: true,
430
+ criterion: outcome.criterion,
431
+ deltas: outcome.deltas,
432
+ unchanged: outcome.unchanged,
433
+ achieved_goals: outcome.achievedGoals,
434
+ });
371
435
  }, { errorCode: 'criterion_update_failed' }));
372
436
 
373
437
  // Auth: flip a criterion back to unsatisfied (rare; for corrections).
@@ -13,7 +13,7 @@
13
13
 
14
14
  // Carved into modules/memory/ (BV1.R72 / ADR 0091 §2): pool via the doorway.
15
15
  const crypto = require('crypto');
16
- const { pool } = require('../../src/module-api');
16
+ const { pool, resolveOptional } = require('../../src/module-api');
17
17
  // ADR 0095 C4: <private>…</private>-fenced content is stripped at ingest so it
18
18
  // never lands in the learnings store (nor the recall index that reads it).
19
19
  const { stripPrivate } = require('./search-chunker');
@@ -111,6 +111,20 @@ async function createLearning({
111
111
  }
112
112
  }
113
113
 
114
+ // task 1003855: once the row lands, offer it to the builder's active speciality
115
+ // for that task's craft (training half (b)). Through the PORT, so this module
116
+ // never requires the specialities module and a disabled one simply means no
117
+ // link. Best-effort by construction: a capture must never fail over it.
118
+ const offerToSpeciality = async (row) => {
119
+ if (!row || !row.id) return;
120
+ try {
121
+ const sp = typeof resolveOptional === 'function' ? resolveOptional('specialities') : null;
122
+ if (sp && typeof sp.recordLearningForTask === 'function') {
123
+ await sp.recordLearningForTask({ learningId: row.id, builderId: row.captured_by, taskId: row.task_id });
124
+ }
125
+ } catch { /* never fail a capture over speciality bookkeeping */ }
126
+ };
127
+
114
128
  const { rows } = await pool.query(
115
129
  `INSERT INTO learnings (title, body_md, tags, related_files, captured_by,
116
130
  source, source_ref, task_id)
@@ -131,7 +145,12 @@ async function createLearning({
131
145
  taskId,
132
146
  ]
133
147
  );
134
- return rows[0] ?? null;
148
+ const created = rows[0] ?? null;
149
+ // Awaited, not fire-and-forget: an unawaited promise here would let the process
150
+ // exit mid-write in a CLI capture, and the helper already swallows its own
151
+ // failures, so awaiting costs one round trip and never fails the capture.
152
+ await offerToSpeciality(created);
153
+ return created;
135
154
  }
136
155
 
137
156
  // -------------------------------------------------------------------------
@@ -59,6 +59,7 @@ const ALWAYS_ON_CORE = Object.freeze([
59
59
  'public-landing',
60
60
  'security',
61
61
  'sessions',
62
+ 'specialities',
62
63
  'status-ui',
63
64
  'ui-design',
64
65
  ]);
@@ -0,0 +1,15 @@
1
+ # @bongos/core — installed platform dependency (not your project's CLAUDE.md)
2
+
3
+ This file is a placeholder shipped inside the **@bongos/core** package. The core's own
4
+ internal agent notes are intentionally omitted from the published artifact so they cannot
5
+ load into your instance's context.
6
+
7
+ **It does not govern this repository.** Your own root `CLAUDE.md`, your `.claude/`
8
+ directory, and the live Bongos database are the source of truth for how work happens here
9
+ and who may do what.
10
+
11
+ You are seeing this only because an agent opened a file under `node_modules/@bongos/core/`.
12
+ Nothing in here needs to be read to use the platform — run `bongos help` for commands.
13
+
14
+ _To work ON the platform core itself, use the standalone @bongos/core source repository,
15
+ not this vendored copy._
@@ -0,0 +1,234 @@
1
+ 'use strict';
2
+
3
+ // modules/specialities/db.js — the queries behind role sub-specialities
4
+ // (task 1003855; ADR 0296). The RULES live in specialities.js and are pure; this
5
+ // file is only the reach into Postgres, through the published doorway
6
+ // (src/module-api.js) per ADR 0091 §2 — never a core internal.
7
+ //
8
+ // EVERY FUNCTION TAKES AN OPTIONAL `executor`. That is the modules/lifecycle/
9
+ // db-deps-criteria.js precedent: the seam lets the route-level tests drive these
10
+ // with a fake and assert the SQL shape without a database, which matters here
11
+ // because the adoption path has a uniqueness rule that is easy to get wrong and
12
+ // expensive to discover in production.
13
+
14
+ const { pool } = require('../../src/module-api');
15
+
16
+ const exec = (executor) => executor || ((text, params) => pool.query(text, params));
17
+
18
+ const COLUMNS = `id, slug, name, discipline, summary, contract_md,
19
+ owner_kind, owner_builder_id, owner_project_id,
20
+ visibility, sellable, created_by, created_at, updated_at`;
21
+
22
+ async function createSpeciality(row, executor) {
23
+ const q = exec(executor);
24
+ const r = await q(
25
+ `INSERT INTO specialities_catalog
26
+ (slug, name, discipline, summary, contract_md, owner_kind,
27
+ owner_builder_id, owner_project_id, visibility, sellable, created_by)
28
+ VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
29
+ RETURNING ${COLUMNS}`,
30
+ [row.slug, row.name, row.discipline, row.summary || '', row.contract_md || '',
31
+ row.owner_kind, row.owner_builder_id || null, row.owner_project_id || null,
32
+ row.visibility || 'private', !!row.sellable, row.created_by || null],
33
+ );
34
+ return r.rows[0] || null;
35
+ }
36
+
37
+ async function getSpeciality(id, executor) {
38
+ const q = exec(executor);
39
+ const r = await q(`SELECT ${COLUMNS} FROM specialities_catalog WHERE id = $1`, [id]);
40
+ return r.rows[0] || null;
41
+ }
42
+
43
+ // The catalogue read. Deliberately returns rows the viewer may NOT adopt as well
44
+ // as ones they may: the route filters with specialities.canAdopt so the rule
45
+ // lives in one pure place. Widening this SQL without touching canAdopt is the
46
+ // mistake this comment exists to prevent.
47
+ async function listVisibleTo({ builderId, projectIds = [], discipline = null }, executor) {
48
+ const q = exec(executor);
49
+ const params = [builderId, projectIds.length ? projectIds : [null]];
50
+ let sql = `SELECT ${COLUMNS} FROM specialities_catalog
51
+ WHERE visibility = 'public'
52
+ OR (owner_kind = 'builder' AND owner_builder_id = $1)
53
+ OR (owner_kind = 'project' AND visibility = 'project' AND owner_project_id = ANY($2))`;
54
+ if (discipline) { params.push(discipline); sql += ` AND discipline = $${params.length}`; }
55
+ sql += ' ORDER BY name ASC';
56
+ const r = await q(sql, params);
57
+ return r.rows;
58
+ }
59
+
60
+ // A speciality's DISCIPLINE IS IMMUTABLE and is not in this list on purpose:
61
+ // moving one between crafts would silently re-scope everyone who adopted it, and
62
+ // the adoption rows carry a denormalised copy the migration relies on.
63
+ const UPDATABLE = ['name', 'summary', 'contract_md', 'visibility', 'sellable'];
64
+
65
+ async function updateSpeciality(id, patch, executor) {
66
+ const q = exec(executor);
67
+ const sets = [];
68
+ const params = [];
69
+ for (const f of UPDATABLE) {
70
+ if (patch[f] === undefined) continue;
71
+ params.push(patch[f]);
72
+ sets.push(`${f} = $${params.length}`);
73
+ }
74
+ if (!sets.length) return getSpeciality(id, executor);
75
+ params.push(id);
76
+ const r = await q(
77
+ `UPDATE specialities_catalog SET ${sets.join(', ')}, updated_at = now()
78
+ WHERE id = $${params.length} RETURNING ${COLUMNS}`,
79
+ params,
80
+ );
81
+ return r.rows[0] || null;
82
+ }
83
+
84
+ // ── training (a): documents the builder supplied ────────────────────────────
85
+ // ON CONFLICT DO NOTHING against the (speciality_id, content_sha) index: adding
86
+ // the same document twice is a no-op rather than a duplicate that would double
87
+ // its weight in the corpus. Returns null when nothing was inserted, so the route
88
+ // can answer 200-already-there instead of pretending it created something.
89
+ async function addDocument(row, executor) {
90
+ const q = exec(executor);
91
+ const r = await q(
92
+ `INSERT INTO specialities_documents (speciality_id, title, body_md, source, content_sha, added_by)
93
+ VALUES ($1,$2,$3,$4,$5,$6)
94
+ ON CONFLICT (speciality_id, content_sha) DO NOTHING
95
+ RETURNING id, speciality_id, title, source, content_sha, added_by, created_at`,
96
+ [row.speciality_id, row.title, row.body_md, row.source || 'paste', row.content_sha, row.added_by || null],
97
+ );
98
+ return r.rows[0] || null;
99
+ }
100
+
101
+ // Metadata only — the bodies are the corpus and can be large. A caller that wants
102
+ // content asks for one document by id.
103
+ async function listDocuments(specialityId, executor) {
104
+ const q = exec(executor);
105
+ const r = await q(
106
+ `SELECT id, speciality_id, title, source, content_sha, added_by, created_at,
107
+ length(body_md) AS body_chars
108
+ FROM specialities_documents WHERE speciality_id = $1 ORDER BY created_at DESC`,
109
+ [specialityId],
110
+ );
111
+ return r.rows;
112
+ }
113
+
114
+ async function getDocument(specialityId, docId, executor) {
115
+ const q = exec(executor);
116
+ const r = await q(
117
+ `SELECT id, speciality_id, title, body_md, source, content_sha, created_at
118
+ FROM specialities_documents WHERE speciality_id = $1 AND id = $2`,
119
+ [specialityId, docId],
120
+ );
121
+ return r.rows[0] || null;
122
+ }
123
+
124
+ async function deleteDocument(specialityId, docId, executor) {
125
+ const q = exec(executor);
126
+ const r = await q(
127
+ 'DELETE FROM specialities_documents WHERE speciality_id = $1 AND id = $2 RETURNING id',
128
+ [specialityId, docId],
129
+ );
130
+ return r.rowCount > 0;
131
+ }
132
+
133
+ // ── training (b): what sessions taught it ───────────────────────────────────
134
+ async function linkLearning(specialityId, learningId, executor) {
135
+ const q = exec(executor);
136
+ const r = await q(
137
+ `INSERT INTO specialities_learnings (speciality_id, learning_id) VALUES ($1,$2)
138
+ ON CONFLICT (speciality_id, learning_id) DO NOTHING RETURNING learning_id`,
139
+ [specialityId, learningId],
140
+ );
141
+ return r.rowCount > 0;
142
+ }
143
+
144
+ async function countTraining(specialityId, executor) {
145
+ const q = exec(executor);
146
+ const r = await q(
147
+ `SELECT (SELECT count(*) FROM specialities_documents WHERE speciality_id = $1) AS documents,
148
+ (SELECT count(*) FROM specialities_learnings WHERE speciality_id = $1) AS learnings`,
149
+ [specialityId],
150
+ );
151
+ const row = r.rows[0] || {};
152
+ return { documentCount: Number(row.documents || 0), learningCount: Number(row.learnings || 0) };
153
+ }
154
+
155
+ // ── adoption ────────────────────────────────────────────────────────────────
156
+ // Switching craft speciality is DEACTIVATE-THEN-ACTIVATE inside one statement
157
+ // pair, because the partial unique index (builder_id, discipline) WHERE active
158
+ // would otherwise reject the second adoption. Both run on the same executor so a
159
+ // caller may wrap them in a transaction.
160
+ async function deactivateCraft(builderId, discipline, executor) {
161
+ const q = exec(executor);
162
+ await q(
163
+ 'UPDATE specialities_adoptions SET active = FALSE WHERE builder_id = $1 AND discipline = $2 AND active',
164
+ [builderId, discipline],
165
+ );
166
+ }
167
+
168
+ async function adopt({ builderId, specialityId, discipline }, executor) {
169
+ const q = exec(executor);
170
+ await deactivateCraft(builderId, discipline, executor);
171
+ const r = await q(
172
+ `INSERT INTO specialities_adoptions (builder_id, speciality_id, discipline, active)
173
+ VALUES ($1,$2,$3,TRUE)
174
+ ON CONFLICT (builder_id, speciality_id)
175
+ DO UPDATE SET active = TRUE, discipline = EXCLUDED.discipline, adopted_at = now()
176
+ RETURNING builder_id, speciality_id, discipline, active, adopted_at`,
177
+ [builderId, specialityId, discipline],
178
+ );
179
+ return r.rows[0] || null;
180
+ }
181
+
182
+ async function abandon(builderId, specialityId, executor) {
183
+ const q = exec(executor);
184
+ const r = await q(
185
+ 'UPDATE specialities_adoptions SET active = FALSE WHERE builder_id = $1 AND speciality_id = $2 AND active RETURNING speciality_id',
186
+ [builderId, specialityId],
187
+ );
188
+ return r.rowCount > 0;
189
+ }
190
+
191
+ // The read the session injection path depends on: the builder's active
192
+ // speciality for a craft, joined to the speciality itself.
193
+ async function activeForCraft(builderId, discipline, executor) {
194
+ const q = exec(executor);
195
+ const r = await q(
196
+ `SELECT s.id, s.slug, s.name, s.discipline, s.summary, s.contract_md
197
+ FROM specialities_adoptions a JOIN specialities_catalog s ON s.id = a.speciality_id
198
+ WHERE a.builder_id = $1 AND a.discipline = $2 AND a.active
199
+ LIMIT 1`,
200
+ [builderId, discipline],
201
+ );
202
+ return r.rows[0] || null;
203
+ }
204
+
205
+ async function listAdoptions(builderId, executor) {
206
+ const q = exec(executor);
207
+ const r = await q(
208
+ // contract_md is HERE because the session-injection port builds the injected
209
+ // fragment from this row — omitting it silently dropped the expert's own terms
210
+ // and left only the name and summary reaching the session.
211
+ `SELECT s.id, s.slug, s.name, s.discipline, s.summary, s.contract_md, a.active, a.adopted_at
212
+ FROM specialities_adoptions a JOIN specialities_catalog s ON s.id = a.speciality_id
213
+ WHERE a.builder_id = $1 ORDER BY a.adopted_at DESC`,
214
+ [builderId],
215
+ );
216
+ return r.rows;
217
+ }
218
+
219
+ // The craft of a task, used to decide WHICH speciality a learning belongs to.
220
+ // `tasks` is a CORE table, read the same way builder-settings reads core
221
+ // `builders` — this is not a reach into another module's own tables.
222
+ async function taskDiscipline(taskId, executor) {
223
+ const q = exec(executor);
224
+ const r = await q('SELECT discipline FROM tasks WHERE id = $1', [taskId]);
225
+ return (r.rows[0] && r.rows[0].discipline) || null;
226
+ }
227
+
228
+ module.exports = {
229
+ UPDATABLE, taskDiscipline,
230
+ createSpeciality, getSpeciality, listVisibleTo, updateSpeciality,
231
+ addDocument, listDocuments, getDocument, deleteDocument,
232
+ linkLearning, countTraining,
233
+ adopt, abandon, deactivateCraft, activeForCraft, listAdoptions,
234
+ };