@bongos/core 1.19.316 → 1.19.318

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 (42) hide show
  1. package/.bongos-core.json +108 -33
  2. package/modules/economy/cost.js +84 -19
  3. package/modules/lifecycle/db-analytics.js +719 -0
  4. package/modules/lifecycle/db-claim-reads.js +165 -0
  5. package/modules/lifecycle/db-claims.js +934 -0
  6. package/modules/lifecycle/db-deps-criteria.js +441 -0
  7. package/modules/lifecycle/db-goals.js +763 -0
  8. package/modules/lifecycle/db-grade.js +233 -0
  9. package/modules/lifecycle/db-overrides.js +181 -0
  10. package/modules/lifecycle/db-rank-authz.js +446 -0
  11. package/modules/lifecycle/db-shared.js +115 -0
  12. package/modules/lifecycle/db-ship.js +994 -0
  13. package/modules/lifecycle/db-tasks.js +810 -0
  14. package/modules/lifecycle/db-versions.js +118 -0
  15. package/modules/lifecycle/db.js +167 -5564
  16. package/modules/lifecycle/publish-reconciler.js +7 -2
  17. package/package-lock.json +2 -2
  18. package/package.json +1 -1
  19. package/scripts/gds/coreB-carve-proof.sh +18 -9
  20. package/scripts/gds/fitness.js +34 -26
  21. package/scripts/gds/identity-strip.js +75 -0
  22. package/scripts/gds/provision.js +7 -3
  23. package/scripts/gds/run-unit-tests.js +8 -1
  24. package/scripts/gds/session-cost.js +16 -0
  25. package/scripts/gds/session-digest-build.js +261 -0
  26. package/scripts/gds/ship.js +7 -198
  27. package/src/module-api.js +5 -1
  28. package/tests/auto_satisfy_criteria.mjs +3 -2
  29. package/tests/dependencies_migration.mjs +3 -2
  30. package/tests/fitness.mjs +43 -0
  31. package/tests/goal_abuse_matrix.mjs +3 -2
  32. package/tests/goal_advisory.mjs +3 -2
  33. package/tests/government_claim_eligibility.mjs +3 -1
  34. package/tests/government_rank_sync.mjs +9 -2
  35. package/tests/helpers.mjs +19 -0
  36. package/tests/migration_allocation.mjs +3 -1
  37. package/tests/publish_reconciler.mjs +41 -1
  38. package/tests/rank_gate.mjs +3 -1
  39. package/tests/reward_gate.mjs +3 -1
  40. package/tests/session_cost.mjs +64 -0
  41. package/tests/session_cost_row_raise.mjs +139 -0
  42. package/tests/session_records.mjs +72 -0
