@bongos/core 1.19.813 → 1.19.815
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 +74 -39
- 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/agents/lib/validate.js +1 -1
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +1 -1
- package/modules/discord/discord-channels.js +80 -5
- package/modules/discord/routes/discord.js +14 -2
- 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/ideas/inbox.js +29 -1
- package/modules/lifecycle/abandon-authz.js +132 -0
- package/modules/lifecycle/cascade.js +134 -19
- package/modules/lifecycle/db-ship.js +59 -5
- package/modules/lifecycle/routes/abandon-handler.js +151 -0
- package/modules/lifecycle/routes/lifecycle.js +18 -0
- 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/agents_validate.mjs +33 -2
- package/tests/discord_channels.mjs +166 -0
- package/tests/government_ownership_scope.mjs +13 -7
- package/tests/idea_cascade.mjs +211 -0
- package/tests/task_goal_required.mjs +15 -3
|
@@ -164,7 +164,16 @@ const PERMISSIONS = [
|
|
|
164
164
|
{ key: 'task.edit', system: false, floor: 'metic', guards: 'PATCH /tasks/:id' },
|
|
165
165
|
{ key: 'task.promote', system: false, floor: 'metic', guards: 'POST /tasks/:id/promote' },
|
|
166
166
|
{ key: 'task.demote', system: false, floor: 'metic', guards: 'POST /tasks/:id/demote' },
|
|
167
|
-
|
|
167
|
+
// task 1002443: now the CROSS-OWNER half of a pair. Floor and guards are
|
|
168
|
+
// unchanged — what is new is that the key DECLARES its ownership axis, so
|
|
169
|
+
// `task.abandon.own` (in the ownership block below) can name it as the escalation
|
|
170
|
+
// path a creator's own-scoped grant is measured against.
|
|
171
|
+
//
|
|
172
|
+
// Verb `act`, not `manage`: retiring a task is ONE reach into the row, and
|
|
173
|
+
// `manage` is the full-control verb — declaring it here would silently dominate
|
|
174
|
+
// every future narrower `task.*.own` key, which is exactly the verb-blind
|
|
175
|
+
// escalation task 1003195 closed.
|
|
176
|
+
{ key: 'task.abandon', system: false, floor: 'metic', scope: 'any', resource: 'task', verb: 'act', guards: 'POST /tasks/:id/abandon — scope removal on ANY task; undo = POST /tasks/:id/promote (ADR 0152, was archon). The cross-owner counterpart of task.abandon.own (task 1002443)' },
|
|
168
177
|
{ key: 'task.merge', system: false, floor: 'metic', guards: 'POST /tasks/:id/merge — mark a duplicate task merged into another (task 1001478, the idea.triage merge analogue)' },
|
|
169
178
|
{ key: 'task.vote', system: false, floor: 'metic', guards: 'POST /tasks/:id/vote' },
|
|
170
179
|
{ key: 'task.dependency.manage', system: false, floor: 'metic', guards: 'POST|PUT|DELETE /tasks/:id/dependencies (same-tier)' },
|
|
@@ -317,6 +326,24 @@ const PERMISSIONS = [
|
|
|
317
326
|
// marker here. Exactly why scope is declared data: the suffix rule only runs the
|
|
318
327
|
// safe direction ('.own' ⇒ scope 'own'), never the converse.
|
|
319
328
|
{ key: 'me.wandering.set', system: false, floor: 'xenos', scope: 'own', resource: 'builder_prefs', verb: 'set', counterpart: [], guards: 'PATCH /me/wandering (clamped by maxLevelForRank) — ownership-scoped' },
|
|
329
|
+
// task 1002443 — the owner's 2026-07-25 request: "the creator of that task should
|
|
330
|
+
// be able to abandon that task directly inside of the UI". Retiring work you
|
|
331
|
+
// filed yourself is not a triage authority, so it does not want the metic floor
|
|
332
|
+
// that `task.abandon` carries; it wants the OWNERSHIP axis, which is precisely the
|
|
333
|
+
// narrower rule a rank cannot express (`whose row is this`).
|
|
334
|
+
//
|
|
335
|
+
// counterpart is REAL here, unlike task.act.own/memory.manage.own: `task.abandon`
|
|
336
|
+
// is the same `act` verb over the same `task` resource across every creator, so it
|
|
337
|
+
// genuinely subsumes retiring your own — a Metic keeps the power they have today
|
|
338
|
+
// without needing to be the creator, and the route admits on either branch through
|
|
339
|
+
// resolver.authorizeOwnedOrAny.
|
|
340
|
+
//
|
|
341
|
+
// The floor is the LOOSER half of the gate, never the whole of it (the
|
|
342
|
+
// `goal.manage.own` posture): holding this key authorizes nothing on its own, and
|
|
343
|
+
// the STATUS envelope — creator-abandon only while the task is backlog/ready/
|
|
344
|
+
// blocked, never completed/confirmed/shipped — is enforced in abandonTask under
|
|
345
|
+
// the same FOR UPDATE lock that performs the write, not here.
|
|
346
|
+
{ key: 'task.abandon.own', system: false, floor: 'xenos', scope: 'own', resource: 'task', verb: 'act', counterpart: ['task.abandon'], guards: 'POST /tasks/:id/abandon on a task you CREATED (tasks.created_by), while it is backlog/ready/blocked and unclaimed — requireBuilder + in-handler authorizeOwnedOrAny; ownership-scoped (task 1002443)' },
|
|
320
347
|
{ key: 'cost.log', system: false, floor: 'xenos', guards: 'POST /cost' },
|
|
321
348
|
{ key: 'learning.create', system: false, floor: 'xenos', guards: 'POST /learnings' },
|
|
322
349
|
{ key: 'blocker.file', system: false, floor: 'xenos', guards: 'POST /blockers' },
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
-- government_015_task_abandon_own.sql — grant the new `task.abandon.own` atom to
|
|
2
|
+
-- every rank (task 1002443, ADR 0297).
|
|
3
|
+
--
|
|
4
|
+
-- WHAT IT IS. The owner's 2026-07-25 request: "the creator of that task should be
|
|
5
|
+
-- able to abandon that task directly inside of the UI." Retiring a task was a rank
|
|
6
|
+
-- power only (`task.abandon`, floor metic, governance_003), so a sub-Metic builder
|
|
7
|
+
-- could file a task and then could not withdraw their own proposal. The new key is
|
|
8
|
+
-- the ADR 0151 §3 OWNERSHIP axis, not a rank widening.
|
|
9
|
+
--
|
|
10
|
+
-- A BRAND-NEW key: nobody holds it yet, so every rung the floor implies is granted
|
|
11
|
+
-- here. RANK_SEED is a cumulative superset and this key floors at `xenos`, the
|
|
12
|
+
-- bottom of the ladder — so ALL FOUR rank-roles get a block. The drift guard
|
|
13
|
+
-- (tests/government_seed.mjs) compares the union of the run-once seed plus every
|
|
14
|
+
-- later grant migration against the floor-derived seed, per rank; minting the
|
|
15
|
+
-- catalog row WITHOUT this migration is exactly what that guard catches, and it did.
|
|
16
|
+
--
|
|
17
|
+
-- GRANTING IT TO EVERY RANK GIVES NOBODY THE POWER TO RETIRE ANYTHING ON ITS OWN.
|
|
18
|
+
-- That is the whole point of an ownership-scoped key and the reason a floor this low
|
|
19
|
+
-- is safe: holding it authorizes nothing until a handler supplies the concrete row's
|
|
20
|
+
-- owner. `requirePermission` REFUSES an own-scoped key outright (auth.js, R100) so it
|
|
21
|
+
-- can never gate a route unconditionally, and `resolveBuilderPermissions` returning
|
|
22
|
+
-- it is only ever half a question. The other half — `tasks.created_by` equals the
|
|
23
|
+
-- caller, and the task is still backlog/ready/blocked — is answered inside
|
|
24
|
+
-- `abandonTask`'s FOR UPDATE transaction (modules/lifecycle/abandon-authz.js).
|
|
25
|
+
--
|
|
26
|
+
-- `task.abandon` (floor metic) is UNCHANGED and is not re-granted here: governance_003
|
|
27
|
+
-- already granted it and migrations are run-once. It becomes this key's declared
|
|
28
|
+
-- cross-owner counterpart — same `act` verb, same `task` resource — so a Metic keeps
|
|
29
|
+
-- retiring any task without being its creator.
|
|
30
|
+
--
|
|
31
|
+
-- Additive only: one new permission key granted to four ranks; nothing is revoked.
|
|
32
|
+
-- Idempotent (ON CONFLICT DO NOTHING); forward-safe (INSERT only).
|
|
33
|
+
--
|
|
34
|
+
-- One block PER RANK, in the government_004/009/010/011 shape: the drift guard scans
|
|
35
|
+
-- for `SELECT '<rank>', unnest(ARRAY[…])` literally, so a combined
|
|
36
|
+
-- `unnest(ARRAY['xenos','thetes']), 'key'` form parses as zero grants and reds it.
|
|
37
|
+
|
|
38
|
+
BEGIN;
|
|
39
|
+
|
|
40
|
+
INSERT INTO government_rank_permissions (rank_key, permission_key)
|
|
41
|
+
SELECT 'xenos', unnest(ARRAY[
|
|
42
|
+
'task.abandon.own'
|
|
43
|
+
])
|
|
44
|
+
ON CONFLICT (rank_key, permission_key) DO NOTHING;
|
|
45
|
+
|
|
46
|
+
INSERT INTO government_rank_permissions (rank_key, permission_key)
|
|
47
|
+
SELECT 'thetes', unnest(ARRAY[
|
|
48
|
+
'task.abandon.own'
|
|
49
|
+
])
|
|
50
|
+
ON CONFLICT (rank_key, permission_key) DO NOTHING;
|
|
51
|
+
|
|
52
|
+
INSERT INTO government_rank_permissions (rank_key, permission_key)
|
|
53
|
+
SELECT 'metic', unnest(ARRAY[
|
|
54
|
+
'task.abandon.own'
|
|
55
|
+
])
|
|
56
|
+
ON CONFLICT (rank_key, permission_key) DO NOTHING;
|
|
57
|
+
|
|
58
|
+
INSERT INTO government_rank_permissions (rank_key, permission_key)
|
|
59
|
+
SELECT 'archon', unnest(ARRAY[
|
|
60
|
+
'task.abandon.own'
|
|
61
|
+
])
|
|
62
|
+
ON CONFLICT (rank_key, permission_key) DO NOTHING;
|
|
63
|
+
|
|
64
|
+
COMMIT;
|
|
@@ -509,6 +509,18 @@
|
|
|
509
509
|
// should carry these permissions is a governance change that needs its own
|
|
510
510
|
// ADR, so this surfaces controls only for builders who already hold the atoms.
|
|
511
511
|
const canShape = isSupervisor;
|
|
512
|
+
// task 1002443 — the CREATOR axis, which is not a rank and so cannot live in
|
|
513
|
+
// canShape. `task.abandon.own` (floor xenos, ADR 0297) lets a builder retire a
|
|
514
|
+
// task THEY filed while it is still backlog/ready/blocked. Same display-hint
|
|
515
|
+
// posture as canShape: the server re-decides this under a row lock, and this
|
|
516
|
+
// line only chooses whether to draw the control.
|
|
517
|
+
//
|
|
518
|
+
// created_by is null on every task filed before core_227 and on system vectors
|
|
519
|
+
// (seed, restock, the Discord bug path), so isCreator is false for them and the
|
|
520
|
+
// control simply does not appear — never a button that would 403.
|
|
521
|
+
const CREATOR_RETIRABLE = ['backlog', 'ready', 'blocked'];
|
|
522
|
+
const isCreator = !!(viewer && t.created_by != null
|
|
523
|
+
&& String(t.created_by) === String(viewer.id));
|
|
512
524
|
// render() rebuilds #task-body wholesale, so a disclosure cannot keep its
|
|
513
525
|
// state in the DOM. This re-renders the SAME data without a refetch — the
|
|
514
526
|
// toggle is a view change, not a reason to ask the server anything.
|
|
@@ -713,7 +725,23 @@
|
|
|
713
725
|
// active claim must be released first — both are 409s the server will raise
|
|
714
726
|
// anyway, and both are knowable from this record. Offering the control and
|
|
715
727
|
// letting it fail would teach the reader that the page guesses.
|
|
728
|
+
//
|
|
729
|
+
// task 1002443 — the control now has TWO audiences, and they are offered
|
|
730
|
+
// different amounts of it. A Metic+ keeps every branch below, over any task. A
|
|
731
|
+
// CREATOR who is not Metic+ gets it only on their own task and only while it is
|
|
732
|
+
// backlog/ready/blocked, because that is exactly what the server will admit:
|
|
733
|
+
// past the grader a retire is a ledger correction, not an author changing their
|
|
734
|
+
// mind. Outside that window the creator is told so in words, for the same reason
|
|
735
|
+
// the two refusals below are pre-answered — a control that cannot work teaches
|
|
736
|
+
// the reader that the page guesses.
|
|
716
737
|
const claimIsLive = !!(lastClaim && !lastClaim.released_at);
|
|
738
|
+
// The creator's narrower window. This is a DISPLAY MIRROR of the server's own
|
|
739
|
+
// CREATOR_ABANDONABLE (modules/lifecycle/abandon-authz.js) — a browser file
|
|
740
|
+
// cannot require it, so the list is restated here and the server re-decides
|
|
741
|
+
// every call under a row lock. If that list moves, this one moves with it.
|
|
742
|
+
const creatorMayRetire = isCreator && !claimIsLive
|
|
743
|
+
&& CREATOR_RETIRABLE.indexOf(t.status) !== -1;
|
|
744
|
+
const mayRetire = canShape || creatorMayRetire;
|
|
717
745
|
let retire = '';
|
|
718
746
|
if (canShape && t.status === 'shipped') {
|
|
719
747
|
retire = '<p class="rec-actnote">A shipped task cannot be retired — it is immutable history now.</p>';
|
|
@@ -721,7 +749,28 @@
|
|
|
721
749
|
retire = '<p class="rec-actnote">Someone is holding a claim on this task. It has to be released before the task can be retired.</p>';
|
|
722
750
|
} else if (canShape && t.status === 'abandoned') {
|
|
723
751
|
retire = '<p class="rec-actnote">This task is already retired. Promote restores it to the backlog.</p>';
|
|
724
|
-
|
|
752
|
+
// THE CREATOR'S REFUSALS ARE ORDERED BY WHAT WOULD ACTUALLY UNBLOCK THEM, which
|
|
753
|
+
// is not the same order as the supervisor's above. Each branch names the ONE
|
|
754
|
+
// thing standing in the way, and must not promise a remedy that would still be
|
|
755
|
+
// refused — the failure this file's own rule warns about, applied to the words
|
|
756
|
+
// rather than to a button.
|
|
757
|
+
//
|
|
758
|
+
// shipped → immutable for EVERYONE, so it must not read as 'ask a Metic'.
|
|
759
|
+
// abandoned → already done.
|
|
760
|
+
// a live claim, on a task that is otherwise retirable (or 'active', which
|
|
761
|
+
// releases back to 'ready') → releasing genuinely unblocks it.
|
|
762
|
+
// anything else outside the window (completed/confirmed) → authority is the
|
|
763
|
+
// blocker, and a Metic really can act there.
|
|
764
|
+
} else if (!canShape && isCreator && t.status === 'shipped') {
|
|
765
|
+
retire = '<p class="rec-actnote">You created this task, but it has shipped. Shipped work is immutable history — nobody can retire it, whatever their rank.</p>';
|
|
766
|
+
} else if (!canShape && isCreator && t.status === 'abandoned') {
|
|
767
|
+
retire = '<p class="rec-actnote">You created this task and it is already retired.</p>';
|
|
768
|
+
} else if (!canShape && isCreator && claimIsLive
|
|
769
|
+
&& (t.status === 'active' || CREATOR_RETIRABLE.indexOf(t.status) !== -1)) {
|
|
770
|
+
retire = '<p class="rec-actnote">You created this task, but someone is holding a claim on it. Once that is released you can retire it yourself.</p>';
|
|
771
|
+
} else if (!canShape && isCreator && !creatorMayRetire) {
|
|
772
|
+
retire = '<p class="rec-actnote">You created this task, but it has already moved past planning. Retiring it now is a change to the record, so it needs a Metic.</p>';
|
|
773
|
+
} else if (mayRetire) {
|
|
725
774
|
retire = abandonOpen
|
|
726
775
|
? `
|
|
727
776
|
<div class="td-form" id="abandon-form">
|
package/modules/ideas/inbox.js
CHANGED
|
@@ -1198,7 +1198,35 @@ async function developSpark(ideaId, { developedBy, templateKey = DEFAULT_TEMPLAT
|
|
|
1198
1198
|
// null = "this row was not a developable spark when we looked". THREE ways to
|
|
1199
1199
|
// get here, and the caller re-reads to say which: it was never a spark,
|
|
1200
1200
|
// somebody else developed it first, or a triager closed the idea underneath us.
|
|
1201
|
-
|
|
1201
|
+
const graded = rows[0] ?? null;
|
|
1202
|
+
|
|
1203
|
+
// task 1003856: a Full Idea is the ideator's hand-off point, and the
|
|
1204
|
+
// cross-role cascade that turns it into an engineer's scoping request needs a
|
|
1205
|
+
// transition to hang on. Emitted only on a REAL transition — the UPDATE's
|
|
1206
|
+
// `AND grade = 'spark'` means a no-op re-grade returns null and fires nothing,
|
|
1207
|
+
// so the event cannot repeat for one idea.
|
|
1208
|
+
//
|
|
1209
|
+
// Wrapped and awaited-free by the same reasoning as every other kernel emit
|
|
1210
|
+
// here: a listener must never be able to fail the write that preceded it. The
|
|
1211
|
+
// cascade runner catches everything of its own accord as well.
|
|
1212
|
+
if (graded) {
|
|
1213
|
+
// The payload carries the WHOLE idea, not just its id. The listener lives in
|
|
1214
|
+
// the lifecycle module, which owns no idea table and has no read port for one
|
|
1215
|
+
// (ideas provides only `ideas.capture`) — so the read happens here, inside the
|
|
1216
|
+
// module that owns the row, rather than by widening another module's surface.
|
|
1217
|
+
try {
|
|
1218
|
+
const idea = await getIdea(ideaId);
|
|
1219
|
+
api.emit('idea.graded.full', {
|
|
1220
|
+
...(idea || {}),
|
|
1221
|
+
id: ideaId,
|
|
1222
|
+
idea_id: ideaId,
|
|
1223
|
+
grade: 'full',
|
|
1224
|
+
completeness_score: graded.completeness_score,
|
|
1225
|
+
developed_by: graded.developed_by,
|
|
1226
|
+
});
|
|
1227
|
+
} catch { /* a listener, or the read that feeds it, must never cost the ideator their grade */ }
|
|
1228
|
+
}
|
|
1229
|
+
return graded;
|
|
1202
1230
|
}
|
|
1203
1231
|
|
|
1204
1232
|
// The grade row on its own. null = no row, which reads as grade='quick'
|
|
@@ -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
|
+
};
|
|
@@ -168,6 +168,68 @@ const CASCADES = Object.freeze([
|
|
|
168
168
|
}),
|
|
169
169
|
]);
|
|
170
170
|
|
|
171
|
+
// THE TRIGGERS. A cascade reacts to a state transition; there are two, and this
|
|
172
|
+
// table is what lets one runner serve both without either pretending to be the
|
|
173
|
+
// other. `subject` names what the rule's when/title/body receive, and the pair is
|
|
174
|
+
// what the generated task's preamble says happened. Adding a third trigger is a
|
|
175
|
+
// row here plus a rule list — deliberately not a condition language.
|
|
176
|
+
const TRIGGERS = Object.freeze({
|
|
177
|
+
ship: Object.freeze({ subject: 'task', happened: (s) => `task ${s.id} shipped` }),
|
|
178
|
+
idea: Object.freeze({ subject: 'idea', happened: (s) => `idea ${s.id} was graded Full` }),
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// Cascades triggered by an IDEA reaching grade 'full' (the ideator's finished
|
|
182
|
+
// article), rather than by a ship. Kept as its own list rather than a `trigger`
|
|
183
|
+
// field on one list, so a rule cannot be evaluated against a subject shape it was
|
|
184
|
+
// never written for — an idea has no touches[], a task has no completeness score.
|
|
185
|
+
const IDEA_CASCADES = Object.freeze([
|
|
186
|
+
Object.freeze({
|
|
187
|
+
key: 'engineer-scoping-on-full-idea',
|
|
188
|
+
discipline: 'engineer',
|
|
189
|
+
kind: 'unclassified',
|
|
190
|
+
// A Full Idea is the ideator's hand-off point: the five questions are
|
|
191
|
+
// answered and the thing is described well enough to cost. What it still
|
|
192
|
+
// lacks is an engineer's read on what building it would actually mean, and
|
|
193
|
+
// that read is the scoping task this files.
|
|
194
|
+
//
|
|
195
|
+
// The anti-recursion guard is FIRST, as in the ship rule. An idea that a
|
|
196
|
+
// cascade itself filed cannot file another.
|
|
197
|
+
when: (idea) => !!idea
|
|
198
|
+
&& idea.grade === 'full'
|
|
199
|
+
&& idea.source !== CASCADE_SOURCE
|
|
200
|
+
&& String(idea.status || 'open') === 'open',
|
|
201
|
+
title: (idea) => `Scope the Full Idea: ${String(idea.title || '').trim() || `idea ${idea.id}`}`,
|
|
202
|
+
body: fullIdeaScopingBody,
|
|
203
|
+
}),
|
|
204
|
+
]);
|
|
205
|
+
|
|
206
|
+
// The body of the engineer's scoping request. Says what the ideator already
|
|
207
|
+
// settled so the engineer does not re-ask it, and what the scoping is FOR.
|
|
208
|
+
function fullIdeaScopingBody(idea) {
|
|
209
|
+
const score = idea.completeness_score != null ? ` (completeness ${idea.completeness_score})` : '';
|
|
210
|
+
const author = idea.captured_by ? ` by builder ${idea.captured_by}` : '';
|
|
211
|
+
// The idea's goal HINT, surfaced for a triager rather than acted on. It is
|
|
212
|
+
// advisory by its column's own definition, so it may inform a human decision and
|
|
213
|
+
// may not silently place this task.
|
|
214
|
+
const hint = idea.suggested_goal_id != null
|
|
215
|
+
? `\nThe idea carried a routing hint — it may belong to goal ${idea.suggested_goal_id}. That is advisory; place this task wherever triage decides.\n`
|
|
216
|
+
: '';
|
|
217
|
+
return [
|
|
218
|
+
`Idea ${idea.id} reached **Full**${score}${author} — the ideator's five questions are answered, so the WHAT is settled.`,
|
|
219
|
+
hint,
|
|
220
|
+
'',
|
|
221
|
+
'What this asks for is the engineer\'s read the idea cannot give itself:',
|
|
222
|
+
'',
|
|
223
|
+
'- What would building it actually touch, and roughly what size is it?',
|
|
224
|
+
'- What already exists that it should sit on rather than duplicate?',
|
|
225
|
+
'- What is genuinely unknown, and what would cheaply settle it?',
|
|
226
|
+
'',
|
|
227
|
+
'Answer by turning it into real tasks, or by saying plainly why it should not be built — a scoping that concludes "not worth it, because X" is a complete answer and a useful one.',
|
|
228
|
+
'',
|
|
229
|
+
`The idea's own body is the brief; read it at the hall rather than re-deriving it here.`,
|
|
230
|
+
].join('\n');
|
|
231
|
+
}
|
|
232
|
+
|
|
171
233
|
// The (source, source_ref) pair the generated task is keyed on. `source` is a
|
|
172
234
|
// constant so every cascade-generated row is findable as a class; the ref pins
|
|
173
235
|
// it to one rule and one originating ship.
|
|
@@ -218,7 +280,7 @@ function validateCascades(rules = CASCADES) {
|
|
|
218
280
|
// A rule whose `when` THROWS is treated as no-match and reported in `errors`,
|
|
219
281
|
// never propagated: one broken predicate must not cost the other rules their
|
|
220
282
|
// tasks, and it certainly must not reach the ship.
|
|
221
|
-
function cascadesFor(task, rules = CASCADES, ctx = {}) {
|
|
283
|
+
function cascadesFor(task, rules = CASCADES, ctx = {}, trigger = TRIGGERS.ship) {
|
|
222
284
|
const matched = [];
|
|
223
285
|
const errors = [];
|
|
224
286
|
if (!task || task.id == null) return { matched, errors: ['no task'] };
|
|
@@ -234,7 +296,7 @@ function cascadesFor(task, rules = CASCADES, ctx = {}) {
|
|
|
234
296
|
}
|
|
235
297
|
if (!hit) continue;
|
|
236
298
|
try {
|
|
237
|
-
matched.push({ rule, spec: cascadeTaskSpec(rule, task) });
|
|
299
|
+
matched.push({ rule, spec: cascadeTaskSpec(rule, task, trigger) });
|
|
238
300
|
} catch (e) {
|
|
239
301
|
errors.push(`cascade "${rule.key}": title/body threw (${e && e.message})`);
|
|
240
302
|
}
|
|
@@ -244,17 +306,26 @@ function cascadesFor(task, rules = CASCADES, ctx = {}) {
|
|
|
244
306
|
|
|
245
307
|
// The createTask payload for one matched rule. The attribution preamble is
|
|
246
308
|
// prepended here rather than left to each rule, so no rule can forget it.
|
|
247
|
-
function cascadeTaskSpec(rule, task) {
|
|
309
|
+
function cascadeTaskSpec(rule, task, trigger = TRIGGERS.ship) {
|
|
248
310
|
const body = String(rule.body(task) || '').trim();
|
|
249
311
|
const preamble =
|
|
250
|
-
`Filed automatically when
|
|
312
|
+
`Filed automatically when ${trigger.happened(task)} — the "${rule.key}" cascade `
|
|
251
313
|
+ `(modules/lifecycle/cascade.js). Nobody typed this; it is a REQUEST, filed at backlog, `
|
|
252
314
|
+ `and it goes through the board's ordinary triage. If the rule that made it is wrong, fix `
|
|
253
315
|
+ `the rule rather than only this task.`;
|
|
254
316
|
return {
|
|
255
|
-
|
|
317
|
+
// An idea carries a GUESS where a task carries a fact. `suggested_version` is
|
|
318
|
+
// inherited because a version is a label; `suggested_goal_id` is NOT, because
|
|
319
|
+
// its own column header forbids exactly that read — see goalId below.
|
|
320
|
+
versionId: task.version_id != null ? task.version_id : (task.suggested_version ?? null),
|
|
256
321
|
// A cascade INHERITS its parent's goal — the follow-up belongs wherever the
|
|
257
322
|
// work that triggered it belongs.
|
|
323
|
+
// NOT inherited from an idea's `suggested_goal_id`. That column documents
|
|
324
|
+
// itself as "Strictly advisory: no rank check, no scope wall, and no payout
|
|
325
|
+
// may read it... The BINDING home for 'which goal' is tasks.goal_id" — and a
|
|
326
|
+
// generated task's goal is binding. So an idea-triggered cascade lands in the
|
|
327
|
+
// catch-all and gets triaged like any other homeless work; the hint travels in
|
|
328
|
+
// the body instead, where a human can act on it.
|
|
258
329
|
goalId: task.goal_id != null ? Number(task.goal_id) : null,
|
|
259
330
|
// BV1.R11 (task 1003598): …and when the parent has none, says so. A shipped
|
|
260
331
|
// task can still be goal-less — the 53 legacy catch-all rows, or any task
|
|
@@ -283,6 +354,59 @@ function cascadeTaskSpec(rule, task) {
|
|
|
283
354
|
// The runner — the only part that touches a database
|
|
284
355
|
// ---------------------------------------------------------------------------
|
|
285
356
|
|
|
357
|
+
// FILE ONE SUBJECT'S MATCHES. Shared by both runners so the idempotency read,
|
|
358
|
+
// the per-rule isolation and the reporting cannot drift apart between triggers —
|
|
359
|
+
// the two runners differ only in how they FETCH a subject and which rule list and
|
|
360
|
+
// context they bring.
|
|
361
|
+
async function fileMatches({ matched, deps, report, trigger, subject }) {
|
|
362
|
+
for (const { rule, spec } of matched) {
|
|
363
|
+
try {
|
|
364
|
+
const existing = deps.findTaskBySourceRef
|
|
365
|
+
? await deps.findTaskBySourceRef({ source: CASCADE_SOURCE, sourceRef: spec.sourceRef })
|
|
366
|
+
: null;
|
|
367
|
+
if (existing) { report.skipped.push({ key: rule.key, taskId: existing.id, reason: 'already filed' }); continue; }
|
|
368
|
+
const created = await deps.createTask(spec);
|
|
369
|
+
report.created.push({ key: rule.key, taskId: created && created.id, discipline: rule.discipline });
|
|
370
|
+
deps.log?.info?.(`[cascade] ${trigger.happened(subject)} → filed ${created && created.id} for ${rule.discipline} ("${rule.key}")`);
|
|
371
|
+
} catch (e) {
|
|
372
|
+
// One rule failing costs that rule its task and nothing else.
|
|
373
|
+
report.errors.push(`cascade "${rule.key}": ${e && e.message}`);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
// React to one idea reaching grade 'full'. Same guarantees as the ship runner —
|
|
379
|
+
// returns a report, never throws, never rejects — because an ideator grading
|
|
380
|
+
// their own idea must not be able to fail on a follow-up task's account.
|
|
381
|
+
//
|
|
382
|
+
// deps.getIdea(id) -> the full idea row (the event payload may
|
|
383
|
+
// carry only the id)
|
|
384
|
+
// deps.findTaskBySourceRef({...}) -> an existing row or null
|
|
385
|
+
// deps.createTask(spec) -> the created row
|
|
386
|
+
// deps.log -> optional { info, error }
|
|
387
|
+
async function runCascadesForIdea(graded, deps = {}) {
|
|
388
|
+
const report = { created: [], skipped: [], errors: [] };
|
|
389
|
+
try {
|
|
390
|
+
const id = graded && (graded.id ?? graded.idea_id);
|
|
391
|
+
if (id == null) { report.errors.push('cascade: idea payload carried no idea id'); return report; }
|
|
392
|
+
|
|
393
|
+
// The emitter sends the whole idea (it owns the row; this module does not),
|
|
394
|
+
// so the payload IS the subject. deps.getIdea stays supported for a caller
|
|
395
|
+
// that would rather re-read — and when one is injected and returns nothing,
|
|
396
|
+
// that is an error rather than a silent fall-back to a payload it rejected.
|
|
397
|
+
const idea = deps.getIdea ? await deps.getIdea(id) : graded;
|
|
398
|
+
if (!idea) { report.errors.push(`cascade: idea ${id} not readable after grading`); return report; }
|
|
399
|
+
|
|
400
|
+
const { matched, errors } = cascadesFor(idea, deps.rules || IDEA_CASCADES, {}, TRIGGERS.idea);
|
|
401
|
+
report.errors.push(...errors);
|
|
402
|
+
await fileMatches({ matched, deps, report, trigger: TRIGGERS.idea, subject: idea });
|
|
403
|
+
} catch (e) {
|
|
404
|
+
report.errors.push(`cascade: ${e && e.message}`);
|
|
405
|
+
}
|
|
406
|
+
if (report.errors.length) deps.log?.error?.(`[cascade] ${report.errors.length} problem(s): ${report.errors.join(' | ')}`);
|
|
407
|
+
return report;
|
|
408
|
+
}
|
|
409
|
+
|
|
286
410
|
// React to one shipped task. Returns a report, NEVER throws and never rejects:
|
|
287
411
|
// property 1 in the header. `deps` is injected so the whole runner is testable
|
|
288
412
|
// with two stubs and no database.
|
|
@@ -323,20 +447,7 @@ async function runCascadesForShip(shipped, deps = {}) {
|
|
|
323
447
|
const { matched, errors } = cascadesFor(task, deps.rules || CASCADES, ctx);
|
|
324
448
|
report.errors.push(...errors);
|
|
325
449
|
|
|
326
|
-
|
|
327
|
-
try {
|
|
328
|
-
const existing = deps.findTaskBySourceRef
|
|
329
|
-
? await deps.findTaskBySourceRef({ source: CASCADE_SOURCE, sourceRef: spec.sourceRef })
|
|
330
|
-
: null;
|
|
331
|
-
if (existing) { report.skipped.push({ key: rule.key, taskId: existing.id, reason: 'already filed' }); continue; }
|
|
332
|
-
const created = await deps.createTask(spec);
|
|
333
|
-
report.created.push({ key: rule.key, taskId: created && created.id, discipline: rule.discipline });
|
|
334
|
-
log?.info?.(`[cascade] task ${id} shipped → filed ${created && created.id} for ${rule.discipline} ("${rule.key}")`);
|
|
335
|
-
} catch (e) {
|
|
336
|
-
// One rule failing costs that rule its task and nothing else.
|
|
337
|
-
report.errors.push(`cascade "${rule.key}": ${e && e.message}`);
|
|
338
|
-
}
|
|
339
|
-
}
|
|
450
|
+
await fileMatches({ matched, deps: { ...deps, log }, report, trigger: TRIGGERS.ship, subject: task });
|
|
340
451
|
} catch (e) {
|
|
341
452
|
report.errors.push(`cascade: ${e && e.message}`);
|
|
342
453
|
}
|
|
@@ -346,6 +457,10 @@ async function runCascadesForShip(shipped, deps = {}) {
|
|
|
346
457
|
|
|
347
458
|
module.exports = {
|
|
348
459
|
CASCADES,
|
|
460
|
+
IDEA_CASCADES,
|
|
461
|
+
TRIGGERS,
|
|
462
|
+
fullIdeaScopingBody,
|
|
463
|
+
runCascadesForIdea,
|
|
349
464
|
CASCADE_SOURCE,
|
|
350
465
|
COPY_VISUAL_MODULES,
|
|
351
466
|
hasCopyOrVisualWork,
|
|
@@ -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 };
|