@bongos/core 1.19.812 → 1.19.814
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.
- package/.bongos-core.json +73 -38
- package/clients/bongos-client/bongos-client.global.js +1 -1
- package/clients/bongos-client/index.cjs +1 -1
- package/clients/bongos-client/index.d.ts +2 -3
- package/clients/bongos-client/index.mjs +1 -1
- package/docs/adr/0297-a-tasks-creator-may-retire-it.md +128 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +5 -28
- package/docs/api-reference.md +2 -2
- package/docs/canonical-permissions.md +30 -1
- package/docs/copy-inventory.md +49 -45
- package/docs/copy-registry.json +86 -50
- package/docs/module-api-changelog.md +4 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +1 -1
- package/modules/government/catalog.js +28 -1
- package/modules/government/migrations/government_015_task_abandon_own.sql +64 -0
- package/modules/hall-ui/public/task.js +50 -1
- package/modules/lifecycle/abandon-authz.js +132 -0
- package/modules/lifecycle/db-executor.js +45 -0
- package/modules/lifecycle/db-goals.js +29 -7
- package/modules/lifecycle/db-ship.js +59 -5
- package/modules/lifecycle/db-tasks.js +11 -5
- package/modules/lifecycle/db-versions.js +17 -3
- package/modules/lifecycle/done-when.js +5 -2
- package/modules/lifecycle/goal-edits.js +5 -1
- package/modules/lifecycle/routes/abandon-handler.js +151 -0
- package/modules/lifecycle/routes/done-when.js +150 -86
- package/modules/lifecycle/routes/tasks.js +26 -38
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/route-rank-check.js +12 -1
- package/src/module-api.js +1 -1
- package/tests/abandon_creator_authz.mjs +176 -0
- package/tests/abandon_route.mjs +276 -0
- package/tests/abandon_task.mjs +145 -3
- package/tests/criterion_authoring_gate.mjs +22 -1
- package/tests/criterion_goal_attach.mjs +233 -8
- package/tests/government_ownership_scope.mjs +13 -7
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
// modules/lifecycle/abandon-authz.js — the PURE authorization decision behind
|
|
2
|
+
// POST /tasks/:id/abandon: may THIS builder retire THIS task, right now?
|
|
3
|
+
//
|
|
4
|
+
// WHY IT IS ITS OWN FILE. Two authorities reach that transition and only one of
|
|
5
|
+
// them is rank-shaped (ADR 0297):
|
|
6
|
+
//
|
|
7
|
+
// • `task.abandon` (floor metic) — retire ANY task. Triage authority; the
|
|
8
|
+
// inverse of /promote, re-ranked to match it (ADR 0152).
|
|
9
|
+
// • `task.abandon.own` (floor xenos) — retire a task you CREATED, while it is
|
|
10
|
+
// still backlog/ready/blocked. The owner's 2026-07-25 request: "the creator
|
|
11
|
+
// of that task should be able to abandon that task directly inside of the
|
|
12
|
+
// UI." A predicate over tasks.created_by, which no rank floor can express —
|
|
13
|
+
// the ADR 0151 ownership axis.
|
|
14
|
+
//
|
|
15
|
+
// The second one is a rule about a ROW, so it cannot be a middleware and it must
|
|
16
|
+
// not be re-derived at each call site. Carving it out follows the
|
|
17
|
+
// goal-route-authz.js / shouldRefuseSessionClaim precedent: PURE and exported, so
|
|
18
|
+
// every branch is pinned by a DB-free unit test instead of only through a live
|
|
19
|
+
// route, and so the hall's display hint and the server's decision are provably
|
|
20
|
+
// reading the same rule.
|
|
21
|
+
//
|
|
22
|
+
// PURE — no DB, no clock, no I/O. The caller resolves the RANK fact before its
|
|
23
|
+
// transaction opens (request-scoped, uncached, ADR 0016) and supplies the ROW
|
|
24
|
+
// fact from inside its FOR UPDATE lock. Splitting them is what makes the decision
|
|
25
|
+
// both fresh and race-free: reading created_by outside the lock would be a TOCTOU
|
|
26
|
+
// window, and resolving permissions inside it would hold a row lock across a
|
|
27
|
+
// second pool acquire.
|
|
28
|
+
//
|
|
29
|
+
// MODULE BOUNDARY: reaches core only through the published doorway.
|
|
30
|
+
|
|
31
|
+
'use strict';
|
|
32
|
+
|
|
33
|
+
// The statuses a CREATOR may retire their own task in. Deliberately the
|
|
34
|
+
// not-yet-started, not-yet-judged window:
|
|
35
|
+
// • backlog / ready / blocked — the task is still a plan. Retiring it is the
|
|
36
|
+
// author withdrawing their own proposal, which is what the owner asked for.
|
|
37
|
+
// • active — excluded here AND by the active-claim guard in abandonTask: a
|
|
38
|
+
// live claim means a session still owns the work.
|
|
39
|
+
// • completed / confirmed — excluded ON PURPOSE. Past the grader a retire is a
|
|
40
|
+
// correction to the ledger, not an author changing their mind, so it stays
|
|
41
|
+
// with the cross-owner `task.abandon` authority.
|
|
42
|
+
// • shipped / abandoned — never reached from here; abandonTask refuses shipped
|
|
43
|
+
// outright and treats abandoned as an idempotent no-op.
|
|
44
|
+
const CREATOR_ABANDONABLE = Object.freeze(['backlog', 'ready', 'blocked']);
|
|
45
|
+
|
|
46
|
+
// The permission pair this decision is spelled in, exported so the route, the
|
|
47
|
+
// tests and the denial text all name the same two atoms.
|
|
48
|
+
const ABANDON_ANY_KEY = 'task.abandon';
|
|
49
|
+
const ABANDON_OWN_KEY = 'task.abandon.own';
|
|
50
|
+
|
|
51
|
+
// Does this builder hold enough to be worth locking the row for? The cheap
|
|
52
|
+
// pre-transaction half: `false` here is a flat 403 and no transaction is opened.
|
|
53
|
+
// Holding only the own-scoped key is NOT an admit — it is permission to be ASKED
|
|
54
|
+
// the ownership question, which is the whole point of the `.own` axis.
|
|
55
|
+
function mayAttemptAbandon({ holdsAny = false, holdsOwn = false } = {}) {
|
|
56
|
+
return holdsAny === true || holdsOwn === true;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// The row-scoped verdict. ORDERED and FAIL-CLOSED — every path that is not an
|
|
60
|
+
// explicit admit returns { ok: false } with a reason the caller can map to a
|
|
61
|
+
// refusal, and the default at the bottom denies rather than falls through.
|
|
62
|
+
//
|
|
63
|
+
// `sameOwner` is INJECTED rather than hand-rolled: Postgres hands bigints back as
|
|
64
|
+
// strings and JS code carries them as numbers, so 7 and '7' must compare equal
|
|
65
|
+
// while null, '', objects and NaN must match nothing. That normalization already
|
|
66
|
+
// exists once, in the government resolver, and duplicating it here is exactly how
|
|
67
|
+
// an ownership comparison quietly widens. A caller that supplies no comparator
|
|
68
|
+
// gets a strict scalar fallback with the same posture — never a loose `==`.
|
|
69
|
+
function strictSameOwner(a, b) {
|
|
70
|
+
const norm = (v) => {
|
|
71
|
+
if (typeof v === 'number') return Number.isFinite(v) ? String(v) : null;
|
|
72
|
+
if (typeof v === 'bigint') return String(v);
|
|
73
|
+
if (typeof v === 'string') return v === '' ? null : v;
|
|
74
|
+
return null;
|
|
75
|
+
};
|
|
76
|
+
const left = norm(a);
|
|
77
|
+
const right = norm(b);
|
|
78
|
+
return left !== null && right !== null && left === right;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function abandonVerdict({
|
|
82
|
+
holdsAny = false,
|
|
83
|
+
holdsOwn = false,
|
|
84
|
+
builderId = null,
|
|
85
|
+
createdBy = null,
|
|
86
|
+
status = null,
|
|
87
|
+
sameOwner = strictSameOwner,
|
|
88
|
+
} = {}) {
|
|
89
|
+
// The cross-owner branch: reaches every creator's row, so no predicate applies.
|
|
90
|
+
// Checked FIRST so a Metic+ is never refused by the creator-only window below —
|
|
91
|
+
// their authority is wider, not narrower.
|
|
92
|
+
if (holdsAny === true) return { ok: true, via: ABANDON_ANY_KEY };
|
|
93
|
+
|
|
94
|
+
if (holdsOwn !== true) {
|
|
95
|
+
return {
|
|
96
|
+
ok: false,
|
|
97
|
+
reason: 'missing_permission',
|
|
98
|
+
message: `missing permission: ${ABANDON_ANY_KEY} (floor: metic), or ${ABANDON_OWN_KEY} on a task you created`,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// An own-scoped key is never sufficient on its own — it is half a question, and
|
|
103
|
+
// this is the other half. A null created_by (every task filed before core_227,
|
|
104
|
+
// and every system vector) matches nobody, by construction.
|
|
105
|
+
const compare = typeof sameOwner === 'function' ? sameOwner : strictSameOwner;
|
|
106
|
+
if (!compare(createdBy, builderId)) {
|
|
107
|
+
return {
|
|
108
|
+
ok: false,
|
|
109
|
+
reason: 'not_task_creator',
|
|
110
|
+
message: `you did not create this task — retiring someone else's task needs ${ABANDON_ANY_KEY} (floor: metic)`,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (!CREATOR_ABANDONABLE.includes(String(status))) {
|
|
115
|
+
return {
|
|
116
|
+
ok: false,
|
|
117
|
+
reason: 'creator_status_not_abandonable',
|
|
118
|
+
message: `this task is '${status}' — a creator can retire their own task only while it is ${CREATOR_ABANDONABLE.join(', ')}; past that it needs ${ABANDON_ANY_KEY} (floor: metic)`,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return { ok: true, via: ABANDON_OWN_KEY };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
module.exports = {
|
|
126
|
+
CREATOR_ABANDONABLE,
|
|
127
|
+
ABANDON_ANY_KEY,
|
|
128
|
+
ABANDON_OWN_KEY,
|
|
129
|
+
mayAttemptAbandon,
|
|
130
|
+
abandonVerdict,
|
|
131
|
+
strictSameOwner,
|
|
132
|
+
};
|
|
@@ -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
|
|
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
|
|
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
|
|
471
|
-
|
|
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 =
|
|
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
|
|
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
|
|
@@ -953,9 +953,39 @@ async function confirmTask({ taskId, builderId }, deps = {}) {
|
|
|
953
953
|
// still owns the work; release it first so the abandon
|
|
954
954
|
// can never race a ship in flight.
|
|
955
955
|
// Any other status (backlog/ready/blocked/completed/confirmed) abandons. The
|
|
956
|
-
// route
|
|
957
|
-
// + its reason are captured in audit_log by the writing
|
|
958
|
-
// append-only ledger records who retired what. restoreTask
|
|
956
|
+
// route carries the authority gate (ADR 0152, and the creator widening of task
|
|
957
|
+
// 1002443), and the abandon + its reason are captured in audit_log by the writing
|
|
958
|
+
// middleware, so the append-only ledger records who retired what. restoreTask
|
|
959
|
+
// (below) is the undo.
|
|
960
|
+
//
|
|
961
|
+
// `authorize` (task 1002443) — the OPTIONAL row-scoped veto, and the reason this
|
|
962
|
+
// function grew a parameter rather than the route growing a pre-flight read.
|
|
963
|
+
//
|
|
964
|
+
// The owner's 2026-07-25 request widened this transition from "a Metic may retire
|
|
965
|
+
// any task" to "...or a builder may retire a task THEY created". That second half
|
|
966
|
+
// is a predicate over a COLUMN OF THIS ROW (tasks.created_by), so a check made
|
|
967
|
+
// before the transaction would be a TOCTOU window: the row can be re-attributed,
|
|
968
|
+
// claimed, or shipped between the read and the write. The callback therefore runs
|
|
969
|
+
// INSIDE the transaction, against the row this function has already locked FOR
|
|
970
|
+
// UPDATE, and its verdict and the UPDATE commit or roll back together.
|
|
971
|
+
//
|
|
972
|
+
// It is deliberately PURE and DB-FREE. The authority question splits in two (the
|
|
973
|
+
// ADR 0151 ownership axis): the RANK fact — does this builder hold the key — is
|
|
974
|
+
// request-scoped and is resolved by the caller BEFORE the transaction opens; only
|
|
975
|
+
// the OWNER fact needs the lock. Resolving permissions in here would acquire a
|
|
976
|
+
// second pool connection while holding a row lock, which under a saturated pool is
|
|
977
|
+
// a deadlock. So the caller closes over its already-resolved grants and hands in a
|
|
978
|
+
// predicate; this function supplies the row.
|
|
979
|
+
//
|
|
980
|
+
// Contract: called with { taskId, createdBy, status }, returns { ok: true } or
|
|
981
|
+
// { ok: false, reason, message }. FAIL-CLOSED — anything that is not an explicit
|
|
982
|
+
// { ok: true } is a refusal, so a caller that returns undefined (or throws) denies
|
|
983
|
+
// rather than admits. Omitting `authorize` entirely preserves the pre-1002443
|
|
984
|
+
// behaviour for every caller whose route already gated the transition.
|
|
985
|
+
//
|
|
986
|
+
// It runs BEFORE the already-abandoned idempotent return, deliberately: a caller
|
|
987
|
+
// with no authority over this row must not be able to read its state back as a
|
|
988
|
+
// 200, and an unauthorized retry is a refusal, not a no-op.
|
|
959
989
|
//
|
|
960
990
|
// NOTE on dependencies: abandoning a task that is listed in another task's
|
|
961
991
|
// dependencies[] does NOT auto-adjust those dependents — they will simply never
|
|
@@ -967,14 +997,16 @@ async function confirmTask({ taskId, builderId }, deps = {}) {
|
|
|
967
997
|
// so a regression test can drive the txn with a SQL-aware fakeClient, same as
|
|
968
998
|
// confirmTask / decideOverrideRequest. Production callers omit deps → module pool.
|
|
969
999
|
|
|
970
|
-
async function abandonTask({ taskId, reason }, deps = {}) {
|
|
1000
|
+
async function abandonTask({ taskId, reason, authorize = null }, deps = {}) {
|
|
971
1001
|
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
972
1002
|
const client = await activePool.connect();
|
|
973
1003
|
try {
|
|
974
1004
|
await client.query('BEGIN');
|
|
975
1005
|
|
|
1006
|
+
// created_by joins the locked SELECT (task 1002443) so the ownership predicate
|
|
1007
|
+
// below reads the SAME row version the UPDATE will write.
|
|
976
1008
|
const { rows: taskRows } = await client.query(
|
|
977
|
-
`SELECT id, title, status FROM tasks WHERE id = $1 FOR UPDATE`,
|
|
1009
|
+
`SELECT id, title, status, created_by FROM tasks WHERE id = $1 FOR UPDATE`,
|
|
978
1010
|
[taskId]
|
|
979
1011
|
);
|
|
980
1012
|
if (taskRows.length === 0) {
|
|
@@ -982,6 +1014,28 @@ async function abandonTask({ taskId, reason }, deps = {}) {
|
|
|
982
1014
|
}
|
|
983
1015
|
const task = taskRows[0];
|
|
984
1016
|
|
|
1017
|
+
// The row-scoped veto, under the lock and ahead of every status branch below —
|
|
1018
|
+
// see the header for why both of those orderings are load-bearing. Fail-closed:
|
|
1019
|
+
// only an explicit { ok: true } admits.
|
|
1020
|
+
if (typeof authorize === 'function') {
|
|
1021
|
+
let verdict;
|
|
1022
|
+
try {
|
|
1023
|
+
verdict = await authorize({ taskId, createdBy: task.created_by, status: task.status });
|
|
1024
|
+
} catch (authErr) {
|
|
1025
|
+
throw Object.assign(new Error('abandon authorization could not be established'), {
|
|
1026
|
+
code: 'ABANDON_FORBIDDEN',
|
|
1027
|
+
reason: 'authorize_threw',
|
|
1028
|
+
cause: authErr,
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
if (!verdict || verdict.ok !== true) {
|
|
1032
|
+
throw Object.assign(new Error((verdict && verdict.message) || 'not authorized to abandon this task'), {
|
|
1033
|
+
code: 'ABANDON_FORBIDDEN',
|
|
1034
|
+
reason: (verdict && verdict.reason) || 'not_authorized',
|
|
1035
|
+
});
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
|
|
985
1039
|
if (task.status === 'abandoned') {
|
|
986
1040
|
await client.query('COMMIT');
|
|
987
1041
|
return { taskId, taskTitle: task.title, taskStatus: 'abandoned', previousStatus: 'abandoned', alreadyAbandoned: true };
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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]
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// modules/lifecycle/routes/abandon-handler.js — the HTTP half of
|
|
2
|
+
// POST /tasks/:id/abandon: resolve the caller's authority, compose it with the
|
|
3
|
+
// row-scoped decision, and map every refusal to its status code.
|
|
4
|
+
//
|
|
5
|
+
// WHY IT IS ITS OWN FILE. Two reasons, and the second is the real one:
|
|
6
|
+
//
|
|
7
|
+
// 1. tasks.js sits against the 1,500-line ratchet, and this handler grew when
|
|
8
|
+
// the route gained a second authority (task 1002443 / ADR 0297) — the same
|
|
9
|
+
// pressure that carved goal-route-authz.js out of goals.js.
|
|
10
|
+
// 2. COMPOSITION is where this route can go wrong, and composition was the one
|
|
11
|
+
// layer no test could reach. `abandon-authz.js` pins the decision and
|
|
12
|
+
// `abandonTask` pins the transaction seam, both in isolation; a mistake in
|
|
13
|
+
// how the route wires them together — mapping a refusal to the wrong code,
|
|
14
|
+
// admitting when the government port is absent, reporting a denial that
|
|
15
|
+
// names the wrong atom — would have passed both. A plain exported function
|
|
16
|
+
// taking `{ req, res, deps }` is callable from a test with fakes, so that
|
|
17
|
+
// layer is now pinned too (tests/abandon_route.mjs).
|
|
18
|
+
//
|
|
19
|
+
// Everything it needs is INJECTED rather than imported: the router hands in its
|
|
20
|
+
// own `db`, `governance` port, logger and validators. That is what makes the
|
|
21
|
+
// handler testable without a live Express app or a DB, and it keeps the module
|
|
22
|
+
// boundary intact — this file requires no core internal.
|
|
23
|
+
|
|
24
|
+
'use strict';
|
|
25
|
+
|
|
26
|
+
const abandonAuthz = require('../abandon-authz');
|
|
27
|
+
|
|
28
|
+
// The atoms a denial should name. Both are a genuine way in, so a 403 that listed
|
|
29
|
+
// only the metic-floor key would send a creator after the wrong permission.
|
|
30
|
+
function requiredAtoms(governance) {
|
|
31
|
+
// The cross-owner escalation path is a DECLARED property of the own-scoped key
|
|
32
|
+
// (task 1003195), read from the catalog rather than hardcoded, so a change to
|
|
33
|
+
// that declaration reaches this route instead of silently disagreeing with it.
|
|
34
|
+
// Falls back to the named atom only when the port cannot answer — a catalog that
|
|
35
|
+
// has moved on must never be able to WIDEN this gate.
|
|
36
|
+
const declared = (governance && typeof governance.crossOwnerKeysFor === 'function')
|
|
37
|
+
? governance.crossOwnerKeysFor(abandonAuthz.ABANDON_OWN_KEY)
|
|
38
|
+
: null;
|
|
39
|
+
const crossOwner = (Array.isArray(declared) && declared.length > 0)
|
|
40
|
+
? declared
|
|
41
|
+
: [abandonAuthz.ABANDON_ANY_KEY];
|
|
42
|
+
return { crossOwner, required: [...crossOwner, abandonAuthz.ABANDON_OWN_KEY] };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Floors resolve through the port at deny time, never hardcoded prose — a floor
|
|
46
|
+
// that moves would otherwise leave a lie in the denial.
|
|
47
|
+
function floorsFor(governance, keys) {
|
|
48
|
+
const out = {};
|
|
49
|
+
if (!governance || typeof governance.permissionFloor !== 'function') return out;
|
|
50
|
+
for (const k of keys) {
|
|
51
|
+
try {
|
|
52
|
+
const f = governance.permissionFloor(k);
|
|
53
|
+
if (f) out[k] = f;
|
|
54
|
+
} catch (_) { /* unknown atom — omit rather than guess */ }
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// deps: { db, seams, log, validateOrRespond, parseId }
|
|
60
|
+
function makeAbandonHandler(deps) {
|
|
61
|
+
const { db, seams, log, validateOrRespond, parseId } = deps;
|
|
62
|
+
|
|
63
|
+
return async function abandonHandler(req, res) {
|
|
64
|
+
if (validateOrRespond(req, res, {
|
|
65
|
+
reason: { type: 'string' }, // free-text retire reason (sliced to 2000 below)
|
|
66
|
+
})) return;
|
|
67
|
+
const id = parseId(req, res, { code: 'bad_task_id', positiveInt: true });
|
|
68
|
+
if (id === null) return;
|
|
69
|
+
const reason = typeof req.body?.reason === 'string' ? req.body.reason.slice(0, 2000) : '';
|
|
70
|
+
|
|
71
|
+
// FAIL-CLOSED on a missing government port, exactly as auth.requirePermission
|
|
72
|
+
// does: `government` is a core-domain, default:true module, so its absence means
|
|
73
|
+
// authority cannot be ESTABLISHED — and admitting there would make disabling a
|
|
74
|
+
// module a privilege escalation, the inversion ADR 0016 exists to prevent.
|
|
75
|
+
const governance = seams.resolveOptional('government');
|
|
76
|
+
const { crossOwner, required } = requiredAtoms(governance);
|
|
77
|
+
const denyUnestablished = () => res.fail('permission_forbidden', {
|
|
78
|
+
status: 403,
|
|
79
|
+
message: 'authority could not be established — denied fail-closed (ADR 0016); this route requires '
|
|
80
|
+
+ `${crossOwner.join(', ')}, or ${abandonAuthz.ABANDON_OWN_KEY} on a task you created`,
|
|
81
|
+
details: { required, held: null, floors: null },
|
|
82
|
+
});
|
|
83
|
+
if (!governance || typeof governance.resolveBuilderPermissions !== 'function'
|
|
84
|
+
|| typeof governance.sameOwner !== 'function') {
|
|
85
|
+
return denyUnestablished();
|
|
86
|
+
}
|
|
87
|
+
let held;
|
|
88
|
+
try {
|
|
89
|
+
held = new Set(await governance.resolveBuilderPermissions(req.builder.id));
|
|
90
|
+
} catch (permErr) {
|
|
91
|
+
log.error('[gds] POST /tasks/:id/abandon — permission resolve', permErr);
|
|
92
|
+
return denyUnestablished();
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const holdsAny = crossOwner.some((k) => held.has(k));
|
|
96
|
+
// Necessary but NEVER sufficient: holding the own-scoped key is permission to be
|
|
97
|
+
// ASKED the ownership question, answered against the locked row below.
|
|
98
|
+
const holdsOwn = held.has(abandonAuthz.ABANDON_OWN_KEY);
|
|
99
|
+
const denyPermission = (message, reasonCode) => res.fail('permission_forbidden', {
|
|
100
|
+
status: 403,
|
|
101
|
+
message,
|
|
102
|
+
details: {
|
|
103
|
+
required,
|
|
104
|
+
held: required.filter((k) => held.has(k)),
|
|
105
|
+
floors: floorsFor(governance, required),
|
|
106
|
+
...(reasonCode ? { reason: reasonCode } : {}),
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// The cheap half — refuse without opening a transaction at all.
|
|
111
|
+
if (!abandonAuthz.mayAttemptAbandon({ holdsAny, holdsOwn })) {
|
|
112
|
+
return denyPermission(abandonAuthz.abandonVerdict({ holdsAny, holdsOwn }).message, null);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
const result = await db.abandonTask({
|
|
117
|
+
taskId: id,
|
|
118
|
+
reason,
|
|
119
|
+
// PURE and DB-free — closes over the grants resolved above and is handed the
|
|
120
|
+
// row under abandonTask's own lock. See that function's header, and
|
|
121
|
+
// abandon-authz.js for the decision itself.
|
|
122
|
+
authorize: ({ createdBy, status }) => abandonAuthz.abandonVerdict({
|
|
123
|
+
holdsAny,
|
|
124
|
+
holdsOwn,
|
|
125
|
+
builderId: req.builder.id,
|
|
126
|
+
createdBy,
|
|
127
|
+
status,
|
|
128
|
+
// The government resolver's own id normalization — never re-implemented
|
|
129
|
+
// here, because a duplicated owner comparison is how one quietly widens.
|
|
130
|
+
sameOwner: governance.sameOwner,
|
|
131
|
+
}),
|
|
132
|
+
});
|
|
133
|
+
res.json({ ok: true, ...result });
|
|
134
|
+
} catch (err) {
|
|
135
|
+
if (err.code === 'ABANDON_FORBIDDEN') {
|
|
136
|
+
return denyPermission(err.message, err.reason || null);
|
|
137
|
+
}
|
|
138
|
+
if (err.code === 'TASK_NOT_FOUND') return res.fail('task_not_found', 404);
|
|
139
|
+
if (err.code === 'CANNOT_ABANDON_SHIPPED') {
|
|
140
|
+
return res.fail('cannot_abandon_shipped', { status: 409, message: 'shipped tasks are immutable history' });
|
|
141
|
+
}
|
|
142
|
+
if (err.code === 'TASK_HAS_ACTIVE_CLAIM') {
|
|
143
|
+
return res.fail('task_has_active_claim', { status: 409, message: 'release the active claim before abandoning' });
|
|
144
|
+
}
|
|
145
|
+
log.error('[gds] POST /tasks/:id/abandon', err);
|
|
146
|
+
res.fail('abandon_failed', { status: 500, message: 'internal error' });
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
module.exports = { makeAbandonHandler, requiredAtoms, floorsFor };
|