@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.
@@ -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 is metic+archon-gated (ADR 0152; archon-only until then), and the abandon
957
- // + its reason are captured in audit_log by the writing middleware, so the
958
- // append-only ledger records who retired what. restoreTask (below) is the undo.
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: metic+archon — scope removal; the inverse of /promote, and re-ranked to
672
- // match it (ADR 0152, was archon under ADR 0090). A Metic who may author, shape,
673
- // and promote work may also retire it: leaving the kill switch an Archon rung
674
- // higher than the create switch only forced the owner to clean up after the
675
- // triage they already delegated. Not one-way any more — /promote restores an
676
- // abandoned task to backlog at the same rank — and the guards below (shipped
677
- // refused, active claim refused) plus the audit_log entry are unchanged.
678
- router.post('/tasks/:id/abandon', auth.requireBuilder, auth.requirePermission('task.abandon'), async (req, res) => {
679
- if (validateOrRespond(req, res, {
680
- reason: { type: 'string' }, // free-text retire reason (sliced to 2000 below)
681
- })) return;
682
- const id = parseId(req, res, { code: 'bad_task_id', positiveInt: true });
683
- if (id === null) return;
684
- const reason = typeof req.body?.reason === 'string' ? req.body.reason.slice(0, 2000) : '';
685
- try {
686
- const result = await db.abandonTask({ taskId: id, reason });
687
- res.json({ ok: true, ...result });
688
- } catch (err) {
689
- if (err.code === 'TASK_NOT_FOUND') return res.fail('task_not_found', 404);
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.813",
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.813",
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.813",
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
- 'POST /tasks/:id/abandon': 'metic+archon',
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.813'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
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');