@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
@@ -0,0 +1,241 @@
1
+ // modules/hall-ui/public/settings-specialities.js — the "Your specialities" panel
2
+ // on /builders/settings (task 1003855; ADR 0296).
3
+ //
4
+ // WHAT A SPECIALITY IS, for anyone reading this file first: a craft says HOW you
5
+ // work (engineer / ideator / artist / ui); a speciality says what you work ON and
6
+ // how you, as a subject-matter expert, want to be engaged inside that craft. The
7
+ // contract you write here is injected into every session where you hold a claim
8
+ // in that craft.
9
+ //
10
+ // Its own file rather than more of settings.js, on the settings-interaction.js /
11
+ // settings-sessions.js precedent and for the same stated reason: settings.js sits
12
+ // against the 1500-line size ratchet. Self-contained — it shares nothing with the
13
+ // rest of the page but `window.OTB` and the generated client, both loaded before it.
14
+ //
15
+ // TRAINING IS TWO-FOLD, and only half of it is visible here. Documents you add are
16
+ // the half you drive; the other half accumulates on its own from learnings captured
17
+ // while you work in the speciality. The counts row says which is which, so "it
18
+ // knows 4 things" is never mistaken for "I typed 4 things".
19
+
20
+ (() => {
21
+ 'use strict';
22
+
23
+ const API = '/api/bongos';
24
+ const { escapeHtml, $ } = window.OTB;
25
+ const toast = (msg) => window.OTB.toast(msg);
26
+
27
+ const state = { rows: [], openId: null, busy: false };
28
+
29
+ const el = (id) => document.getElementById(id);
30
+ const statusLine = (msg) => { const s = el('specialities-status'); if (s) s.textContent = msg || ''; };
31
+
32
+ async function call(method, path, body) {
33
+ const res = await fetch(`${API}${path}`, {
34
+ method,
35
+ credentials: 'same-origin',
36
+ headers: body ? { 'Content-Type': 'application/json', Accept: 'application/json' } : { Accept: 'application/json' },
37
+ body: body ? JSON.stringify(body) : undefined,
38
+ });
39
+ if (res.status === 401) { window.location.assign(`${API}/auth/web/start`); return null; }
40
+ let data = null;
41
+ try { data = await res.json(); } catch { /* an empty body is legal on some paths */ }
42
+ if (!res.ok) {
43
+ // Surface the field-level reason when the server sent one — a bare
44
+ // "failed" on a form with five fields is useless.
45
+ const detail = data && data.details && Array.isArray(data.details.errors)
46
+ ? data.details.errors.map((e) => `${e.field}: ${e.code}`).join(', ')
47
+ : (data && data.message) || `HTTP ${res.status}`;
48
+ throw new Error(detail);
49
+ }
50
+ return data;
51
+ }
52
+
53
+ const VISIBILITY_COPY = {
54
+ private: 'Only you',
55
+ project: 'Anyone in the owning project',
56
+ public: 'Anyone on this instance',
57
+ };
58
+
59
+
60
+ // THE INSTRUCTION THE ADOPTER IS AGREEING TO. A speciality someone else wrote
61
+ // puts their prose into YOUR sessions, which is the only place in this product
62
+ // where that happens — so it is shown in full before "Use this", never folded
63
+ // away behind a summary. Shown for your own too, because seeing what actually
64
+ // reaches a session is the point either way.
65
+ function contractHtml(s) {
66
+ if (!s.contract_md) return '';
67
+ const theirs = !s.mine;
68
+ return `
69
+ <details class="speciality-contract"${theirs ? ' open' : ''}>
70
+ <summary>${theirs ? 'What this adds to your sessions — written by someone else' : 'What this adds to your sessions'}</summary>
71
+ <pre class="speciality-contract__body">${escapeHtml(s.contract_md)}</pre>
72
+ ${theirs ? '<p class="prow__hint">You did not write this. Read it before using the speciality — it reaches every session where you hold work in that craft, and it is passed to Claude as this author\'s stated preferences, not as instructions it must obey.</p>' : ''}
73
+ </details>`;
74
+ }
75
+
76
+ function cardHtml(s) {
77
+ const trained = Number(s.documentCount || 0);
78
+ const learned = Number(s.learningCount || 0);
79
+ const open = String(state.openId) === String(s.id);
80
+ return `
81
+ <li class="prow speciality-card" data-id="${escapeHtml(String(s.id))}">
82
+ <div class="prow__main">
83
+ <div class="prow__label">
84
+ <strong>${escapeHtml(s.name)}</strong>
85
+ <span class="speciality-craft">${escapeHtml(s.discipline)}</span>
86
+ ${s.active ? '<span class="speciality-badge speciality-badge--on">active</span>' : ''}
87
+ </div>
88
+ <p class="prow__hint">${escapeHtml(s.summary || 'No summary yet.')}</p>
89
+ ${contractHtml(s)}
90
+ <p class="prow__hint speciality-counts">
91
+ Trained on <strong>${trained}</strong> document${trained === 1 ? '' : 's'} you added
92
+ · learned <strong>${learned}</strong> thing${learned === 1 ? '' : 's'} from your sessions
93
+ · shared with: ${escapeHtml(VISIBILITY_COPY[s.visibility] || s.visibility || 'only you')}
94
+ </p>
95
+ </div>
96
+ <div class="prow__actions">
97
+ ${s.mine ? `<button type="button" class="btn btn--quiet" data-act="train" data-id="${escapeHtml(String(s.id))}">${open ? 'Close' : 'Train'}</button>` : ''}
98
+ ${s.active
99
+ ? `<button type="button" class="btn btn--quiet" data-act="abandon" data-id="${escapeHtml(String(s.id))}">Stop using</button>`
100
+ : `<button type="button" class="btn" data-act="adopt" data-id="${escapeHtml(String(s.id))}" ${s.adoptable ? '' : 'disabled'}>Use this</button>`}
101
+ </div>
102
+ ${open ? trainHtml(s) : ''}
103
+ </li>`;
104
+ }
105
+
106
+ function trainHtml(s) {
107
+ return `
108
+ <form class="speciality-train" data-id="${escapeHtml(String(s.id))}">
109
+ <p class="prow__hint">
110
+ Add something you wrote — notes, a standard, a worked example. It is indexed
111
+ for recall, not pasted into every session, so length costs you nothing.
112
+ </p>
113
+ <label class="speciality-field">
114
+ <span>Title</span>
115
+ <input type="text" name="title" maxlength="200" required placeholder="How we price a swap" />
116
+ </label>
117
+ <label class="speciality-field">
118
+ <span>The document</span>
119
+ <textarea name="body_md" rows="6" required placeholder="Paste or write it here."></textarea>
120
+ </label>
121
+ <div class="speciality-actions">
122
+ <button type="submit" class="btn">Add to what it knows</button>
123
+ </div>
124
+ </form>`;
125
+ }
126
+
127
+ function render() {
128
+ const host = el('specialities-rows');
129
+ if (!host) return;
130
+ if (!state.rows.length) {
131
+ host.innerHTML = `<li class="prow"><div class="prow__main">
132
+ <p class="prow__hint">No specialities yet. Create one below — it takes a name and a craft, and you can train it afterwards.</p>
133
+ </div></li>`;
134
+ return;
135
+ }
136
+ host.innerHTML = state.rows.map(cardHtml).join('');
137
+ }
138
+
139
+ async function load() {
140
+ try {
141
+ const [cat, mine] = await Promise.all([call('GET', '/specialities'), call('GET', '/me/specialities')]);
142
+ if (!cat) return;
143
+ const activeIds = new Set(((mine && mine.adoptions) || []).filter((a) => a.active).map((a) => String(a.id)));
144
+ state.rows = ((cat && cat.specialities) || []).map((s) => ({ ...s, active: activeIds.has(String(s.id)) }));
145
+ render();
146
+ statusLine('');
147
+ } catch (err) {
148
+ statusLine(`Could not load specialities — ${err.message}`);
149
+ }
150
+ }
151
+
152
+ async function act(action, id) {
153
+ if (state.busy) return;
154
+ state.busy = true;
155
+ try {
156
+ if (action === 'adopt') { await call('POST', `/specialities/${id}/adopt`); toast('Using this speciality.'); }
157
+ if (action === 'abandon') { await call('POST', `/specialities/${id}/abandon`); toast('Stopped using it.'); }
158
+ await load();
159
+ } catch (err) {
160
+ statusLine(err.message);
161
+ } finally {
162
+ state.busy = false;
163
+ }
164
+ }
165
+
166
+ function wire() {
167
+ const host = el('specialities-rows');
168
+ if (host) {
169
+ host.addEventListener('click', (e) => {
170
+ const btn = e.target.closest('button[data-act]');
171
+ if (!btn) return;
172
+ const { act: action, id } = btn.dataset;
173
+ if (action === 'train') { state.openId = String(state.openId) === String(id) ? null : id; render(); return; }
174
+ act(action, id);
175
+ });
176
+
177
+ host.addEventListener('submit', async (e) => {
178
+ const form = e.target.closest('form.speciality-train');
179
+ if (!form) return;
180
+ e.preventDefault();
181
+ const title = form.elements.title.value.trim();
182
+ const body = form.elements.body_md.value.trim();
183
+ // Validate before calling: an empty field should say so inline rather
184
+ // than round-trip to a 400.
185
+ if (!title || !body) { statusLine('A document needs both a title and some content.'); return; }
186
+ try {
187
+ const out = await call('POST', `/specialities/${form.dataset.id}/documents`, { title, body_md: body });
188
+ toast(out && out.skipped ? 'It already knew that one.' : 'Added.');
189
+ state.openId = null;
190
+ await load();
191
+ } catch (err) { statusLine(err.message); }
192
+ });
193
+ }
194
+
195
+ const create = el('speciality-create');
196
+ if (create) {
197
+ create.addEventListener('submit', async (e) => {
198
+ e.preventDefault();
199
+ const name = create.elements.name.value.trim();
200
+ const discipline = create.elements.discipline.value;
201
+ if (!name) { statusLine('Give it a name.'); return; }
202
+ if (!discipline) { statusLine('Pick which craft it sits under.'); return; }
203
+ try {
204
+ await call('POST', '/specialities', {
205
+ name,
206
+ discipline,
207
+ summary: create.elements.summary.value.trim(),
208
+ contract_md: create.elements.contract_md.value.trim(),
209
+ visibility: create.elements.visibility.value,
210
+ sellable: create.elements.sellable.checked,
211
+ });
212
+ create.reset();
213
+ toast('Speciality created.');
214
+ await load();
215
+ } catch (err) { statusLine(err.message); }
216
+ });
217
+
218
+ // The server refuses private+sellable (there is nobody it could be sold
219
+ // to). Mirror that here so the checkbox cannot be left in a state the save
220
+ // will reject.
221
+ const vis = create.elements.visibility;
222
+ const sell = create.elements.sellable;
223
+ const sync = () => {
224
+ const isPrivate = vis.value === 'private';
225
+ sell.disabled = isPrivate;
226
+ if (isPrivate) sell.checked = false;
227
+ };
228
+ vis.addEventListener('change', sync);
229
+ sync();
230
+ }
231
+ }
232
+
233
+ function boot() {
234
+ if (!el('specialities-rows')) return; // panel not on this page
235
+ wire();
236
+ load();
237
+ }
238
+
239
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', boot);
240
+ else boot();
241
+ })();
@@ -161,3 +161,59 @@ select.voices__select { min-width: 140px; }
161
161
  /* the box panel is the shell's (.setup__box, box-panel.js); inside a settings
162
162
  panel it sits on the glass rather than a second card */
