@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.
Files changed (39) hide show
  1. package/.bongos-core.json +74 -39
  2. package/clients/bongos-client/bongos-client.global.js +1 -1
  3. package/clients/bongos-client/index.cjs +1 -1
  4. package/clients/bongos-client/index.d.ts +2 -3
  5. package/clients/bongos-client/index.mjs +1 -1
  6. package/docs/adr/0297-a-tasks-creator-may-retire-it.md +128 -0
  7. package/docs/adr/README.md +1 -0
  8. package/docs/api/openapi.json +5 -28
  9. package/docs/api-reference.md +2 -2
  10. package/docs/canonical-permissions.md +30 -1
  11. package/docs/copy-inventory.md +49 -45
  12. package/docs/copy-registry.json +86 -50
  13. package/docs/module-api-changelog.md +4 -0
  14. package/modules/agents/lib/validate.js +1 -1
  15. package/modules/dev-box/app/src/vendor/bongos-client.cjs +1 -1
  16. package/modules/discord/discord-channels.js +80 -5
  17. package/modules/discord/routes/discord.js +14 -2
  18. package/modules/government/catalog.js +28 -1
  19. package/modules/government/migrations/government_015_task_abandon_own.sql +64 -0
  20. package/modules/hall-ui/public/task.js +50 -1
  21. package/modules/ideas/inbox.js +29 -1
  22. package/modules/lifecycle/abandon-authz.js +132 -0
  23. package/modules/lifecycle/cascade.js +134 -19
  24. package/modules/lifecycle/db-ship.js +59 -5
  25. package/modules/lifecycle/routes/abandon-handler.js +151 -0
  26. package/modules/lifecycle/routes/lifecycle.js +18 -0
  27. package/modules/lifecycle/routes/tasks.js +26 -38
  28. package/package-lock.json +2 -2
  29. package/package.json +1 -1
  30. package/src/bongos/route-rank-check.js +12 -1
  31. package/src/module-api.js +1 -1
  32. package/tests/abandon_creator_authz.mjs +176 -0
  33. package/tests/abandon_route.mjs +276 -0
  34. package/tests/abandon_task.mjs +145 -3
  35. package/tests/agents_validate.mjs +33 -2
  36. package/tests/discord_channels.mjs +166 -0
  37. package/tests/government_ownership_scope.mjs +13 -7
  38. package/tests/idea_cascade.mjs +211 -0
  39. package/tests/task_goal_required.mjs +15 -3
@@ -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 };
@@ -44,12 +44,30 @@ const cascade = require('../cascade');
44
44
  // leave it. Nothing here can affect the ship: the emitters fire post-commit and
45
45
  // already wrap the emit, and `runCascadesForShip` resolves rather than throws.
46
46
  let _cascadeListenerOff = null;
47
+ let _ideaCascadeListenerOff = null;
47
48
 
