@bongos/core 1.19.813 → 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 +60 -30
- 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 +2 -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-ship.js +59 -5
- package/modules/lifecycle/routes/abandon-handler.js +151 -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/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
|
+
};
|
|
@@ -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 };
|
|
@@ -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 };
|
|
@@ -32,6 +32,13 @@ const hierarchy = require('../hierarchy');
|
|
|
32
32
|
const { suggestCriteria } = require('../criterion-suggest');
|
|
33
33
|
// The orphan-task advisory (task 1002539) — pure, fail-open; see goal-advisory.js.
|
|
34
34
|
const { buildGoalAdvisory } = require('../goal-advisory');
|
|
35
|
+
// The PURE abandon decision (task 1002443, ADR 0297) — rank fact + row fact, kept
|
|
36
|
+
// out of this router so every branch is unit-tested without a live route.
|
|
37
|
+
const abandonAuthz = require('../abandon-authz');
|
|
38
|
+
// The abandon route's HTTP half, carved out so its COMPOSITION (authority resolve
|
|
39
|
+
// + the row-scoped decision + refusal mapping) is directly testable — the one
|
|
40
|
+
// layer neither the pure decision nor the db seam could reach.
|
|
41
|
+
const { makeAbandonHandler } = require('./abandon-handler');
|
|
35
42
|
// grading carved to modules/grading/ (BV1.R73 / ADR 0091 §4): the lifecycle no
|
|
36
43
|
// longer imports the grader — it resolves the `grade` kernel PORT at grade time
|
|
37
44
|
// (seams.resolveOptional('grade'), in POST /tasks/:id/grade below).
|
|
@@ -668,45 +675,26 @@ module.exports = function buildTasksRouter() {
|
|
|
668
675
|
// on a task with an active claim (release it first); idempotent on an already-
|
|
669
676
|
// abandoned task. The abandon + its `reason` land in audit_log via the writing
|
|
670
677
|
// middleware, so the ledger keeps a record of who retired what.
|
|
671
|
-
// rank:
|
|
672
|
-
//
|
|
673
|
-
//
|
|
674
|
-
//
|
|
675
|
-
//
|
|
676
|
-
//
|
|
677
|
-
//
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
if (err.code === 'CANNOT_ABANDON_SHIPPED') {
|
|
691
|
-
return res.fail('cannot_abandon_shipped', { status: 409, message: 'shipped tasks are immutable history' });
|
|
692
|
-
}
|
|
693
|
-
if (err.code === 'TASK_HAS_ACTIVE_CLAIM') {
|
|
694
|
-
return res.fail('task_has_active_claim', { status: 409, message: 'release the active claim before abandoning' });
|
|
695
|
-
}
|
|
696
|
-
log.error('[gds] POST /tasks/:id/abandon', err);
|
|
697
|
-
res.fail('abandon_failed', { status: 500, message: 'internal error' });
|
|
698
|
-
}
|
|
699
|
-
});
|
|
678
|
+
// rank: any-builder — DELIBERATELY, and the middleware list is not the whole gate
|
|
679
|
+
// (ADR 0297). Two authorities reach this transition: `task.abandon` (floor metic)
|
|
680
|
+
// retires ANY task, and `task.abandon.own` (floor xenos) retires one you CREATED
|
|
681
|
+
// while it is still backlog/ready/blocked. The second is a predicate over
|
|
682
|
+
// tasks.created_by, which no rank floor can express — the ADR 0151 ownership axis,
|
|
683
|
+
// and this is its first route. requirePermission structurally cannot carry it: an
|
|
684
|
+
// own-scoped key has no unconditional answer and that gate refuses one outright
|
|
685
|
+
// (auth.js, R100), so `.own` can never collapse into `.any`.
|
|
686
|
+
//
|
|
687
|
+
// The two facts are resolved in two places ON PURPOSE. The RANK fact is
|
|
688
|
+
// request-scoped and resolves here, once, uncached (ADR 0016); the OWNER fact is a
|
|
689
|
+
// column that can change under us, so it is evaluated inside abandonTask's FOR
|
|
690
|
+
// UPDATE transaction. Reading created_by here would be a TOCTOU window; resolving
|
|
691
|
+
// permissions in there would hold a row lock across a second pool acquire.
|
|
692
|
+
//
|
|
693
|
+
// Existing guards (shipped refused, active claim refused, idempotent re-abandon)
|
|
694
|
+
// and the audit_log entry are unchanged on both paths.
|
|
695
|
+
router.post('/tasks/:id/abandon', auth.requireBuilder,
|
|
696
|
+
makeAbandonHandler({ db, seams, log, validateOrRespond, parseId }));
|
|
700
697
|
|
|
701
|
-
// POST /tasks/:id/merge — mark a duplicate task merged into its canonical one
|
|
702
|
-
// (task 1001478 — the /bug-triage counterpart to idea_inbox's merge verdict).
|
|
703
|
-
// Implemented as `abandonTask` plus a target (db.mergeTask, db-ship.js) rather
|
|
704
|
-
// than a new `tasks.status` value — see that function's header for why. Refuses
|
|
705
|
-
// a self-merge, a shipped task, a task with an active claim, a target that
|
|
706
|
-
// doesn't exist, and a target that is itself merged elsewhere (no chains);
|
|
707
|
-
// idempotent when re-merging into the SAME target already recorded.
|
|
708
|
-
// rank: metic+archon — same floor as task.abandon, which this reuses under the
|
|
709
|
-
// hood; a Metic who may retire a task may also say which task it duplicates.
|
|
710
698
|
router.post('/tasks/:id/merge', auth.requireBuilder, auth.requirePermission('task.merge'), async (req, res) => {
|
|
711
699
|
if (validateOrRespond(req, res, {
|
|
712
700
|
into_task_id: { required: true, type: 'integer', min: 1 },
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.814",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.814",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.814",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -311,7 +311,18 @@ const MODULE_EXPECTED_RANKS = {
|
|
|
311
311
|
// it; /promote carries the abandoned→backlog undo at the same rank.
|
|
312
312
|
'POST /tasks': 'metic+archon',
|
|
313
313
|
'POST /tasks/:id/promote': 'metic+archon',
|
|
314
|
-
|
|
314
|
+
// task 1002443 — DELIBERATELY 'any-builder', widened from 'metic+archon'. Two
|
|
315
|
+
// authorities reach this route and only one is rank-shaped: `task.abandon`
|
|
316
|
+
// (floor metic) retires ANY task, and `task.abandon.own` (floor xenos) retires
|
|
317
|
+
// one you CREATED while it is still backlog/ready/blocked. The second is the
|
|
318
|
+
// ADR 0151 ownership axis, which requirePermission structurally cannot gate (an
|
|
319
|
+
// own-scoped key has no unconditional answer), so the wall is requireBuilder +
|
|
320
|
+
// an in-handler authorizeOwnedOrAny — the POST /goals/:id/members shape.
|
|
321
|
+
//
|
|
322
|
+
// Pinning it here keeps catching the accidental TIGHTENING back to a rank floor,
|
|
323
|
+
// which would lock creators out of retiring their own work and silently revert
|
|
324
|
+
// the owner's 2026-07-25 request, as loudly as it catches a gate removal.
|
|
325
|
+
'POST /tasks/:id/abandon': 'any-builder',
|
|
315
326
|
// override-request adjudication (lands credit + ship attribution).
|
|
316
327
|
// ADR 0157: `override_request.decide` lowered archon→metic; pinned by atom.
|
|
317
328
|
'GET /override-requests': 'perm:override_request.decide',
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.814'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|