163
163
  .settings-groups .setup__box { margin: var(--sp-sm) 0 0; background: var(--glass); border-color: var(--glass-edge); border-radius: var(--radius-sm); }
164
+
165
+ /* Specialities panel (task 1003855, ADR 0296). A speciality is a named bundle of
166
+ knowledge and terms under a craft; this styles the list, the train form and the
167
+ create form. Tokens only — no literal colours, so light/dark follow the page. */
168
+ .speciality-craft {
169
+ font-size: 0.78rem;
170
+ text-transform: lowercase;
171
+ opacity: 0.72;
172
+ margin-left: 0.4rem;
173
+ }
174
+ .speciality-badge {
175
+ font-size: 0.7rem;
176
+ padding: 0.05rem 0.4rem;
177
+ border-radius: 0.5rem;
178
+ margin-left: 0.4rem;
179
+ border: 1px solid currentColor;
180
+ }
181
+ .speciality-counts { opacity: 0.82; }
182
+ .speciality-card { flex-wrap: wrap; }
183
+ .speciality-train,
184
+ .speciality-create {
185
+ flex-basis: 100%;
186
+ margin-top: 0.75rem;
187
+ padding-top: 0.75rem;
188
+ border-top: 1px solid var(--rule, rgba(128, 128, 128, 0.25));
189
+ display: flex;
190
+ flex-direction: column;
191
+ gap: 0.6rem;
192
+ }
193
+ .speciality-create__h { margin: 0; font-size: 0.95rem; }
194
+ .speciality-field { display: flex; flex-direction: column; gap: 0.25rem; }
195
+ .speciality-field > span { font-size: 0.85rem; }
196
+ .speciality-field small { display: block; opacity: 0.7; font-weight: normal; }
197
+ .speciality-field input[type='text'],
198
+ .speciality-field select,
199
+ .speciality-field textarea {
200
+ width: 100%;
201
+ box-sizing: border-box;
202
+ }
203
+ /* The checkbox row reads as one sentence, so it runs inline rather than stacked. */
204
+ .speciality-field--check { flex-direction: row; align-items: flex-start; gap: 0.5rem; }
205
+ .speciality-field--check input { margin-top: 0.2rem; }
206
+ .speciality-actions { display: flex; gap: 0.5rem; }
207
+ /* The adopted contract, shown in full before "Use this" — another builder's prose
208
+ reaching your sessions is the one thing here that must never be folded away. */
209
+ .speciality-contract { flex-basis: 100%; margin-top: 0.5rem; font-size: 0.85rem; }
210
+ .speciality-contract > summary { cursor: pointer; opacity: 0.85; }
211
+ .speciality-contract__body {
212
+ white-space: pre-wrap;
213
+ margin: 0.4rem 0 0;
214
+ padding: 0.5rem 0.65rem;
215
+ border-left: 2px solid var(--rule, rgba(128, 128, 128, 0.35));
216
+ border-radius: 0;
217
+ font-family: inherit;
218
+ opacity: 0.92;
219
+ }
@@ -269,6 +269,70 @@
269
269
  <p class="settings-status" id="interaction-status" role="status" aria-live="polite"></p>
