@bongos/core 1.19.316 → 1.19.317

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.
@@ -0,0 +1,763 @@
1
+ // modules/lifecycle/db-goals.js — goals, goal membership, membership requests + goal scope/visibility/successor.
2
+ //
3
+ // Carved out of modules/lifecycle/db.js by task 1003204 (goal 1000079, the R03
4
+ // de-monolith plan) — a STRUCTURAL move, not a rewrite: the code below is the
5
+ // original db.js lines 657-1373 unchanged. db.js remains the facade that
6
+ // re-exports every name, so every existing db call site is unchanged.
7
+ //
8
+ // THE BOUNDARY (ADR 0093 §1): this file reaches core ONLY through the published
9
+ // doorway ../../src/module-api.js — never a core internal, never a sibling module.
10
+
11
+ 'use strict';
12
+
13
+ const api = require('../../src/module-api');
14
+ const { pool } = api;
15
+ const doneWhen = require('./done-when.js');
16
+ const { deriveRequiredRank, highestRank } = require('./db-rank-authz.js');
17
+
18
+ // -------------------------------------------------------------------------
19
+ // Goals (ADR 0086 §2 / BV1.R56 — task 1510)
20
+ //
21
+ // The shared, module-scoped tier between versions and criteria. Membership
22
+ // (goal_members) is what later grants bounded task authoring within the goal's
23
+ // `scope_modules` wall (R57 adds routes/goals.js; R58 the /status rollup). These
24
+ // are the pure SQL helpers + their unit tests only — NO route references a goal
25
+ // yet, so OTB behaves identically.
26
+ //
27
+ // Each accepts the #538 deps.pool seam (honored ONLY under NODE_ENV==='test')
28
+ // so the suite can drive them with a SQL-aware fake — same posture as
29
+ // abandonTask / createOverrideRequest.
30
+ // -------------------------------------------------------------------------
31
+
32
+ // Shared column list so every goal read/return shapes a row identically.
33
+
34
+ const GOAL_COLS =
35
+ 'id, version_id, title, description, scope_modules, status, visibility, created_by, succeeded_by_goal_id, sort_order, created_at, updated_at';
36
+
37
+ // Shared column list for a goal_membership_requests row (task 1951 / ADR 0112) —
38
+ // the invite / join-request queue behind private goals. Every helper below shapes
39
+ // a row identically, the GOAL_COLS precedent.
40
+ const MEMBERSHIP_REQUEST_COLS =
41
+ 'id, goal_id, builder_id, kind, status, note, created_by, resolved_by, created_at, resolved_at, updated_at';
42
+
43
+
44
+ // The creator is the goal's OWNER (created_by — the fixed authority, task 1735)
45
+ // and lands in the member list as a 'lead' (rendered "manager" in the hall): the
46
+ // member-insert CTE rides in the same statement so goal + owner-membership land
47
+ // atomically. createdBy NULL (system-attributed, e.g. the migration-160 default
48
+ // goals) inserts no member — those goals have no owner; Archons act as one.
49
+
50
+ async function createGoal(
51
+ { versionId, title, description = null, scopeModules = [], status = 'open', createdBy = null },
52
+ deps = {}
53
+ ) {
54
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
55
+ const { rows } = await activePool.query(
56
+ `WITH next_order AS (
57
+ SELECT COALESCE(MAX(sort_order) + 1, 0) AS n FROM goals WHERE version_id = $1
58
+ ), g AS (
59
+ INSERT INTO goals (version_id, title, description, scope_modules, status, created_by, sort_order)
60
+ SELECT $1, $2, $3, $4, $5, $6, next_order.n FROM next_order
61
+ RETURNING ${GOAL_COLS}
62
+ ), first_lead AS (
63
+ INSERT INTO goal_members (goal_id, builder_id, membership_kind)
64
+ SELECT g.id, $6::bigint, 'lead' FROM g WHERE $6::bigint IS NOT NULL
65
+ )
66
+ SELECT * FROM g`,
67
+ [versionId, title, description, scopeModules, status, createdBy]
68
+ );
69
+ return rows[0];
70
+ }
71
+
72
+
73
+ async function getGoal(id, deps = {}) {
74
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
75
+ const { rows } = await activePool.query(
76
+ `SELECT ${GOAL_COLS} FROM goals WHERE id = $1`,
77
+ [id]
78
+ );
79
+ return rows[0] ?? null;
80
+ }
81
+
82
+ // getGoalDependencies (task 1761) — the prerequisite goals THIS goal is blocked by:
83
+ // the goal->goal edges in the polymorphic `dependencies` table (migration 163),
84
+ // read as "from is blocked until to is done". Each carries its satisfied state — a
85
+ // goal dependency is satisfied when the target goal is 'achieved' (migration 163's
86
+ // done-ness for a goal). The hall (goals-page + roadmap) shows these and greys/locks
87
+ // an open goal with any unsatisfied dep. Scope is goal->goal only (a goal depending
88
+ // on a task/criterion is not a modeled pattern). deps.pool test seam.
89
+ async function getGoalDependencies(goalId, deps = {}) {
90
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
91
+ const { rows } = await activePool.query(
92
+ `SELECT g.id, g.title, g.status, g.version_id,
93
+ (g.status = 'achieved') AS satisfied
94
+ FROM dependencies d
95
+ JOIN goals g ON g.id = d.to_id
96
+ WHERE d.from_kind = 'goal' AND d.from_id = $1 AND d.to_kind = 'goal'
97
+ ORDER BY g.id`,
98
+ [goalId]
99
+ );
100
+ return rows;
101
+ }
102
+
103
+ // listGoals — optional version_id / status filters (NULL = no filter), ordered
104
+ // by the owner's WITHIN-version priority (sort_order, task 1760), falling back
105
+ // to created_at/id so a fresh instance and prod agree on row order pre-reorder.
106
+
107
+ // `limit` is optional and defaults to unbounded — the goal board reads every goal.
108
+ // A caller that only needs the first N (the orphan advisory wants 5 candidates)
109
+ // passes it so Postgres does the truncating instead of shipping the whole version's
110
+ // goals to be discarded in JS (task 1002539).
111
+ async function listGoals({ versionId = null, status = null, limit = null } = {}, deps = {}) {
112
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
113
+ const { rows } = await activePool.query(
114
+ `SELECT ${GOAL_COLS}
115
+ FROM goals
116
+ WHERE ($1::text IS NULL OR version_id = $1)
117
+ AND ($2::text IS NULL OR status = $2)
118
+ ORDER BY version_id, sort_order, created_at, id
119
+ LIMIT $3::int`,
120
+ [versionId, status, limit]
121
+ );
122
+ return rows;
123
+ }
124
+
125
+ // reorderGoals — Archon drag-to-reorder (task 1760). orderedIds is the new
126
+ // relative order for a SCOPED SUBSET of versionId's goals — the caller (hall
127
+ // UI) only ever renders a status-filtered slice (roadmap excludes archived;
128
+ // the Goals page defaults to open-only), so demanding the version's COMPLETE
129
+ // goal set here would fail-closed on every version that has ever archived a
130
+ // goal (the goal the client never rendered, and so could never round-trip).
131
+ // Every id must still belong to THIS version, appear once (fail-closed on a
132
+ // cross-version id or a duplicate — SET_MISMATCH), and be non-empty; any
133
+ // goal in the version NOT named in orderedIds (e.g. an archived one hidden
134
+ // from the view that dragged) keeps its RELATIVE order, appended after the
135
+ // reordered slice — reordering a subset never disturbs a goal the client
136
+ // didn't even see. Returns the reordered rows (the touched subset only).
137
+
138
+ async function reorderGoals({ versionId, orderedIds }, deps = {}) {
139
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
140
+ const { rows: existing } = await activePool.query(
141
+ `SELECT id FROM goals WHERE version_id = $1 ORDER BY sort_order, created_at, id`,
142
+ [versionId]
143
+ );
144
+ const existingIds = existing.map((r) => String(r.id));
145
+ const existingSet = new Set(existingIds);
146
+ const wantedIds = orderedIds.map(String);
147
+ if (wantedIds.length === 0 || new Set(wantedIds).size !== wantedIds.length || !wantedIds.every((id) => existingSet.has(id))) {
148
+ const err = new Error('ordered_ids must be a duplicate-free list of this version\'s goal ids');
149
+ err.code = 'SET_MISMATCH';
150
+ throw err;
151
+ }
152
+ const wantedSet = new Set(wantedIds);
153
+ const remainder = existingIds.filter((id) => !wantedSet.has(id));
154
+ const finalOrder = [...orderedIds, ...remainder.map(Number)];
155
+ const { rows } = await activePool.query(
156
+ `UPDATE goals g
157
+ SET sort_order = data.ord, updated_at = now()
158
+ FROM (SELECT unnest($1::bigint[]) AS id, unnest($2::int[]) AS ord) AS data
159
+ WHERE g.id = data.id
160
+ RETURNING ${GOAL_COLS.split(', ').map((c) => `g.${c}`).join(', ')}`,
161
+ [finalOrder, finalOrder.map((_, i) => i)]
162
+ );
163
+ const byId = new Map(rows.map((r) => [String(r.id), r]));
164
+ return orderedIds.map((id) => byId.get(String(id))).filter(Boolean);
165
+ }
166
+
167
+ // setGoalStatus — the open -> achieved -> archived lifecycle move (R63/R64 wire
168
+ // the auto-achieve + archive/reopen on top). Bumps updated_at. Returns null if
169
+ // the goal id is unknown.
170
+
171
+ async function setGoalStatus({ goalId, status }, deps = {}) {
172
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
173
+ const { rows } = await activePool.query(
174
+ `UPDATE goals
175
+ SET status = $2, updated_at = now()
176
+ WHERE id = $1
177
+ RETURNING ${GOAL_COLS}`,
178
+ [goalId, status]
179
+ );
180
+ return rows[0] ?? null;
181
+ }
182
+
183
+ // achieveGoalIfComplete — the auto-achieve half of the criterion-confirm flow
184
+ // (ADR 0086 §6 / BV1.R63). Called after a criterion is confirmed (satisfied=true):
185
+ // if that criterion's goal is still `open` AND every criterion under the goal is
186
+ // now satisfied (with ≥1), flip the goal open→achieved. Conservative + idempotent —
187
+ // it never touches an achieved/archived goal (reopen/archive is the R64 lifecycle
188
+ // move), and an empty goal (no criteria) is never auto-achieved. Returns
189
+ // { achieved, goal }: `goal` is the (possibly updated) row, or null for an unknown
190
+ // id. Honours the #538 deps.pool test seam (NODE_ENV==='test' only), like its
191
+ // siblings, so the flip logic is unit-testable with a fake client.
192
+
193
+ async function achieveGoalIfComplete({ goalId }, deps = {}) {
194
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
195
+ const goal = await getGoal(goalId, deps);
196
+ // Only an OPEN goal can auto-achieve. An already achieved/archived goal is left
197
+ // exactly as-is — re-confirming a criterion must not churn its status.
198
+ if (!goal || goal.status !== 'open') return { achieved: false, goal: goal ?? null };
199
+ const { rows } = await activePool.query(
200
+ `SELECT count(*)::int AS total,
201
+ count(*) FILTER (WHERE NOT satisfied)::int AS unsatisfied
202
+ FROM done_when_criteria
203
+ WHERE goal_id = $1`,
204
+ [goalId]
205
+ );
206
+ const total = rows[0]?.total ?? 0;
207
+ const unsatisfied = rows[0]?.unsatisfied ?? 0;
208
+ // Need ≥1 criterion and zero unsatisfied — confirming the LAST one is the
209
+ // trigger. An empty goal stays open (nothing could complete it).
210
+ if (total === 0 || unsatisfied > 0) return { achieved: false, goal };
211
+ const updated = await setGoalStatus({ goalId, status: 'achieved' }, deps);
212
+ return { achieved: true, goal: updated };
213
+ }
214
+
215
+ // addGoalMember — join. Upserts on the (goal_id, builder_id) PK so a re-join is
216
+ // not a duplicate-key error, and the conflict arm PRESERVES the stored
217
+ // membership_kind (task 1002689): overwriting it let POST /goals/:id/join (always
218
+ // 'member') and a POST /goals/:id/members re-add silently demote a lead —
219
+ // including the owner's row — bypassing authorizeMembershipKindChange's
220
+ // owner-only-demotion guards. Changes go through PATCH
221
+ // /goals/:id/members/:builderId only (the acceptMembershipRequestAndAddMember
222
+ // precedent). membershipKind defaults to 'member'.
223
+
224
+ // THE COLUMN IS `membership_kind` (task 1003013, ADR 0177 — ADR 0174 reserves the
225
+ // other word for the three crafts). The legacy column still EXISTS for one
226
+ // release, kept in lockstep by core_220's trigger so a rolled-back deploy still
227
+ // reads a schema it understands: write the new name only, step 2 drops the old.
228
+ //
229
+ // ARRIVING AS A MEMBER CLOSES ANY PENDING ASK (task 1002888 / idea 1000688).
230
+ // The membership write used to be a bare upsert that never looked at
231
+ // goal_membership_requests, so a builder's pending row SURVIVED their arrival by
232
+ // any other route — the goal flips to public and they self-join, or an Archon
233
+ // adds them directly. The stale row lingered in the owner/manager inbox, a later
234
+ // approve resolved a request for an existing member, and a later deny stamped
235
+ // 'rejected' on someone who is in fact a member. Harmless to access control,
236
+ // wrong in the audit trail.
237
+ //
238
+ // BOTH kinds are cancelled, not just 'request'. The partial-unique index
239
+ // (core_188: one pending row per goal+builder, either direction) means there is
240
+ // at most ONE pending row to close, and a stale pending INVITE is the same bug
241
+ // mirrored — the invitee could later decline an invitation to a goal they are
242
+ // already in, stamping 'rejected' on a member. 'cancelled' is the right terminal
243
+ // state (core_188: "withdrawn/rescinded"); 'accepted' stays reserved for an
244
+ // accept/approve flow that actually ran, so the inbox history never claims a
245
+ // decision nobody made. resolvedBy is the ACTOR — the joiner themselves on
246
+ // /join, the adding owner/manager/Archon on /members.
247
+ //
248
+ // One CTE, not two queries — the acceptMembershipRequestAndAddMember precedent:
249
+ // the cancel commits with the membership write or not at all, so an error
250
+ // between them can't leave the pair half-applied. The two arms are independent
251
+ // (Postgres runs a data-modifying CTE to completion whether or not the primary
252
+ // query reads it), and both see the same snapshot.
253
+
254
+ async function addGoalMember({ goalId, builderId, membershipKind = 'member', resolvedBy = null }, deps = {}) {
255
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
256
+ const { rows } = await activePool.query(
257
+ `WITH added AS (
258
+ INSERT INTO goal_members (goal_id, builder_id, membership_kind)
259
+ VALUES ($1, $2, $3)
260
+ ON CONFLICT (goal_id, builder_id) DO UPDATE SET membership_kind = goal_members.membership_kind
261
+ RETURNING goal_id, builder_id, membership_kind, joined_at
262
+ ), cancelled AS (
263
+ UPDATE goal_membership_requests
264
+ SET status = 'cancelled', resolved_by = $4, resolved_at = now(), updated_at = now()
265
+ WHERE goal_id = $1 AND builder_id = $2 AND status = 'pending'
266
+ RETURNING id
267
+ )
268
+ SELECT goal_id, builder_id, membership_kind, joined_at FROM added`,
269
+ [goalId, builderId, membershipKind, resolvedBy]
270
+ );
271
+ return rows[0];
272
+ }
273
+
274
+ // removeGoalMember — leave. Returns { removed } so the (later) route can 404 a
275
+ // non-member leave without a second lookup.
276
+
277
+ async function removeGoalMember({ goalId, builderId }, deps = {}) {
278
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
279
+ const { rowCount } = await activePool.query(
280
+ `DELETE FROM goal_members WHERE goal_id = $1 AND builder_id = $2`,
281
+ [goalId, builderId]
282
+ );
283
+ return { removed: rowCount > 0 };
284
+ }
285
+
286
+
287
+ async function listGoalMembers(goalId, deps = {}) {
288
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
289
+ // Builder identity rides along (task 1734: the hall Goals page renders member
290
+ // names + lead badges) — additive columns, so every existing consumer that
291
+ // reads only {goal_id, builder_id, membership_kind} is untouched.
292
+ const { rows } = await activePool.query(
293
+ `SELECT gm.goal_id, gm.builder_id, gm.membership_kind, gm.joined_at,
294
+ b.github_login, b.display_name, b.avatar_url, b.rank
295
+ FROM goal_members gm
296
+ LEFT JOIN builders b ON b.id = gm.builder_id
297
+ WHERE gm.goal_id = $1
298
+ ORDER BY gm.joined_at, gm.builder_id`,
299
+ [goalId]
300
+ );
301
+ return rows;
302
+ }
303
+
304
+ // listCriteriaForGoal — the done_when_criteria re-parented under a goal (R56
305
+ // backfilled every criterion under its version's default goal). Powers the
306
+ // "linked criteria" half of GET /goals/:id; projection matches done-when.js's
307
+ // listCriteria so the hall renders goal + version criteria identically.
308
+
309
+ async function listCriteriaForGoal(goalId, deps = {}) {
310
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
311
+ // task 1002164: carry the linked-task counts so the caller can derive the
312
+ // "met — pending review" state the hall renders. The counts are aggregated
313
+ // ONCE, over only this goal's criteria (the `counts` CTE narrows on the `crit`
314
+ // CTE before grouping), rather than per-row correlated subqueries — one pass
315
+ // over the idx_task_criteria_criterion range instead of 2N lookups. The
316
+ // criterion columns are still listed explicitly so the projection this read
317
+ // already feeds (the version-criteria view, the task-create suggester) is
318
+ // unchanged apart from the two additive count columns.
319
+ const { rows } = await activePool.query(
320
+ `WITH crit AS (
321
+ SELECT id, version_id, criterion_id, criterion_md, satisfied, sort_order, goal_id
322
+ FROM done_when_criteria
323
+ WHERE goal_id = $1
324
+ ),
325
+ counts AS (
326
+ SELECT tc.criterion_id,
327
+ count(*)::int AS linked_tasks,
328
+ -- task 1002651 (migration 017's family): abandoned is a terminal
329
+ -- state (deliberately cut), not pending work — it must not hold a
330
+ -- criterion's "met — pending review" flip hostage forever.
331
+ count(*) FILTER (WHERE t.status NOT IN ('shipped', 'abandoned'))::int AS unshipped_tasks
332
+ FROM task_criteria tc
333
+ JOIN tasks t ON t.id = tc.task_id
334
+ WHERE tc.criterion_id IN (SELECT id FROM crit)
335
+ GROUP BY tc.criterion_id
336
+ )
337
+ SELECT c.id, c.version_id, c.criterion_id, c.criterion_md, c.satisfied,
338
+ c.sort_order, c.goal_id,
339
+ COALESCE(n.linked_tasks, 0) AS linked_tasks,
340
+ COALESCE(n.unshipped_tasks, 0) AS unshipped_tasks
341
+ FROM crit c
342
+ LEFT JOIN counts n ON n.criterion_id = c.id
343
+ ORDER BY sort_order, id`,
344
+ [goalId]
345
+ );
346
+ return rows.map((r) => ({
347
+ ...r,
348
+ pending_review: doneWhen.isPendingReview({
349
+ satisfied: r.satisfied, linked: r.linked_tasks, unshipped: r.unshipped_tasks,
350
+ }),
351
+ }));
352
+ }
353
+
354
+ // isGoalMember — is this builder a member of this goal? The membership predicate
355
+ // behind the bounded-authority gates (BV1.R64 ask-to-widen; the R60 authoring
356
+ // gate reuses the same idea). Returns a plain boolean.
357
+
358
+ async function isGoalMember({ goalId, builderId }, deps = {}) {
359
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
360
+ const { rows } = await activePool.query(
361
+ `SELECT 1 FROM goal_members WHERE goal_id = $1 AND builder_id = $2 LIMIT 1`,
362
+ [goalId, builderId]
363
+ );
364
+ return rows.length > 0;
365
+ }
366
+
367
+ // getGoalMember — one membership row incl. membership_kind, or null. The
368
+ // lead-authority read behind PATCH /goals/:id/members/:builderId (task 1735): the
369
+ // route needs the actor's and the target's membership_kind, not just the
370
+ // isGoalMember boolean.
371
+
372
+ async function getGoalMember({ goalId, builderId }, deps = {}) {
373
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
374
+ const { rows } = await activePool.query(
375
+ `SELECT goal_id, builder_id, membership_kind, joined_at
376
+ FROM goal_members
377
+ WHERE goal_id = $1 AND builder_id = $2`,
378
+ [goalId, builderId]
379
+ );
380
+ return rows[0] ?? null;
381
+ }
382
+
383
+ // setGoalMembershipKind — apply a membership change with the owner guard IN the statement
384
+ // (task 1735): the goal OWNER (goals.created_by) is the fixed authority, so a
385
+ // demotion never applies to the owner's row even if two requests race past the
386
+ // route's pure gate (the attachParent "re-check atomically" posture). Returns
387
+ // the updated row, or null when nothing changed — no such member, or the guard
388
+ // blocked an owner demotion — which the route answers as a conflict.
389
+
390
+ async function setGoalMembershipKind({ goalId, builderId, membershipKind }, deps = {}) {
391
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
392
+ const { rows } = await activePool.query(
393
+ `UPDATE goal_members gm
394
+ SET membership_kind = $3
395
+ WHERE gm.goal_id = $1 AND gm.builder_id = $2
396
+ AND ($3 = 'lead'
397
+ OR gm.builder_id IS DISTINCT FROM
398
+ (SELECT g.created_by FROM goals g WHERE g.id = $1))
399
+ RETURNING goal_id, builder_id, membership_kind, joined_at`,
400
+ [goalId, builderId, membershipKind]
401
+ );
402
+ return rows[0] ?? null;
403
+ }
404
+
405
+ // transferGoalOwnership — reassign the goal's owner (task 1735): created_by
406
+ // moves to the new owner and the new owner's membership row is upserted to
407
+ // 'lead' (manager) in the SAME statement, so the goal can never point at an
408
+ // owner who isn't in its member list. The OLD owner keeps their lead row —
409
+ // "drops to manager" — exactly the handoff semantics the route promises. The
410
+ // route gates WHO may call this (owner | Archon, target Metic+); this helper
411
+ // just performs the move. Returns the updated goal row, or null for unknown id.
412
+
413
+ async function transferGoalOwnership({ goalId, newOwnerId }, deps = {}) {
414
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
415
+ const { rows } = await activePool.query(
416
+ `WITH g AS (
417
+ UPDATE goals
418
+ SET created_by = $2, updated_at = now()
419
+ WHERE id = $1
420
+ RETURNING ${GOAL_COLS}
421
+ ), new_owner_member AS (
422
+ INSERT INTO goal_members (goal_id, builder_id, membership_kind)
423
+ SELECT g.id, $2, 'lead' FROM g
424
+ ON CONFLICT (goal_id, builder_id) DO UPDATE SET membership_kind = 'lead'
425
+ )
426
+ SELECT * FROM g`,
427
+ [goalId, newOwnerId]
428
+ );
429
+ return rows[0] ?? null;
430
+ }
431
+
432
+ // listActiveClaimsForTasks — which of these tasks hold an active claim right
433
+ // now (claims.released_at IS NULL — the claims-table convention; there is no
434
+ // status column). Feeds the conflict detector's claim-aware severity
435
+ // (goal-conflicts.js, task 1736). Holder identity rides along so the hall can
436
+ // name who to talk to.
437
+
438
+ async function listActiveClaimsForTasks(taskIds, deps = {}) {
439
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
440
+ const ids = (taskIds || []).map(Number).filter(Number.isFinite);
441
+ if (ids.length === 0) return [];
442
+ const { rows } = await activePool.query(
443
+ `SELECT c.id AS claim_id, c.task_id, c.builder_id, c.claimed_at,
444
+ b.github_login
445
+ FROM claims c
446
+ LEFT JOIN builders b ON b.id = c.builder_id
447
+ WHERE c.task_id = ANY($1::bigint[]) AND c.released_at IS NULL
448
+ ORDER BY c.claimed_at, c.id`,
449
+ [ids]
450
+ );
451
+ return rows;
452
+ }
453
+
454
+ // requestClaimRelease — the flag-for-release resolution action (task 1736): a
455
+ // goal owner/manager asks the holder of a task's ACTIVE claim to wrap up or
456
+ // release. A soft nudge, not an eviction — nothing about the claim's validity
457
+ // changes; the flag surfaces on the holder's own claim reads
458
+ // (getActiveClaimsForBuilder → /me). Idempotent: re-flagging refreshes
459
+ // requester/time/note. Returns the flagged claim row, or null when the task
460
+ // holds no active claim (the route answers 409).
461
+
462
+ async function requestClaimRelease({ taskId, requestedBy, note = null }, deps = {}) {
463
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
464
+ const { rows } = await activePool.query(
465
+ `UPDATE claims
466
+ SET release_requested_by = $2,
467
+ release_requested_at = now(),
468
+ release_requested_note = $3
469
+ WHERE task_id = $1 AND released_at IS NULL
470
+ RETURNING id AS claim_id, task_id, builder_id,
471
+ release_requested_by, release_requested_at, release_requested_note`,
472
+ [taskId, requestedBy, note]
473
+ );
474
+ return rows[0] ?? null;
475
+ }
476
+
477
+ // rescopeTaskTouches — the rescope resolution action (task 1736): a goal
478
+ // owner/manager rewrites a task's DECLARED touches[] at PLANNING time, with the
479
+ // ADR 0049 advisory semantics (git merge stays the authoritative collision
480
+ // detector; this only reshapes the planning-time conflict view). This is NOT
481
+ // the mid-claim drift-expand removed in task 879 — the route refuses a task
482
+ // with an active claim, so touches are never rewritten under a working session.
483
+ //
484
+ // The ADR 0084 floor stays honest: the rank floor was auto-derived from touches
485
+ // at CREATE, so rewriting touches re-derives it and RATCHETS requires_rank UP
486
+ // in the same UPDATE if the new blast radius demands more (the
487
+ // updateTaskSecuritySensitive precedent — never lowered; an Archon can PATCH it
488
+ // down explicitly). The caller passes the task row's current facts
489
+ // (securitySensitive, currentRequiresRank) it already fetched for containment.
490
+ // Returns { id, touches, requires_rank }, or null for an unknown task.
491
+
492
+ async function rescopeTaskTouches({ taskId, touches, securitySensitive = false, currentRequiresRank = 'xenos' }, deps = {}) {
493
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
494
+ const cleaned = Array.from(new Set((touches || []).map((p) => String(p || '').trim()).filter(Boolean)));
495
+ const floor = deriveRequiredRank(cleaned, securitySensitive === true);
496
+ const effectiveRank = highestRank(currentRequiresRank ?? 'xenos', floor);
497
+ const { rows } = await activePool.query(
498
+ `UPDATE tasks SET touches = $2, requires_rank = $3, updated_at = now()
499
+ WHERE id = $1
500
+ RETURNING id, touches, requires_rank`,
501
+ [taskId, cleaned, effectiveRank]
502
+ );
503
+ return rows[0] ?? null;
504
+ }
505
+
506
+ // addGoalScopeModules — the "ask to widen" mutation (ADR 0086 §4 / BV1.R64). Adds
507
+ // module key(s) to a goal's scope_modules, set-union + deduped (existing keys and
508
+ // repeats collapse), so widening is idempotent. The route — not this helper — is
509
+ // where the wall is enforced: scope is NEVER self-granted, so a protected module
510
+ // can only be added by an Archon, and only a member/Archon may widen at all. This
511
+ // helper just performs the append once that gate has passed. Returns the updated
512
+ // goal row, or null for an unknown id.
513
+
514
+ async function addGoalScopeModules({ goalId, modules }, deps = {}) {
515
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
516
+ const { rows } = await activePool.query(
517
+ `UPDATE goals
518
+ SET scope_modules = COALESCE((
519
+ SELECT array_agg(DISTINCT m ORDER BY m)
520
+ FROM unnest(scope_modules || $2::text[]) AS m
521
+ ), '{}'),
522
+ updated_at = now()
523
+ WHERE id = $1
524
+ RETURNING ${GOAL_COLS}`,
525
+ [goalId, modules]
526
+ );
527
+ return rows[0] ?? null;
528
+ }
529
+
530
+ // setGoalSuccessor — the rescope carry-forward link (ADR 0086 §6 / BV1.R64). Stamps
531
+ // an (achieved) goal's succeeded_by_goal_id to point at the new-version goal that
532
+ // continues it, so the version→version lineage is queryable. Bumps updated_at.
533
+ // Returns the updated row, or null if the goal id is unknown.
534
+
535
+ async function setGoalSuccessor({ goalId, successorId }, deps = {}) {
536
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
537
+ const { rows } = await activePool.query(
538
+ `UPDATE goals
539
+ SET succeeded_by_goal_id = $2, updated_at = now()
540
+ WHERE id = $1
541
+ RETURNING ${GOAL_COLS}`,
542
+ [goalId, successorId]
543
+ );
544
+ return rows[0] ?? null;
545
+ }
546
+
547
+ // -------------------------------------------------------------------------
548
+ // Private goals: visibility + the invite / join-request queue (task 1951 / ADR
549
+ // 0112). The query layer only — the routes (task 1952) hold the authorization
550
+ // wall (owner|manager|Archon for invites/approvals; the invitee for accepting).
551
+ // -------------------------------------------------------------------------
552
+
553
+ // setGoalVisibility — flip a goal public|private (ADR 0112). 'public' is the
554
+ // migration default = today's open-join; 'private' means non-members can't
555
+ // self-join (they are invited or request+approved). Bumps updated_at. Returns
556
+ // the updated goal row, or null for an unknown id. The route gates WHO may call
557
+ // this (authorizeGoalManagement: owner|manager|Archon); this helper just writes.
558
+ async function setGoalVisibility({ goalId, visibility }, deps = {}) {
559
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
560
+ const { rows } = await activePool.query(
561
+ `UPDATE goals
562
+ SET visibility = $2, updated_at = now()
563
+ WHERE id = $1
564
+ RETURNING ${GOAL_COLS}`,
565
+ [goalId, visibility]
566
+ );
567
+ return rows[0] ?? null;
568
+ }
569
+
570
+ // createMembershipRequest — open a pending invite (kind='invite', a manager asks
571
+ // builder_id in) or join-request (kind='request', the builder asks to join). The
572
+ // partial-unique index (one pending row per goal+builder) means a duplicate ask
573
+ // raises 23505; we surface it as a typed PENDING_EXISTS the route maps to 409 (the
574
+ // access_requests precedent). createdBy is who opened it. Returns the new row.
575
+ async function createMembershipRequest({ goalId, builderId, kind, createdBy = null, note = null }, deps = {}) {
576
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
577
+ try {
578
+ const { rows } = await activePool.query(
579
+ `INSERT INTO goal_membership_requests (goal_id, builder_id, kind, created_by, note)
580
+ VALUES ($1, $2, $3, $4, $5)
581
+ RETURNING ${MEMBERSHIP_REQUEST_COLS}`,
582
+ [goalId, builderId, kind, createdBy, note]
583
+ );
584
+ return rows[0];
585
+ } catch (err) {
586
+ if (err && err.code === '23505') {
587
+ const e = new Error('a pending membership request already exists for this builder on this goal');
588
+ e.code = 'PENDING_EXISTS';
589
+ throw e;
590
+ }
591
+ throw err;
592
+ }
593
+ }
594
+
595
+ // getMembershipRequest — one request row by id, or null. The route reads it to
596
+ // check kind + goal_id + status + builder_id before authorizing a resolve.
597
+ async function getMembershipRequest(id, deps = {}) {
598
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
599
+ const { rows } = await activePool.query(
600
+ `SELECT ${MEMBERSHIP_REQUEST_COLS} FROM goal_membership_requests WHERE id = $1`,
601
+ [id]
602
+ );
603
+ return rows[0] ?? null;
604
+ }
605
+
606
+ // resolveMembershipRequest — close a pending request. status is one of
607
+ // 'accepted' | 'rejected' | 'cancelled'. The WHERE status='pending' guard makes
608
+ // it atomic + idempotent: a lost race (already resolved / gone) returns null, so
609
+ // the route never double-adds a member off a re-submitted resolve. resolvedBy is
610
+ // who closed it. The membership add itself is the route's job (addGoalMember on
611
+ // accept/approve). Returns the updated row, or null when nothing was pending.
612
+ async function resolveMembershipRequest({ id, status, resolvedBy = null }, deps = {}) {
613
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
614
+ const { rows } = await activePool.query(
615
+ `UPDATE goal_membership_requests
616
+ SET status = $2, resolved_by = $3, resolved_at = now(), updated_at = now()
617
+ WHERE id = $1 AND status = 'pending'
618
+ RETURNING ${MEMBERSHIP_REQUEST_COLS}`,
619
+ [id, status, resolvedBy]
620
+ );
621
+ return rows[0] ?? null;
622
+ }
623
+
624
+ // acceptMembershipRequestAndAddMember — the ACCEPT/APPROVE half done ATOMICALLY
625
+ // (task 1952 review fix). Resolving a request to 'accepted' and adding the
626
+ // member used to be two separate queries: an error between them left an
627
+ // 'accepted' row with no membership — unrecoverable, since the row is no longer
628
+ // pending and can't be re-accepted. One CTE makes them a single statement: the
629
+ // member INSERT feeds off the SAME UPDATE (the createGoal / transferGoalOwnership
630
+ // atomic-CTE precedent), so either both land or neither does. The UPDATE's
631
+ // WHERE status='pending' keeps it idempotent — a lost race (already resolved /
632
+ // gone) resolves nothing, adds nobody, and returns null. On the (near-impossible,
633
+ // since the create routes reject an existing member) membership conflict the
634
+ // existing membership_kind is PRESERVED, so an approve never silently demotes a
635
+ // manager.
636
+ // Returns { request, member } or null.
637
+ async function acceptMembershipRequestAndAddMember({ id, resolvedBy = null }, deps = {}) {
638
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
639
+ const { rows } = await activePool.query(
640
+ `WITH resolved AS (
641
+ UPDATE goal_membership_requests
642
+ SET status = 'accepted', resolved_by = $2, resolved_at = now(), updated_at = now()
643
+ WHERE id = $1 AND status = 'pending'
644
+ RETURNING ${MEMBERSHIP_REQUEST_COLS}
645
+ ), added AS (
646
+ INSERT INTO goal_members (goal_id, builder_id, membership_kind)
647
+ SELECT goal_id, builder_id, 'member' FROM resolved
648
+ ON CONFLICT (goal_id, builder_id) DO UPDATE SET membership_kind = goal_members.membership_kind
649
+ RETURNING goal_id, builder_id, membership_kind, joined_at
650
+ )
651
+ SELECT (SELECT to_jsonb(r) FROM resolved r) AS request,
652
+ (SELECT to_jsonb(a) FROM added a) AS member`,
653
+ [id, resolvedBy]
654
+ );
655
+ const request = rows[0] && rows[0].request;
656
+ if (!request) return null; // nothing was pending — no resolve, no member added
657
+ return { request, member: rows[0].member };
658
+ }
659
+
660
+ // listInboxFor — the home-page inbox (task 1954) for one builder, three buckets:
661
+ // • invitations — pending invites addressed to ME (accept/decline), with the
662
+ // goal + who invited me.
663
+ // • joinRequests — pending join-requests on goals I OWN or MANAGE
664
+ // (goals.created_by = me OR a goal_members 'lead' row for me),
665
+ // with the goal + the asker's identity + their note (approve/deny).
666
+ // • myPendingRequests — my own outstanding join-requests (so I can see/withdraw them).
667
+ // Requests only ever exist on private goals someone made private, so an
668
+ // owner/manager always exists — a request never lands in nobody's inbox. Archons
669
+ // can still approve any request via the route; the inbox stays scoped to what a
670
+ // builder owns/manages so it doesn't drown an Archon in every goal's queue.
671
+ async function listInboxFor(builderId, deps = {}) {
672
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
673
+ const [invitations, joinRequests, myPendingRequests] = await Promise.all([
674
+ activePool.query(
675
+ `SELECT r.id, r.goal_id, r.kind, r.status, r.note, r.created_at,
676
+ g.title AS goal_title, g.version_id, g.visibility,
677
+ ib.github_login AS invited_by_login, ib.display_name AS invited_by_name,
678
+ ib.avatar_url AS invited_by_avatar
679
+ FROM goal_membership_requests r
680
+ JOIN goals g ON g.id = r.goal_id
681
+ LEFT JOIN builders ib ON ib.id = r.created_by
682
+ WHERE r.kind = 'invite' AND r.status = 'pending' AND r.builder_id = $1
683
+ ORDER BY r.created_at, r.id`,
684
+ [builderId]
685
+ ),
686
+ activePool.query(
687
+ `SELECT r.id, r.goal_id, r.builder_id, r.kind, r.status, r.note, r.created_at,
688
+ g.title AS goal_title, g.version_id, g.visibility,
689
+ b.github_login, b.display_name, b.avatar_url, b.rank
690
+ FROM goal_membership_requests r
691
+ JOIN goals g ON g.id = r.goal_id
692
+ LEFT JOIN builders b ON b.id = r.builder_id
693
+ WHERE r.kind = 'request' AND r.status = 'pending'
694
+ AND (g.created_by = $1
695
+ OR EXISTS (SELECT 1 FROM goal_members gm
696
+ WHERE gm.goal_id = g.id AND gm.builder_id = $1 AND gm.membership_kind = 'lead'))
697
+ ORDER BY r.created_at, r.id`,
698
+ [builderId]
699
+ ),
700
+ activePool.query(
701
+ `SELECT r.id, r.goal_id, r.status, r.note, r.created_at,
702
+ g.title AS goal_title, g.version_id, g.visibility
703
+ FROM goal_membership_requests r
704
+ JOIN goals g ON g.id = r.goal_id
705
+ WHERE r.kind = 'request' AND r.status = 'pending' AND r.builder_id = $1
706
+ ORDER BY r.created_at, r.id`,
707
+ [builderId]
708
+ ),
709
+ ]);
710
+ return {
711
+ invitations: invitations.rows,
712
+ joinRequests: joinRequests.rows,
713
+ myPendingRequests: myPendingRequests.rows,
714
+ };
715
+ }
716
+
717
+ // listPendingRequestsForGoal — the goals-page manager view (task 1953): every
718
+ // pending row on a goal, BOTH kinds (invites the goal has outstanding + join-
719
+ // requests awaiting approval), with the counterpart builder's identity. Ordered
720
+ // kind-then-time so invites and requests group predictably.
721
+ async function listPendingRequestsForGoal(goalId, deps = {}) {
722
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
723
+ const { rows } = await activePool.query(
724
+ `SELECT r.id, r.goal_id, r.builder_id, r.kind, r.status, r.note, r.created_at,
725
+ b.github_login, b.display_name, b.avatar_url, b.rank
726
+ FROM goal_membership_requests r
727
+ LEFT JOIN builders b ON b.id = r.builder_id
728
+ WHERE r.goal_id = $1 AND r.status = 'pending'
729
+ ORDER BY r.kind, r.created_at, r.id`,
730
+ [goalId]
731
+ );
732
+ return rows;
733
+ }
734
+
735
+ module.exports = {
736
+ acceptMembershipRequestAndAddMember,
737
+ achieveGoalIfComplete,
738
+ addGoalMember,
739
+ addGoalScopeModules,
740
+ createGoal,
741
+ createMembershipRequest,
742
+ getGoal,
743
+ getGoalDependencies,
744
+ getGoalMember,
745
+ getMembershipRequest,
746
+ isGoalMember,
747
+ listActiveClaimsForTasks,
748
+ listCriteriaForGoal,
749
+ listGoalMembers,
750
+ listGoals,
751
+ listInboxFor,
752
+ listPendingRequestsForGoal,
753
+ removeGoalMember,
754
+ reorderGoals,
755
+ requestClaimRelease,
756
+ rescopeTaskTouches,
757
+ resolveMembershipRequest,
758
+ setGoalMembershipKind,
759
+ setGoalStatus,
760
+ setGoalSuccessor,
761
+ setGoalVisibility,
762
+ transferGoalOwnership,
763
+ };