@@ -0,0 +1,441 @@
1
+ // modules/lifecycle/db-deps-criteria.js — task/goal dependency edges, cycle detection + done-when criterion links.
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 2133-2539 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, withTx } = api;
15
+
16
+ // -------------------------------------------------------------------------
17
+ // Task dependencies (migration 020, task 309 / V3.R01b)
18
+ // -------------------------------------------------------------------------
19
+ // Forward edge: rows where task_id = X give X's dependencies.
20
+ // Reverse edge: rows where depends_on_task_id = X give X's dependents.
21
+
22
+ // Shared projection for both dep edges. Forward (dependencies): the tasks X
23
+ // depends on. Reverse (dependents): the tasks that depend on X. Same SELECT;
24
+ // the two differ only in which side of the dependencies edge is joined vs filtered.
25
+
26
+ async function getTaskDepEdges(taskId, { reverse = false } = {}) {
27
+ // task->task edges only (the historical dependency arrays). Goal/criterion edges
28
+ // live in the same `dependencies` table but surface via the generic /dependencies
29
+ // endpoint, not these task-summary arrays — so consumers see an unchanged shape
30
+ // (task 1516). Forward: $1 is the from side; reverse: $1 is the to side.
31
+ const joinCol = reverse ? 'd.from_id' : 'd.to_id';
32
+ const whereCol = reverse ? 'd.to_id' : 'd.from_id';
33
+ const { rows } = await pool.query(
34
+ `SELECT t.id, t.title, t.status, t.version_id, t.kind, t.priority
35
+ FROM dependencies d
36
+ JOIN tasks t ON t.id = ${joinCol}
37
+ WHERE d.from_kind = 'task' AND d.to_kind = 'task'
38
+ AND ${whereCol} = $1
39
+ ORDER BY t.title`,
40
+ [taskId]
41
+ );
42
+ return rows;
43
+ }
44
+
45
+
46
+ async function getTaskDependencies(taskId) {
47
+ return getTaskDepEdges(taskId, { reverse: false });
48
+ }
49
+
50
+
51
+ async function getTaskDependents(taskId) {
52
+ return getTaskDepEdges(taskId, { reverse: true });
53
+ }
54
+
55
+ // attachTaskDepSummaries (hall redesign task 1002439) — enrich an already-fetched
56
+ // task list with the two dependency numbers the Goals rollup + Tasks dependency
57
+ // view need, WITHOUT an N+1 (two aggregate queries over the whole id set, not one
58
+ // per task). For each task:
59
+ // blocked_by[] — the tasks THIS task waits on ({id,title,status,goal_id,satisfied}),
60
+ // where satisfied === (target.status === 'shipped'), matching the
61
+ // claim gate's DEPS_NOT_SHIPPED rule (ADR 0015). A `backlog` task
62
+ // with any unsatisfied entry is what the UI paints "blocked".
63
+ // blocks — how many tasks are waiting on THIS one (reverse edge count) —
64
+ // the "clearing this unblocks N" signal.
65
+ // task->task edges only (from_kind/to_kind='task'), same filter as getTaskDepEdges.
66
+ // Mutates + returns the passed array so callers can `await db.attachTaskDepSummaries(tasks)`.
67
+ async function attachTaskDepSummaries(tasks) {
68
+ if (!Array.isArray(tasks) || tasks.length === 0) return tasks;
69
+ const ids = tasks.map((t) => Number(t.id)).filter(Number.isFinite);
70
+ if (ids.length === 0) return tasks;
71
+ const [fwd, rev] = await Promise.all([
72
+ pool.query(
73
+ `SELECT d.from_id AS task_id,
74
+ json_agg(json_build_object(
75
+ 'id', t2.id, 'title', t2.title, 'status', t2.status,
76
+ 'goal_id', t2.goal_id, 'satisfied', (t2.status = 'shipped')
77
+ ) ORDER BY t2.id) AS blocked_by
78
+ FROM dependencies d
79
+ JOIN tasks t2 ON t2.id = d.to_id
80
+ WHERE d.from_kind = 'task' AND d.to_kind = 'task'
81
+ AND d.from_id = ANY($1::bigint[])
82
+ GROUP BY d.from_id`,
83
+ [ids]
84
+ ),
85
+ pool.query(
86
+ `SELECT d.to_id AS task_id, count(*)::int AS blocks
87
+ FROM dependencies d
88
+ WHERE d.from_kind = 'task' AND d.to_kind = 'task'
89
+ AND d.to_id = ANY($1::bigint[])
90
+ GROUP BY d.to_id`,
91
+ [ids]
92
+ ),
93
+ ]);
94
+ const blockedBy = new Map(fwd.rows.map((r) => [String(r.task_id), r.blocked_by]));
95
+ const blocks = new Map(rev.rows.map((r) => [String(r.task_id), r.blocks]));
96
+ for (const t of tasks) {
97
+ t.blocked_by = blockedBy.get(String(t.id)) || [];
98
+ t.blocks = blocks.get(String(t.id)) || 0;
99
+ }
100
+ return tasks;
101
+ }
102
+
103
+ // Generic cycle check over the polymorphic graph. Adding edge (from -> to) creates
104
+ // a cycle iff `from` is already reachable FROM `to` along existing edges, so we seed
105
+ // the walk at `to` and look for `from`. Nodes are (kind, id) pairs tokenized as
106
+ // "kind:id"; returns the offending path of tokens, or null. Visited-set + depth cap
107
+ // keep it terminating (task 1516, generalizes the task-only CTE from migration 020).
108
+
109
+ async function findCycle(fromKind, fromId, toKind, toId, executor = pool) {
110
+ if (fromKind === toKind && Number(fromId) === Number(toId)) {
111
+ return [`${fromKind}:${fromId}`, `${toKind}:${toId}`];
112
+ }
113
+ const { rows } = await executor.query(
114
+ `WITH RECURSIVE walk(kind, id, depth, path) AS (
115
+ SELECT $1::text, $2::bigint, 1, ARRAY[$1 || ':' || $2::text]
116
+ UNION ALL
117
+ SELECT d.to_kind, d.to_id, w.depth + 1, w.path || (d.to_kind || ':' || d.to_id::text)
118
+ FROM dependencies d
119
+ JOIN walk w ON d.from_kind = w.kind AND d.from_id = w.id
120
+ WHERE NOT ((d.to_kind || ':' || d.to_id::text) = ANY(w.path))
121
+ AND w.depth < 100
122
+ )
123
+ SELECT path FROM walk WHERE kind = $3 AND id = $4::bigint LIMIT 1`,
124
+ [toKind, toId, fromKind, fromId]
125
+ );
126
+ return rows[0]?.path ?? null;
127
+ }
128
+
129
+ // Task-only wrapper preserving the historical bigint[] path shape for the task
130
+ // dependency API. A pure task->task cycle maps cleanly to task ids; a cycle that
131
+ // traverses a goal/criterion node (possible once cross-kind edges exist) keeps those
132
+ // nodes as "kind:id" tokens rather than mislabeling them as task ids.
133
+
134
+ async function findDependencyCycle(taskId, depTaskId, executor = pool) {
135
+ if (Number(taskId) === Number(depTaskId)) return [Number(taskId), Number(depTaskId)];
136
+ const path = await findCycle('task', taskId, 'task', depTaskId, executor);
137
+ if (!path) return null;
138
+ return path.map((tok) => {
139
+ const [kind, id] = String(tok).split(':');
140
+ return kind === 'task' ? Number(id) : tok;
141
+ });
142
+ }
143
+
144
+
145
+ async function addTaskDependency(taskId, depTaskId) {
146
+ if (taskId === depTaskId) {
147
+ throw { code: 'SELF_DEP' };
148
+ }
149
+ // Confirm both tasks exist
150
+ const { rows: existing } = await pool.query(
151
+ `SELECT id FROM tasks WHERE id = ANY($1::bigint[])`,
152
+ [[taskId, depTaskId]]
153
+ );
154
+ if (existing.length !== 2) {
155
+ throw { code: 'TASK_NOT_FOUND' };
156
+ }
157
+ const cyclePath = await findDependencyCycle(taskId, depTaskId);
158
+ if (cyclePath) {
159
+ throw { code: 'CYCLE', path: cyclePath };
160
+ }
161
+ await pool.query(
162
+ `INSERT INTO dependencies (from_kind, from_id, to_kind, to_id)
163
+ VALUES ('task', $1, 'task', $2)
164
+ ON CONFLICT DO NOTHING`,
165
+ [taskId, depTaskId]
166
+ );
167
+ return { task_id: taskId, depends_on_task_id: depTaskId };
168
+ }
169
+
170
+
171
+ async function removeTaskDependency(taskId, depTaskId) {
172
+ const { rowCount } = await pool.query(
173
+ `DELETE FROM dependencies
174
+ WHERE from_kind = 'task' AND from_id = $1
175
+ AND to_kind = 'task' AND to_id = $2`,
176
+ [taskId, depTaskId]
177
+ );
178
+ return rowCount > 0;
179
+ }
180
+
181
+ // Replace the full dependency set for a task. Used by bulk seeders.
182
+ // Validates no cycles among the new set in one transaction. Order of the
183
+ // list doesn't matter.
184
+
185
+ async function setTaskDependencies(taskId, depTaskIds) {
186
+ const unique = [...new Set(depTaskIds.map(Number))];
187
+ if (unique.includes(Number(taskId))) {
188
+ throw { code: 'SELF_DEP' };
189
+ }
190
+ return withTx(async (client) => {
191
+ // Replace only this task's task->task edges; leave any task->goal/criterion
192
+ // edges (created via the generic /dependencies endpoint) untouched.
193
+ await client.query(
194
+ `DELETE FROM dependencies WHERE from_kind = 'task' AND from_id = $1 AND to_kind = 'task'`,
195
+ [taskId]
196
+ );
197
+ for (const dep of unique) {
198
+ // Re-check cycle for each insert (using the same txn connection so the
199
+ // previous inserts in this txn are visible). Reuses findDependencyCycle
200
+ // with the txn client as executor — task 1087, C3.
201
+ const cyclePath = await findDependencyCycle(taskId, dep, client);
202
+ if (cyclePath) {
203
+ throw { code: 'CYCLE', path: cyclePath };
204
+ }
205
+ await client.query(
206
+ `INSERT INTO dependencies (from_kind, from_id, to_kind, to_id)
207
+ VALUES ('task', $1, 'task', $2)`,
208
+ [taskId, dep]
209
+ );
210
+ }
211
+ return unique;
212
+ });
213
+ }
214
+
215
+ // ---------------------------------------------------------------------------
216
+ // Generic polymorphic dependencies (migration 163 / ADR 0086 §5 / task 1516).
217
+ // The task->task helpers above are the historical task API over this same table;
218
+ // these add/remove/read edges of ANY kind ({task, goal, criterion}). A dependency
219
+ // is a REFERENCE, not a grant — it never widens module scope, so cross-goal /
220
+ // cross-version edges are allowed.
221
+ // ---------------------------------------------------------------------------
222
+
223
+ const DEP_KINDS = ['task', 'goal', 'criterion'];
224
+
225
+ const DEP_KIND_TABLE = { task: 'tasks', goal: 'goals', criterion: 'done_when_criteria' };
226
+
227
+
228
+ async function dependencyNodeExists(kind, id, executor = pool) {
229
+ const table = DEP_KIND_TABLE[kind];
230
+ if (!table) return false;
231
+ const { rows } = await executor.query(`SELECT 1 FROM ${table} WHERE id = $1`, [id]);
232
+ return rows.length > 0;
233
+ }
234
+
235
+ // Add a polymorphic edge "from is blocked until to is done". Throws BAD_KIND,
236
+ // SELF_DEP, NODE_NOT_FOUND, or CYCLE (with the offending kind:id path).
237
+
238
+ async function addDependency(fromKind, fromId, toKind, toId) {
239
+ if (!DEP_KINDS.includes(fromKind) || !DEP_KINDS.includes(toKind)) throw { code: 'BAD_KIND' };
240
+ if (fromKind === toKind && Number(fromId) === Number(toId)) throw { code: 'SELF_DEP' };
241
+ if (!(await dependencyNodeExists(fromKind, fromId)) || !(await dependencyNodeExists(toKind, toId))) {
242
+ throw { code: 'NODE_NOT_FOUND' };
243
+ }
244
+ const cyclePath = await findCycle(fromKind, fromId, toKind, toId);
245
+ if (cyclePath) throw { code: 'CYCLE', path: cyclePath };
246
+ await pool.query(
247
+ `INSERT INTO dependencies (from_kind, from_id, to_kind, to_id)
248
+ VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING`,
249
+ [fromKind, fromId, toKind, toId]
250
+ );
251
+ return { from_kind: fromKind, from_id: Number(fromId), to_kind: toKind, to_id: Number(toId) };
252
+ }
253
+
254
+
255
+ async function removeDependency(fromKind, fromId, toKind, toId) {
256
+ const { rowCount } = await pool.query(
257
+ `DELETE FROM dependencies
258
+ WHERE from_kind = $1 AND from_id = $2 AND to_kind = $3 AND to_id = $4`,
259
+ [fromKind, fromId, toKind, toId]
260
+ );
261
+ return rowCount > 0;
262
+ }
263
+
264
+ // ---------------------------------------------------------------------------
265
+ // Task ↔ criterion links (task_criteria, migration 055 / ADR 0025; populated on
266
+ // task-create + manage endpoints by task 438). Mirrors the task_dependencies
267
+ // CRUD above. A criterion "ref" is either a numeric done_when_criteria.id, a
268
+ // positional "Cn" token (C1 = the version's first criterion by
269
+ // (sort_order, criterion_id) — the SAME ordering the #435 backfill +
270
+ // done-when.criterionProgress use), or a criterion_id slug. resolveCriterionIds
271
+ // maps refs to numeric ids SCOPED to a version, so "C8" is unambiguous and a
272
+ // numeric id from another version doesn't resolve.
273
+ // ---------------------------------------------------------------------------
274
+
275
+ // PURE: classify a single criterion ref. Returns { kind:'cnum', n } |
276
+ // { kind:'id', id } | { kind:'slug', slug }. Exported for unit tests (no DB).
277
+ // A leading C/c + digits is positional; bare digits are a numeric id; anything
278
+ // else is treated as a criterion_id slug.
279
+
280
+ function parseCriterionRef(raw) {
281
+ const ref = String(raw == null ? '' : raw).trim();
282
+ const cMatch = /^C(\d+)$/i.exec(ref);
283
+ if (cMatch) return { kind: 'cnum', n: Number(cMatch[1]) };
284
+ if (/^\d+$/.test(ref)) return { kind: 'id', id: Number(ref) };
285
+ return { kind: 'slug', slug: ref };
286
+ }
287
+
288
+ // Resolve an array of refs to done_when_criteria.id values within a version.
289
+ // Returns { ids: [unique numeric ids, first-appearance order], unresolved: [refs] }.
290
+
291
+ async function resolveCriterionIds(versionId, refs) {
292
+ const ids = [];
293
+ const unresolved = [];
294
+ if (!Array.isArray(refs) || refs.length === 0) return { ids, unresolved };
295
+ const { rows } = await pool.query(
296
+ `SELECT id, criterion_id,
297
+ ROW_NUMBER() OVER (ORDER BY sort_order, criterion_id) AS cnum
298
+ FROM done_when_criteria
299
+ WHERE version_id = $1`,
300
+ [versionId]
301
+ );
302
+ const byId = new Set(rows.map((r) => Number(r.id)));
303
+ const byCnum = new Map(rows.map((r) => [Number(r.cnum), Number(r.id)]));
304
+ const bySlug = new Map(rows.map((r) => [String(r.criterion_id), Number(r.id)]));
305
+ const seen = new Set();
306
+ for (const raw of refs) {
307
+ const p = parseCriterionRef(raw);
308
+ let id = null;
309
+ if (p.kind === 'cnum') id = byCnum.get(p.n) ?? null;
310
+ else if (p.kind === 'id') id = byId.has(p.id) ? p.id : null;
311
+ else id = bySlug.get(p.slug) ?? null;
312
+ if (id == null) { unresolved.push(String(raw).trim()); continue; }
313
+ if (!seen.has(id)) { seen.add(id); ids.push(id); }
314
+ }
315
+ return { ids, unresolved };
316
+ }
317
+
318
+ // Read the criteria linked to a task, with the live "Cn" position + link source.
319
+
320
+ async function getTaskCriteria(taskId) {
321
+ const { rows } = await pool.query(
322
+ `WITH ranked AS (
323
+ SELECT id, version_id, criterion_id, criterion_md,
324
+ ROW_NUMBER() OVER (PARTITION BY version_id ORDER BY sort_order, criterion_id) AS cnum
325
+ FROM done_when_criteria
326
+ )
327
+ SELECT r.id, r.criterion_id, r.criterion_md, r.cnum, tc.source, tc.created_at
328
+ FROM task_criteria tc
329
+ JOIN ranked r ON r.id = tc.criterion_id
330
+ WHERE tc.task_id = $1
331
+ ORDER BY r.cnum`,
332
+ [taskId]
333
+ );
334
+ return rows;
335
+ }
336
+
337
+ // Link one task to one criterion. Validates both exist AND share a version (a
338
+ // task can only gate its OWN version's criteria). Idempotent (ON CONFLICT DO
339
+ // NOTHING). Throws { code } the route maps: TASK_NOT_FOUND | CRITERION_NOT_FOUND
340
+ // | CRITERION_VERSION_MISMATCH.
341
+
342
+ async function addTaskCriterion(taskId, criterionId, source = 'manual') {
343
+ const { rows: tRows } = await pool.query(`SELECT version_id FROM tasks WHERE id = $1`, [taskId]);
344
+ if (tRows.length === 0) throw { code: 'TASK_NOT_FOUND' };
345
+ const { rows: cRows } = await pool.query(`SELECT version_id FROM done_when_criteria WHERE id = $1`, [criterionId]);
346
+ if (cRows.length === 0) throw { code: 'CRITERION_NOT_FOUND' };
347
+ if (cRows[0].version_id !== tRows[0].version_id) {
348
+ throw { code: 'CRITERION_VERSION_MISMATCH', taskVersion: tRows[0].version_id, criterionVersion: cRows[0].version_id };
349
+ }
350
+ const safeSource = ['manual', 'planning', 'backfill'].includes(source) ? source : 'manual';
351
+ await pool.query(
352
+ `INSERT INTO task_criteria (task_id, criterion_id, source)
353
+ VALUES ($1, $2, $3)
354
+ ON CONFLICT DO NOTHING`,
355
+ [taskId, criterionId, safeSource]
356
+ );
357
+ return { task_id: taskId, criterion_id: criterionId, source: safeSource };
358
+ }
359
+
360
+
361
+ async function removeTaskCriterion(taskId, criterionId) {
362
+ const { rowCount } = await pool.query(
363
+ `DELETE FROM task_criteria WHERE task_id = $1 AND criterion_id = $2`,
364
+ [taskId, criterionId]
365
+ );
366
+ return rowCount > 0;
367
+ }
368
+
369
+ // Link many already-resolved criterion ids at create-time (default source
370
+ // 'planning'). Assumes resolveCriterionIds already version-checked them, so this
371
+ // is one idempotent batch insert. Returns the count inserted.
372
+
373
+ async function linkTaskCriteria(taskId, criterionIds, source = 'planning') {
374
+ const ids = [...new Set((criterionIds || []).map(Number).filter(Number.isFinite))];
375
+ if (ids.length === 0) return 0;
376
+ const safeSource = ['manual', 'planning', 'backfill'].includes(source) ? source : 'planning';
377
+ const { rowCount } = await pool.query(
378
+ `INSERT INTO task_criteria (task_id, criterion_id, source)
379
+ SELECT $1, c, $3 FROM unnest($2::bigint[]) AS c
380
+ ON CONFLICT DO NOTHING`,
381
+ [taskId, ids, safeSource]
382
+ );
383
+ return rowCount;
384
+ }
385
+
386
+ // The claim gate's resolution point (task 1516 / ADR 0086 §5). Returns every
387
+ // dependency of taskId that is NOT yet "done", resolving the task's OWN edges plus
388
+ // edges INHERITED from its goal (tasks.goal_id) and the criteria it fulfils
389
+ // (task_criteria). "Done": task shipped | criterion satisfied | goal achieved. Each
390
+ // unmet row is { kind, id, title, state }. An orphaned `to` (no FK; row deleted)
391
+ // yields no JOIN match and is silently absent — the promotion predicate treats such
392
+ // an edge as un-done, so it can never accidentally unblock a task. A task with only
393
+ // task->task edges resolves identically to the pre-1516 task-only gate.
394
+
395
+ async function unsatisfiedDeps(taskId, client = pool) {
396
+ const { rows } = await client.query(
397
+ `WITH gating AS (
398
+ SELECT d.to_kind, d.to_id
399
+ FROM dependencies d
400
+ JOIN tasks t ON t.id = $1
401
+ WHERE (d.from_kind = 'task' AND d.from_id = t.id)
402
+ OR (d.from_kind = 'goal' AND d.from_id = t.goal_id)
403
+ OR (d.from_kind = 'criterion' AND d.from_id IN (
404
+ SELECT tc.criterion_id FROM task_criteria tc WHERE tc.task_id = t.id))
405
+ )
406
+ SELECT 'task' AS kind, x.id, x.title, x.status::text AS state
407
+ FROM gating g JOIN tasks x ON g.to_kind = 'task' AND x.id = g.to_id
408
+ WHERE x.status <> 'shipped'
409
+ UNION ALL
410
+ SELECT 'criterion' AS kind, x.id, x.criterion_md AS title,
411
+ (CASE WHEN x.satisfied THEN 'satisfied' ELSE 'unsatisfied' END) AS state
412
+ FROM gating g JOIN done_when_criteria x ON g.to_kind = 'criterion' AND x.id = g.to_id
413
+ WHERE x.satisfied = false
414
+ UNION ALL
415
+ SELECT 'goal' AS kind, x.id, x.title, x.status::text AS state
416
+ FROM gating g JOIN goals x ON g.to_kind = 'goal' AND x.id = g.to_id
417
+ WHERE x.status <> 'achieved'`,
418
+ [taskId]
419
+ );
420
+ return rows;
421
+ }
422
+
423
+ module.exports = {
424
+ addDependency,
425
+ addTaskCriterion,
426
+ addTaskDependency,
427
+ attachTaskDepSummaries,
428
+ findCycle,
429
+ findDependencyCycle,
430
+ getTaskCriteria,
431
+ getTaskDependencies,
432
+ getTaskDependents,
433
+ linkTaskCriteria,
434
+ parseCriterionRef,
435
+ removeDependency,
436
+ removeTaskCriterion,
437
+ removeTaskDependency,
438
+ resolveCriterionIds,
439
+ setTaskDependencies,
440
+ unsatisfiedDeps,
441
+ };