270
270
  </section>
271
271
 
272
+ <!-- SPECIALITIES — a named bundle of knowledge + terms UNDER a craft
273
+ (task 1003855, ADR 0296). GET/POST /api/bongos/specialities,
274
+ POST /specialities/:id/{adopt,abandon,documents}. No rank gate:
275
+ a speciality is how an expert's knowledge enters the platform,
276
+ and gating that by rank would mean only trusted builders could
277
+ be experts. -->
278
+ <section class="scroll" id="specialities-scroll" aria-labelledby="specialities-h" data-group="preferences" hidden>
279
+ <h2 id="specialities-h" class="scroll__h">Your specialities</h2>
280
+ <p class="scroll__lede">
281
+ A craft says how you work. A speciality says what you work on — and how you,
282
+ as someone who knows the subject, want to be engaged inside it. Whichever one
283
+ you are using reaches every session where you hold a claim in that craft.
284
+ Train it by adding things you have written; it also picks things up on its own
285
+ as you work.
286
+ </p>
287
+ <ul class="prows" id="specialities-rows"></ul>
288
+
289
+ <form id="speciality-create" class="speciality-create">
290
+ <h3 class="speciality-create__h">Create one</h3>
291
+ <label class="speciality-field">
292
+ <span>Name</span>
293
+ <input type="text" name="name" maxlength="80" required placeholder="Economics" />
294
+ </label>
295
+ <label class="speciality-field">
296
+ <span>Craft it sits under</span>
297
+ <select name="discipline" required>
298
+ <option value="">Pick one…</option>
299
+ <option value="engineer">Engineer</option>
300
+ <option value="ideator">Ideator</option>
301
+ <option value="artist">Artist</option>
302
+ <option value="ui">UI design</option>
303
+ </select>
304
+ </label>
305
+ <label class="speciality-field">
306
+ <span>Summary <small>(optional)</small></span>
307
+ <input type="text" name="summary" maxlength="500" placeholder="Markets, incentives, and how to price them." />
308
+ </label>
309
+ <label class="speciality-field">
310
+ <span>How Claude should engage you in it <small>(optional)</small></span>
311
+ <textarea name="contract_md" rows="4" maxlength="4000" placeholder="Argue from mechanism, not vibes. Always name the assumption a number rests on."></textarea>
312
+ </label>
313
+ <label class="speciality-field">
314
+ <span>Who can use it</span>
315
+ <select name="visibility">
316
+ <option value="private">Only me</option>
317
+ <option value="project">Anyone in the owning project</option>
318
+ <option value="public">Anyone on this instance</option>
319
+ </select>
320
+ </label>
321
+ <label class="speciality-field speciality-field--check">
322
+ <input type="checkbox" name="sellable" />
323
+ <span>
324
+ May be offered for sale later
325
+ <small>Records your decision only — there is no marketplace yet, and nothing is listed anywhere.</small>
326
+ </span>
327
+ </label>
328
+ <div class="speciality-actions">
329
+ <button type="submit" class="btn">Create speciality</button>
330
+ </div>
331
+ </form>
332
+
333
+ <p class="settings-status" id="specialities-status" role="status" aria-live="polite"></p>
334
+ </section>
335
+
272
336
  <!-- WANDERING — how readily the agent opens off-task threads (task 1268).