48
49
  function registerSeams() {
49
50
  if (!api.hasProvider('lifecycle')) {
50
51
  api.registerProvider('lifecycle', lifecycle);
51
52
  }
52
53
  if (_cascadeListenerOff) _cascadeListenerOff();
54
+ // Torn down with its sibling: the factory runs again every time a test rebuilds
55
+ // the router, and a leaked subscription would fire this cascade twice.
56
+ if (_ideaCascadeListenerOff) _ideaCascadeListenerOff();
57
+ // task 1003856: the second cross-role cascade. Same module, same runner
58
+ // guarantees, different trigger — an idea reaching Full rather than a ship.
59
+ _ideaCascadeListenerOff = api.on('idea.graded.full', (graded) => cascade.runCascadesForIdea(graded, {
60
+ // No getIdea: the ideas module owns that row and sends it whole. Injecting a
61
+ // reader here would mean lifecycle querying another module's table.
62
+ findTaskBySourceRef: (q) => db.findTaskBySourceRef(q),
63
+ createTask: (spec) => db.createTask(spec),
64
+ // INVOKED, like its sibling below. api.logger is a FACTORY (api.logger(ns)),
65
+ // so passing it bare left every log?.info?.() call resolving to undefined and
66
+ // silently doing nothing — the optional chaining that makes the runner safe is
67
+ // also what hid this.
68
+ log: api.logger ? api.logger('lifecycle') : null,
69
+ }));
70
+
53
71
  _cascadeListenerOff = api.on('task.shipped', (shipped) => cascade.runCascadesForShip(shipped, {
54
72
  getTask: (id) => db.getTask(id),
55
73
  findTaskBySourceRef: (ref) => db.findTaskBySourceRef(ref),
@@ -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.815",
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.815",
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.815",
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.815'; // 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');
@@ -0,0 +1,176 @@
1
+ // tests/abandon_creator_authz.mjs
2
+ //
3
+ // Regression guard for task 1002443 / ADR 0297 — the PURE abandon decision in
4
+ // modules/lifecycle/abandon-authz.js.
5
+ //
6
+ // WHAT IS BEING PROTECTED. POST /tasks/:id/abandon went from one rank-shaped
7
+ // authority to two, only one of which a rank can express:
8
+ // • task.abandon (floor metic) — retire ANY task.
9
+ // • task.abandon.own (floor xenos) — retire a task you CREATED, and only while
10
+ // it is still backlog/ready/blocked.
11
+ // The second is the ADR 0151 ownership axis. Its whole safety property is that an
12
+ // own-scoped key is NEVER sufficient by itself: it must be met by a predicate over
13
+ // the concrete row. Every test below exists to stop that collapsing back into an
14
+ // unconditional grant — the `.own` → `.any` degradation the axis was built to
15
+ // prevent.
16
+ //
17
+ // DB-free and route-free by construction (the goal-route-authz.js /
18
+ // shouldRefuseSessionClaim precedent), so every branch is pinned directly rather
19
+ // than only through a live HTTP path.
20
+ //
21
+ // Run: node --test tests/abandon_creator_authz.mjs
22
+
23
+ import { strict as assert } from 'node:assert';
24
+ import { createRequire } from 'node:module';
25
+ import { makeRunner } from './helpers.mjs';
26
+
27
+ const require = createRequire(import.meta.url);
28
+ const authz = require('../modules/lifecycle/abandon-authz.js');
29
+ const {
30
+ abandonVerdict, mayAttemptAbandon, strictSameOwner,
31
+ CREATOR_ABANDONABLE, ABANDON_ANY_KEY, ABANDON_OWN_KEY,
32
+ } = authz;
33
+
34
+ const { test, summary } = makeRunner();
35
+
36
+ const ME = 7;
37
+ const SOMEONE_ELSE = 8;
38
+
39
+ // ── the cross-owner branch ───────────────────────────────────────────────────
40
+ await test('holding task.abandon admits on ANY task, whoever created it', async () => {
41
+ const out = abandonVerdict({ holdsAny: true, builderId: ME, createdBy: SOMEONE_ELSE, status: 'ready' });
42
+ assert.equal(out.ok, true);
43
+ assert.equal(out.via, ABANDON_ANY_KEY, 'reports WHICH authority admitted');
44
+ });
45
+
46
+ await test('task.abandon is NOT narrowed by the creator status window', async () => {
47
+ // A Metic+ keeps exactly the power ADR 0152 gave them. The creator window is an
48
+ // ADDITION for sub-metic authors, never a new ceiling on triage authority — if
49
+ // this ever fails, the widening has silently become a restriction.
50
+ for (const status of ['completed', 'confirmed', 'active', 'backlog']) {
51
+ const out = abandonVerdict({ holdsAny: true, builderId: ME, createdBy: SOMEONE_ELSE, status });
52
+ assert.equal(out.ok, true, `task.abandon must still admit on '${status}'`);
53
+ }
54
+ });
55
+
56
+ // ── holding nothing ──────────────────────────────────────────────────────────
57
+ await test('holding neither key is refused, even on your own ready task', async () => {
58
+ const out = abandonVerdict({ holdsAny: false, holdsOwn: false, builderId: ME, createdBy: ME, status: 'ready' });
59
+ assert.equal(out.ok, false);
60
+ assert.equal(out.reason, 'missing_permission');
61
+ assert.match(out.message, /task\.abandon/, 'the denial names the atom it wants');
62
+ });
63
+
64
+ await test('the default verdict denies — an empty call is never an admit', async () => {
65
+ assert.equal(abandonVerdict().ok, false, 'fail-closed with no arguments at all');
66
+ assert.equal(abandonVerdict({}).ok, false, 'fail-closed with an empty options object');
67
+ });
68
+
69
+ // ── the ownership branch: the key alone is NOT enough ────────────────────────
70
+ await test('task.abandon.own alone does NOT admit on someone else\'s task', async () => {
71
+ const out = abandonVerdict({ holdsOwn: true, builderId: ME, createdBy: SOMEONE_ELSE, status: 'ready' });
72
+ assert.equal(out.ok, false, 'an own-scoped key must never reach another owner\'s row');
73
+ assert.equal(out.reason, 'not_task_creator');
74
+ });
75
+
76
+ await test('an UNATTRIBUTED task (created_by null) belongs to nobody', async () => {
77
+ // Every task filed before core_227, plus the seed / restock / discord-bug system
78
+ // vectors. If null ever matched, one own-scoped grant would open ~1,357 historical
79
+ // tasks to any builder at once — the single widest failure mode here.
80
+ const out = abandonVerdict({ holdsOwn: true, builderId: ME, createdBy: null, status: 'ready' });
81
+ assert.equal(out.ok, false);
82
+ assert.equal(out.reason, 'not_task_creator');
83
+ });
84
+
85
+ await test('the creator DOES admit on their own task, in each allowed status', async () => {
86
+ assert.ok(CREATOR_ABANDONABLE.length > 0, 'a vacuous allow-list would make every case below pass for the wrong reason');
87
+ for (const status of CREATOR_ABANDONABLE) {
88
+ const out = abandonVerdict({ holdsOwn: true, builderId: ME, createdBy: ME, status });
89
+ assert.equal(out.ok, true, `a creator must be able to retire their own '${status}' task`);
90
+ assert.equal(out.via, ABANDON_OWN_KEY, 'and it must be recorded as the OWN authority, not the any one');
91
+ }
92
+ });
93
+
94
+ await test('the creator is refused once the task has moved past planning', async () => {
95
+ // Past the grader a retire is a correction to the ledger, not an author changing
96
+ // their mind, so completed/confirmed stay with the cross-owner authority.
97
+ for (const status of ['active', 'completed', 'confirmed', 'shipped']) {
98
+ const out = abandonVerdict({ holdsOwn: true, builderId: ME, createdBy: ME, status });
99
+ assert.equal(out.ok, false, `a creator must NOT retire their own '${status}' task`);
100
+ assert.equal(out.reason, 'creator_status_not_abandonable');
101
+ assert.match(out.message, new RegExp(status), 'the denial says which status refused it');
102
+ }
103
+ });
104
+
105
+ await test('an unknown or missing status is refused, not admitted', async () => {
106
+ for (const status of [undefined, null, '', 'READY', 'not-a-status']) {
107
+ const out = abandonVerdict({ holdsOwn: true, builderId: ME, createdBy: ME, status });
108
+ assert.equal(out.ok, false, `status ${JSON.stringify(status)} must fail closed`);
109
+ }
110
+ });
111
+
112
+ // ── the owner comparison ─────────────────────────────────────────────────────
113
+ await test('owner ids compare across the string/number boundary', async () => {
114
+ // Postgres hands bigints back as strings; JS code carries them as numbers. If
115
+ // these stopped matching, every creator would be refused on their own task and
116
+ // the feature would be silently dead.
117
+ assert.equal(abandonVerdict({ holdsOwn: true, builderId: 7, createdBy: '7', status: 'ready' }).ok, true);
118
+ assert.equal(abandonVerdict({ holdsOwn: true, builderId: '7', createdBy: 7, status: 'ready' }).ok, true);
119
+ });
120
+
121
+ await test('nothing without a real identity matches anything', async () => {
122
+ // Mirrors the government resolver's ownerIdentity contract EXACTLY, which is the
123
+ // point — a second, subtly different normalization is how an ownership
124
+ // comparison quietly widens. Note what is deliberately absent from this list:
125
+ // any FINITE number is a real identity there (ids arrive as bigints from
126
+ // Postgres and as numbers in JS), so asserting otherwise here would pin a rule
127
+ // the two halves do not actually share.
128
+ for (const v of [null, undefined, '', NaN, Infinity, {}, [], true]) {
129
+ assert.equal(strictSameOwner(v, v), false, `${String(v)} must not match itself`);
130
+ }
131
+ assert.equal(strictSameOwner(7, 7), true, 'a real id still matches itself');
132
+ assert.equal(strictSameOwner(7, 8), false);
133
+ assert.equal(strictSameOwner(7, '7'), true, 'and across the bigint/number boundary');
134
+ });
135
+
136
+ await test('the INJECTED comparator is the one actually used', async () => {
137
+ // The route injects the government resolver's own normalization so the owner
138
+ // comparison exists in exactly one place. A refusing comparator must refuse a
139
+ // pair the built-in would have admitted — otherwise the injection is decorative
140
+ // and the two halves can drift apart unnoticed.
141
+ let saw = null;
142
+ const refuseAll = (a, b) => { saw = [a, b]; return false; };
143
+ const out = abandonVerdict({ holdsOwn: true, builderId: ME, createdBy: ME, status: 'ready', sameOwner: refuseAll });
144
+ assert.equal(out.ok, false, 'the injected comparator must be consulted, not bypassed');
145
+ assert.equal(out.reason, 'not_task_creator');
146
+ assert.deepEqual(saw, [ME, ME], 'and it must be handed (createdBy, builderId)');
147
+ });
148
+
149
+ await test('a non-function comparator falls back to the strict one, never to admit', async () => {
150
+ for (const bogus of [null, 'yes', 42, {}]) {
151
+ const out = abandonVerdict({ holdsOwn: true, builderId: ME, createdBy: SOMEONE_ELSE, status: 'ready', sameOwner: bogus });
152
+ assert.equal(out.ok, false, 'a broken comparator must not open the gate');
153
+ }
154
+ const ok = abandonVerdict({ holdsOwn: true, builderId: ME, createdBy: ME, status: 'ready', sameOwner: null });
155
+ assert.equal(ok.ok, true, 'and the fallback must still admit a genuine creator');
156
+ });
157
+
158
+ // ── the cheap pre-transaction half ───────────────────────────────────────────
159
+ await test('mayAttemptAbandon opens the transaction for either key, and for neither', async () => {
160
+ assert.equal(mayAttemptAbandon({ holdsAny: true, holdsOwn: false }), true);
161
+ assert.equal(mayAttemptAbandon({ holdsAny: false, holdsOwn: true }), true, 'an own-holder must be let as far as the row question');
162
+ assert.equal(mayAttemptAbandon({ holdsAny: false, holdsOwn: false }), false);
163
+ assert.equal(mayAttemptAbandon({}), false, 'fail-closed on an empty call');
164
+ assert.equal(mayAttemptAbandon(), false);
165
+ });
166
+
167
+ await test('only a strict true counts as holding a key', async () => {
168
+ // Guards a caller that hands in a truthy non-boolean (a permission array, a
169
+ // string) and would otherwise be read as a grant.
170
+ for (const truthy of ['task.abandon', 1, ['task.abandon'], {}]) {
171
+ assert.equal(mayAttemptAbandon({ holdsAny: truthy }), false, `${JSON.stringify(truthy)} must not read as a grant`);
172
+ assert.equal(abandonVerdict({ holdsAny: truthy, builderId: ME, createdBy: SOMEONE_ELSE, status: 'ready' }).ok, false);
173
+ }
174
+ });
175
+
176
+ summary();