273
337
  GET/PATCH /api/bongos/me/wandering. Rank sets the default + ceiling; the
274
338
  server clamps, so options above the rank cap render disabled. -->
@@ -398,6 +462,7 @@
398
462
  the same precedent and for the same reason — settings.js is against the
399
463
  1500-line ratchet, and this panel shares nothing with the rest of the page. -->
400
464
  <script src="/builders/settings-interaction.js?v=2026-09-15-interaction"></script>
465
+ <script src="/builders/settings-specialities.js?v=2026-09-17-specialities"></script>
401
466
  <!-- task 1002518: the card-richness ladder. Own file on the same precedent —
402
467
  the panel grew from one switch to a rung picker with a derived default, and
403
468
  settings.js is against the 1500-line ratchet. -->
@@ -21,7 +21,11 @@ async function getActiveClaimsForBuilder(builderId) {
21
21
  `SELECT c.id AS claim_id, c.task_id, c.worktree_name, c.claimed_at,
22
22
  c.head_sha, c.reserved_migration_number,
23
23
  c.release_requested_by, c.release_requested_at, c.release_requested_note,
24
- t.title, t.touches, t.priority, t.credits_reward, t.version_id, t.module_key
24
+ t.title, t.touches, t.priority, t.credits_reward, t.version_id, t.module_key,
25
+ -- task 1003855: the craft of the claimed work. A speciality contract is
26
+ -- craft-scoped, so the Conductor needs to know which craft is in play
27
+ -- before it can pick the right one to inject.
28
+ t.discipline
25
29
  FROM claims c JOIN tasks t ON t.id = c.task_id
26
30
  WHERE c.builder_id = $1 AND c.released_at IS NULL
27
31
  ORDER BY c.claimed_at`,
@@ -0,0 +1,45 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/db-executor.js — which thing runs the query (task 1003840).
4
+ //
5
+ // WHY THIS IS ONE FUNCTION AND NOT SEVEN COPIES. Every read in this module family
6
+ // already carried the same hand-written line:
7
+ //
8
+ // const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
9
+ //
10
+ // Threading a transaction client through the criterion surface meant editing that
11
+ // line in seven files. A rule about WHICH connection a write lands on is exactly
12
+ // the rule you do not want seven copies of: get one wrong and a write silently
13
+ // leaves the transaction it was supposed to be inside, which is invisible in
14
+ // review and invisible at runtime until a race loses.
15
+ //
16
+ // THE ORDER MATTERS, and it is not the same for the two seams:
17
+ //
18
+ // 1. `deps.client` — a real transaction client, honoured ALWAYS. It is
19
+ // production behaviour, not a test affordance: when a caller has opened a
20
+ // transaction, every statement in that unit of work must run on that client
21
+ // or it is not in the transaction at all.
22
+ // 2. `deps.pool` — the #538 test seam, honoured ONLY under NODE_ENV==='test',
23
+ // exactly as before. Unchanged so a stray deps.pool in production code still
24
+ // cannot redirect a write.
25
+ // 3. the module's own pool — the default, unchanged.
26
+ //
27
+ // A caller that passes both gets the client, because the transaction is the
28
+ // stronger claim.
29
+
30
+ function executorFor(deps, defaultPool) {
31
+ if (deps && deps.client) return deps.client;
32
+ if (deps && deps.pool && process.env.NODE_ENV === 'test') return deps.pool;
33
+ return defaultPool;
34
+ }
35
+
36
+ // True when the caller is inside a transaction, which is the only place a row
37
+ // lock means anything: `SELECT ... FOR UPDATE` on the pool takes a lock and
38
+ // releases it on the next statement, so asking for one outside a transaction is
39
+ // a false sense of safety rather than a cheap extra. Callers use this to refuse
40
+ // rather than to silently downgrade.
41
+ function inTransaction(deps) {
42
+ return Boolean(deps && deps.client);
43
+ }
44
+
45
+ module.exports = { executorFor, inTransaction };
@@ -17,6 +17,7 @@ const { deriveRequiredRank, highestRank } = require('./db-rank-authz.js');
17
17
  const { nonTerminalSql, isTerminalStatus } = require('./task-status');
18
18
  const { maintenanceGoalTitle } = require('./goal-advisory.js');
19
19
  const { SMOKE_NOT_LIKE } = require('./db-shared.js');
20
+ const { executorFor, inTransaction } = require('./db-executor.js');
20
21
  // BV1.R16 (task 1003603) — auto-close, shared with the done-when cascade so the
21
22
  // two achievement writers cannot drift apart (ADR 0263 §4).
22
23
  const { autoCloseVersionsForGoals } = require('./version-autoclose.js');
@@ -325,10 +326,22 @@ async function getGoalAdmission(goalId, deps = {}) {
325
326
  }
326
327
 
327
328
 
329
+ // deps.client runs the read inside a caller's transaction; deps.forUpdate adds the
330
+ // row lock (task 1003840). The lock is what actually closes the criterion routes'
331
+ // check-then-write window: without it, re-reading inside the transaction still sees
332
+ // a row another transaction is free to change before the write lands.
333
+ //
334
+ // forUpdate REFUSES outside a transaction rather than quietly dropping to a plain
335
+ // read. FOR UPDATE on a pooled connection takes a lock and releases it at the end
336
+ // of that implicit statement, so the caller would hold nothing while believing it
337
+ // held something — the precise false sense of safety this task exists to remove.
328
338
  async function getGoal(id, deps = {}) {
329
- const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
339
+ const activePool = executorFor(deps, pool);
340
+ if (deps.forUpdate && !inTransaction(deps)) {
341
+ throw new Error('getGoal({ forUpdate }) requires deps.client — a row lock outside a transaction holds nothing');
342
+ }
330
343
  const { rows } = await activePool.query(
331
- `SELECT ${GOAL_COLS} FROM goals WHERE id = $1`,
344
+ `SELECT ${GOAL_COLS} FROM goals WHERE id = $1${deps.forUpdate ? ' FOR UPDATE' : ''}`,
332
345
  [id]
333
346
  );
334
347
  return rows[0] ?? null;
@@ -444,8 +457,15 @@ async function reorderGoals({ versionId, orderedIds }, deps = {}) {
444
457
  // the auto-achieve + archive/reopen on top). Bumps updated_at. Returns null if
445
458
  // the goal id is unknown.
446
459
 
460
+ // THE WRITE achieveGoalIfComplete DELEGATES TO, so it must honour deps.client or
461
+ // the auto-achieve leaves the caller's transaction (found in review of task
462
+ // 1003840's first cut, where it was missed). Against real Postgres that is not
463
+ // merely untransacted, it SELF-DEADLOCKS: the criterion route holds this goal row
464
+ // FOR UPDATE on its transaction client, and this UPDATE on a pool connection
465
+ // blocks on that lock — while the transaction is awaiting this UPDATE before it
466
+ // can COMMIT and release it. Neither side can move.
447
467
  async function setGoalStatus({ goalId, status }, deps = {}) {
448
- const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
468
+ const activePool = executorFor(deps, pool);
449
469
  const { rows } = await activePool.query(
450
470
  `UPDATE goals
451
471
  SET status = $2, updated_at = now()
@@ -467,8 +487,10 @@ async function setGoalStatus({ goalId, status }, deps = {}) {
467
487
  // siblings, so the flip logic is unit-testable with a fake client.
468
488
 
469
489
  async function achieveGoalIfComplete({ goalId }, deps = {}) {
470
- const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
471
- const goal = await getGoal(goalId, deps);
490
+ const activePool = executorFor(deps, pool);
491
+ // The nested getGoal must NOT inherit forUpdate — this is a re-derive, not the
492
+ // authority read, and locking here would widen the transaction for no gain.
493
+ const goal = await getGoal(goalId, { ...deps, forUpdate: false });
472
494
  // Only an OPEN goal can auto-achieve. An already achieved/archived goal is left
473
495
  // exactly as-is — re-confirming a criterion must not churn its status.
474
496
  if (!goal || goal.status !== 'open') return { achieved: false, goal: goal ?? null };
@@ -512,7 +534,7 @@ async function achieveGoalIfComplete({ goalId }, deps = {}) {
512
534
  //
513
535
  // Runs on the caller's exec so it sees the flip above, which in the ship path is
514
536
  // still uncommitted. Never throws: autoCloseVersionsForGoals catches per version.
515
- const exec = deps.client || ((deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool);
537
+ const exec = executorFor(deps, pool);
516
538
  const versionsClosed = await autoCloseVersionsForGoals(exec, [updated]);
517
539
  return { achieved: true, goal: updated, versionsClosed };
518
540
  }
@@ -829,7 +851,7 @@ async function isGoalMember({ goalId, builderId }, deps = {}) {
829
851
  // isGoalMember boolean.
830
852
 
831
853
  async function getGoalMember({ goalId, builderId }, deps = {}) {
832
- const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
854
+ const activePool = executorFor(deps, pool);
833
855
  const { rows } = await activePool.query(
834
856
  `SELECT goal_id, builder_id, membership_kind, joined_at
835
857
  FROM goal_members
@@ -17,6 +17,7 @@ const { generalGoalTitle } = require('./goal-advisory.js');
17
17
  const { matchProtected } = api.permissionPathCheck;
18
18
  const { decorateGoalMembership, capNewcomerTasks } = require('./claim-feed.js');
19
19
  const { deriveRequiredRank, highestRank } = require('./db-rank-authz.js');
20
+ const { executorFor } = require('./db-executor.js');
20
21
 
21
22
  // -------------------------------------------------------------------------
22
23
  // Tasks
@@ -334,16 +335,21 @@ async function updateTaskColumn(id, column, value) {
334
335
  // planning session uses. sort_order defaults to max(existing)+1 (mirrors the
335
336
  // criterion-proposal ratify path). Throws 23505 on a duplicate (version_id,
336
337
  // criterion_id) — the route maps that to 409.
337
- async function createCriterion({ versionId, criterionId, criterionMd, goalId = null, sortOrder = null }) {
338
+ // deps.client threads the caller's transaction (task 1003840). BOTH statements
339
+ // below must run on it: the MAX(sort_order) read and the INSERT that depends on
340
+ // it are one decision, and splitting them across connections reopens a narrower
341
+ // version of the same race — two creates reading the same next order.
342
+ async function createCriterion({ versionId, criterionId, criterionMd, goalId = null, sortOrder = null }, deps = {}) {
343
+ const executor = executorFor(deps, pool);
338
344
  let order = sortOrder;
339
345
  if (order == null) {
340
- const { rows: maxRows } = await pool.query(
346
+ const { rows: maxRows } = await executor.query(
341
347
  `SELECT COALESCE(MAX(sort_order), 0) + 1 AS next FROM done_when_criteria WHERE version_id = $1`,
342
348
  [versionId]
343
349
  );
344
350
  order = maxRows[0].next;
345
351
  }
346
- const { rows } = await pool.query(
352
+ const { rows } = await executor.query(
347
353
  `INSERT INTO done_when_criteria (version_id, criterion_id, criterion_md, goal_id, sort_order)
348
354
  VALUES ($1, $2, $3, $4, $5)
349
355
  RETURNING id, version_id, criterion_id, criterion_md, goal_id, sort_order, satisfied`,
@@ -364,8 +370,8 @@ async function createCriterion({ versionId, criterionId, criterionMd, goalId = n
364
370
  // question this raises — that the goal exists and sits on the criterion's own
365
371
  // version, who may write it, and whether the move completes the goal; this is the
366
372
  // UPDATE and nothing else, exactly as updateTaskGoal below is for tasks.
367
- async function updateCriterionGoal(id, goalId) {
368
- const { rows } = await pool.query(
373
+ async function updateCriterionGoal(id, goalId, deps = {}) {
374
+ const { rows } = await executorFor(deps, pool).query(
369
375
  `UPDATE done_when_criteria SET goal_id = $1 WHERE id = $2
370
376
  RETURNING id, version_id, criterion_id, criterion_md, goal_id, sort_order, satisfied`,
371
377
  [goalId, id]
@@ -15,6 +15,7 @@ const { pool, withTx } = api;
15
15
  const doneWhen = require('./done-when.js');
16
16
  const { nonTerminalSql } = require('./task-status');
17
17
  const { SMOKE_NOT_LIKE } = require('./db-shared.js');
18
+ const { executorFor, inTransaction } = require('./db-executor.js');
18
19
  // db-goals.js does NOT require this file, so this direction adds no require cycle
19
20
  // (fitness pins the count). createVersion needs it to seed a version's FIRST goal
20
21
  // in the same transaction — task 1003701.
@@ -30,10 +31,23 @@ async function listVersions() {
30
31
  }
31
32
 
32
33
 
33
- async function getVersion(id) {
34
- const { rows } = await pool.query(
34
+ // deps.client/deps.pool, so the criterion routes can read the version inside the
35
+ // same transaction that authorizes and writes (task 1003840). A version's status
36
+ // is one of the facts the decision rests on — reading it on a different connection
37
+ // is reading it at a different instant.
38
+ async function getVersion(id, deps = {}) {
39
+ // deps.forUpdate locks the version row too (task 1003840, round 2). The goal
40
+ // lock alone left one of the three named facts unguarded: closeVersion takes
41
+ // FOR UPDATE on this row, but under READ COMMITTED a PLAIN read is never
42
+ // blocked by another session's lock — so a freeze could still commit between
43
+ // this read and this transaction's COMMIT, and the criterion would land on a
44
+ // version that had closed. Locking makes the two serialize.
45
+ if (deps.forUpdate && !inTransaction(deps)) {
46
+ throw new Error('getVersion({ forUpdate }) requires deps.client — a row lock outside a transaction holds nothing');
47
+ }
48
+ const { rows } = await executorFor(deps, pool).query(
35
49
  `SELECT id, name, track, status, scope_doc_path, started_at, shipped_at, done_when
36
- FROM versions WHERE id = $1`,
50
+ FROM versions WHERE id = $1${deps.forUpdate ? ' FOR UPDATE' : ''}`,
37
51
  [id]
38
52
  );
39
53
  return rows[0] ?? null;
@@ -26,6 +26,7 @@
26
26
  const { pool } = require('../../src/module-api');
27
27
  const { nonTerminalSql } = require('./task-status');
28
28
  const { SMOKE_NOT_LIKE } = require('./db-shared.js');
29
+ const { executorFor } = require('./db-executor.js');
29
30
  // BV1.R16 (task 1003603) — auto-close. Dependency-free module, so requiring it
30
31
  // here creates no cycle (db-versions.js requires THIS file, which is why the
31
32
  // rule could not live beside closeVersion).
@@ -101,10 +102,12 @@ async function criteriaGroupedByGoal(versionId) {
101
102
  // Fetch a single criterion by primary key. Used by the routes to validate
102
103
  // existence before writing (so a missing id returns 404 instead of a silent
103
104
  // no-op UPDATE).
104
- async function getCriterion(criterionId) {
105
+ // deps.client so the PATCH route can read the criterion inside its transaction
106
+ // (task 1003840) — its goal_id is the fact the authority decision rests on.
107
+ async function getCriterion(criterionId, deps = {}) {
105
108
  const id = Number(criterionId);
106
109
  if (!Number.isFinite(id)) return null;
107
- const { rows } = await pool.query(
110
+ const { rows } = await executorFor(deps, pool).query(
108
111
  `SELECT id, version_id, criterion_id, criterion_md, satisfied,
109
112
  satisfied_by_task_id, checked_at, sort_order, goal_id
110
113
  FROM done_when_criteria
@@ -23,6 +23,7 @@
23
23
  // correction is a cohesive slice — the goal-authz.js / claim-feed.js precedent.
24
24
 
25
25
  const api = require('../../src/module-api');
26
+ const { executorFor } = require('./db-executor.js');
26
27
  const { pool } = api;
27
28
 
28
29
  // ---------------------------------------------------------------------------
@@ -119,7 +120,10 @@ async function updateGoalProse({ goalId, patch = {} }, deps = {}) {
119
120
  // (criterionProseRefusal) so the route can answer 404 vs 409 distinctly; this
120
121
  // only writes. Returns { criterion, deltas } or null when it vanished mid-flight.
121
122
  async function updateCriterionProse({ criterionId, criterionMd }, deps = {}) {
122
- const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
123
+ // Read-then-write in its own right: the before-value it captures for the audit
124
+ // delta must come from the same transaction as the UPDATE, or the recorded
125
+ // "before" can be a value that was already gone (task 1003840).
126
+ const activePool = executorFor(deps, pool);
123
127
  const { rows: before } = await activePool.query(
124
128
  `SELECT id, criterion_md, satisfied FROM done_when_criteria WHERE id = $1`,
125
129
  [criterionId]