@bongos/core 1.19.812 → 1.19.814

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (38) hide show
  1. package/.bongos-core.json +73 -38
  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/dev-box/app/src/vendor/bongos-client.cjs +1 -1
  15. package/modules/government/catalog.js +28 -1
  16. package/modules/government/migrations/government_015_task_abandon_own.sql +64 -0
  17. package/modules/hall-ui/public/task.js +50 -1
  18. package/modules/lifecycle/abandon-authz.js +132 -0
  19. package/modules/lifecycle/db-executor.js +45 -0
  20. package/modules/lifecycle/db-goals.js +29 -7
  21. package/modules/lifecycle/db-ship.js +59 -5
  22. package/modules/lifecycle/db-tasks.js +11 -5
  23. package/modules/lifecycle/db-versions.js +17 -3
  24. package/modules/lifecycle/done-when.js +5 -2
  25. package/modules/lifecycle/goal-edits.js +5 -1
  26. package/modules/lifecycle/routes/abandon-handler.js +151 -0
  27. package/modules/lifecycle/routes/done-when.js +150 -86
  28. package/modules/lifecycle/routes/tasks.js +26 -38
  29. package/package-lock.json +2 -2
  30. package/package.json +1 -1
  31. package/src/bongos/route-rank-check.js +12 -1
  32. package/src/module-api.js +1 -1
  33. package/tests/abandon_creator_authz.mjs +176 -0
  34. package/tests/abandon_route.mjs +276 -0
  35. package/tests/abandon_task.mjs +145 -3
  36. package/tests/criterion_authoring_gate.mjs +22 -1
  37. package/tests/criterion_goal_attach.mjs +233 -8
  38. package/tests/government_ownership_scope.mjs +13 -7
@@ -130,40 +130,58 @@ module.exports = function buildDoneWhenRouter() {
130
130
  goal_id: { type: 'integer', min: 1 },
131
131
  sort_order: { type: 'integer', min: 0 },
132
132
  })) return;
133
- const version = await db.getVersion(versionId);
134
- if (!version) return res.fail('version_not_found', { status: 404, message: `No version '${versionId}'.` });
135
133
  // bad_goal_id now means exactly what it says: the goal is absent or belongs
136
134
  // to another version. (The unauthorized case rendered through this same
137
135
  // helper until task 1002614 — see the mask note in authorizeCriterionCreate.)
138
136
  const badGoalId = () => res.fail('bad_goal_id', { status: 400, message: `goal_id ${req.body.goal_id} does not exist on version '${versionId}'.` });
139
- // A goal_id, if given, must exist AND belong to this version.
140
- let goal = null;
141
- if (req.body.goal_id != null) {
142
- goal = await db.getGoal(req.body.goal_id);
143
- if (!goal || String(goal.version_id) !== String(versionId)) return badGoalId();
144
- }
145
- // ADR 0154: the authority wall. Checked AFTER the goal's version linkage so a
146
- // bad ref is still the clearer 400. Membership is only read when a goal was
147
- // named and the caller isn't an Archon — a primary-key hit on
148
- // goal_members (goal_id, builder_id) (migration 160), on a low-frequency route.
149
- const actorMembership = (goal && req.builder.rank !== 'archon')
150
- ? await db.getGoalMember({ goalId: goal.id, builderId: req.builder.id })
151
- : null;
152
- const decision = authorizeCriterionCreate({
153
- goal, version, actorId: req.builder.id, actorRank: req.builder.rank, actorMembership,
154
- });
155
- if (!decision.ok) {
156
- return res.status(decision.status).json(decision.body);
157
- }
158
137
  try {
159
- const criterion = await db.createCriterion({
160
- versionId,
161
- criterionId: req.body.criterion_id,
162
- criterionMd: req.body.criterion_md,
163
- goalId: req.body.goal_id ?? null,
164
- sortOrder: req.body.sort_order ?? null,
165
- });
166
- res.status(201).json({ ok: true, criterion });
138
+ // RESOLVE, DECIDE and WRITE in ONE transaction, with the goal row LOCKED
139
+ // (task 1003840). Before this, the three facts the decision rests on — the
140
+ // version's status, the goal's owner/version linkage, the actor's
141
+ // membership — were read on the pool, and the INSERT landed afterwards on
142
+ // another connection. Between the two, the goal could be transferred or
143
+ // closed, the membership revoked, or the version frozen, and the write
144
+ // would land on authority that no longer held.
145
+ //
146
+ // The LOCK is the part that closes it. A bare transaction around the same
147
+ // sequence would still read the stale rows; the lock makes a concurrent
148
+ // transfer wait behind this one instead of racing it.
149
+ const outcome = await api.withTx(async (client) => {
150
+ const version = await db.getVersion(versionId, { client, forUpdate: true });
151
+ if (!version) return { fail: ['version_not_found', { status: 404, message: `No version '${versionId}'.` }] };
152
+ // A goal_id, if given, must exist AND belong to this version.
153
+ let goal = null;
154
+ if (req.body.goal_id != null) {
155
+ goal = await db.getGoal(req.body.goal_id, { client, forUpdate: true });
156
+ if (!goal || String(goal.version_id) !== String(versionId)) return { badGoal: true };
157
+ }
158
+ // ADR 0154: the authority wall. Checked AFTER the goal's version linkage so a
159
+ // bad ref is still the clearer 400. Membership is only read when a goal was
160
+ // named and the caller isn't an Archon — a primary-key hit on
161
+ // goal_members (goal_id, builder_id) (migration 160), on a low-frequency route.
162
+ const actorMembership = (goal && req.builder.rank !== 'archon')
163
+ ? await db.getGoalMember({ goalId: goal.id, builderId: req.builder.id }, { client })
164
+ : null;
165
+ const decision = authorizeCriterionCreate({
166
+ goal, version, actorId: req.builder.id, actorRank: req.builder.rank, actorMembership,
167
+ });
168
+ if (!decision.ok) return { decision };
169
+ const criterion = await db.createCriterion({
170
+ versionId,
171
+ criterionId: req.body.criterion_id,
172
+ criterionMd: req.body.criterion_md,
173
+ goalId: req.body.goal_id ?? null,
174
+ sortOrder: req.body.sort_order ?? null,
175
+ }, { client });
176
+ return { criterion };
177
+ }, { pool: api.pool });
178
+ // Responses are sent AFTER the transaction resolves, never from inside it:
179
+ // a refusal must roll back rather than commit a half-written unit of work,
180
+ // and a 201 must not be promised before the COMMIT succeeds.
181
+ if (outcome.badGoal) return badGoalId();
182
+ if (outcome.fail) return res.fail(outcome.fail[0], outcome.fail[1]);
183
+ if (outcome.decision) return res.status(outcome.decision.status).json(outcome.decision.body);
184
+ res.status(201).json({ ok: true, criterion: outcome.criterion });
167
185
  } catch (err) {
168
186
  if (err && err.code === '23505') {
169
187
  return res.fail('criterion_exists', { status: 409, message: `criterion_id '${req.body.criterion_id}' already exists on version '${versionId}'.` });
@@ -294,64 +312,82 @@ module.exports = function buildDoneWhenRouter() {
294
312
  }
295
313
  const id = parseId(req, res, { param: 'criterionId', code: 'bad_criterion_id' });
296
314
  if (id === null) return;
297
- const existing = await doneWhen.getCriterion(id);
298
- if (!existing) return res.fail('criterion_not_found', 404);
299
- const version = await db.getVersion(existing.version_id);
300
- // The ownership wall, resolved exactly as the CREATE route resolves it: the
301
- // goal + version, and membership read only for a non-Archon. One closure, so
302
- // both ends of a move are decided by the same code rather than a copy.
303
- const authorizeFor = async (goal) => {
304
- const actorMembership = (goal && req.builder.rank !== 'archon')
305
- ? await db.getGoalMember({ goalId: goal.id, builderId: req.builder.id })
306
- : null;
307
- return authorizeCriterionCreate({
308
- goal, version, actorId: req.builder.id, actorRank: req.builder.rank, actorMembership,
309
- });
310
- };
311
- const currentGoal = existing.goal_id != null ? await db.getGoal(existing.goal_id) : null;
312
- const authority = await authorizeFor(currentGoal);
313
- if (!authority.ok) return res.status(authority.status).json(authority.body);
315
+ // Resolve, authorize and write inside ONE transaction, both goal rows LOCKED
316
+ // (task 1003840) — the same window the CREATE route above had, and the same
317
+ // cure. A move reads authority at BOTH ends, so both ends are locked: losing
318
+ // the race at either would move a criterion under authority that had just
319
+ // been revoked. Locking in a stable order (source before destination) is what
320
+ // keeps two concurrent moves from deadlocking on each other's rows.
321
+ const outcome = await api.withTx(async (client) => {
322
+ const existing = await doneWhen.getCriterion(id, { client });
323
+ if (!existing) return { fail: ['criterion_not_found', 404] };
324
+ const version = await db.getVersion(existing.version_id, { client, forUpdate: true });
325
+ // The ownership wall, resolved exactly as the CREATE route resolves it: the
326
+ // goal + version, and membership read only for a non-Archon. One closure, so
327
+ // both ends of a move are decided by the same code rather than a copy.
328
+ const authorizeFor = async (goal) => {
329
+ const actorMembership = (goal && req.builder.rank !== 'archon')
330
+ ? await db.getGoalMember({ goalId: goal.id, builderId: req.builder.id }, { client })
331
+ : null;
332
+ return authorizeCriterionCreate({
333
+ goal, version, actorId: req.builder.id, actorRank: req.builder.rank, actorMembership,
334
+ });
335
+ };
336
+ const targetGoalId = movingGoal ? (req.body.goal_id ?? null) : null;
337
+
338
+ // Lock BOTH ends in ASCENDING GOAL ID, never in the order the route happens
339
+ // to need them (task 1003840, round 2 — the first cut locked source then
340
+ // destination, which is call order, not a canonical one). Two opposite
341
+ // moves, 5→10 and 10→5, would then take the two locks in opposite orders
342
+ // and deadlock; Postgres aborts one rather than corrupting anything, but an
343
+ // avoidable abort is still an outage for whoever loses. A single agreed
344
+ // order means they queue instead.
345
+ const lockIds = [...new Set([
346
+ existing.goal_id != null ? Number(existing.goal_id) : null,
347
+ targetGoalId !== null ? Number(targetGoalId) : null,
348
+ ].filter((v) => v != null))].sort((a, b) => a - b);
349
+ const locked = new Map();
350
+ for (const gid of lockIds) locked.set(gid, await db.getGoal(gid, { client, forUpdate: true }));
351
+
352
+ const currentGoal = existing.goal_id != null ? locked.get(Number(existing.goal_id)) ?? null : null;
353
+ const authority = await authorizeFor(currentGoal);
354
+ if (!authority.ok) return { decision: authority };
314
355
 
315
- const targetGoalId = movingGoal ? (req.body.goal_id ?? null) : null;
316
- if (movingGoal) {
317
- let targetGoal = null;
318
- if (targetGoalId !== null) {
319
- targetGoal = await db.getGoal(targetGoalId);
320
- // Same refusal the CREATE route gives, for the same reason: a criterion may
321
- // never cross versions, which would put it in one version's rollup and the
322
- // other's goal at once.
323
- if (!targetGoal || String(targetGoal.version_id) !== String(existing.version_id)) {
324
- return res.fail('bad_goal_id', { status: 400, message: `goal_id ${targetGoalId} does not exist on version '${existing.version_id}'.` });
356
+ if (movingGoal) {
357
+ let targetGoal = null;
358
+ if (targetGoalId !== null) {
359
+ targetGoal = locked.get(Number(targetGoalId)) ?? null;
360
+ // Same refusal the CREATE route gives, for the same reason: a criterion may
361
+ // never cross versions, which would put it in one version's rollup and the
362
+ // other's goal at once.
363
+ if (!targetGoal || String(targetGoal.version_id) !== String(existing.version_id)) {
364
+ return { fail: ['bad_goal_id', { status: 400, message: `goal_id ${targetGoalId} does not exist on version '${existing.version_id}'.` }] };
365
+ }
325
366
  }
367
+ const destination = await authorizeFor(targetGoal);
368
+ if (!destination.ok) return { decision: destination };
326
369
  }
327
- const destination = await authorizeFor(targetGoal);
328
- if (!destination.ok) return res.status(destination.status).json(destination.body);
329
- }
330
370
 
331
- let criterion = existing;
332
- let deltas = [];
333
- let unchanged = false;
334
- if (changingProse) {
335
- const refusal = goalEdits.criterionProseRefusal(existing);
336
- if (refusal) return res.fail(refusal.code, refusal.status, refusal.message ? { message: refusal.message } : undefined);
337
- const result = await goalEdits.updateCriterionProse({ criterionId: id, criterionMd: req.body.criterion_md });
338
- if (!result) return res.fail('criterion_not_found', 404);
339
- await goalEdits.recordProseEdit('criterion', id, req, result.deltas);
340
- criterion = result.criterion;
341
- deltas = result.deltas;
342
- unchanged = !!result.unchanged;
343
- }
344
- const achievedGoals = [];
345
- const fromGoalId = existing.goal_id ?? null;
371
+ let criterion = existing;
372
+ let deltas = [];
373
+ let unchanged = false;
374
+ if (changingProse) {
375
+ const refusal = goalEdits.criterionProseRefusal(existing);
376
+ if (refusal) return { refusal };
377
+ const result = await goalEdits.updateCriterionProse({ criterionId: id, criterionMd: req.body.criterion_md }, { client });
378
+ if (!result) return { fail: ['criterion_not_found', 404] };
379
+ criterion = result.criterion;
380
+ deltas = result.deltas;
381
+ unchanged = !!result.unchanged;
382
+ }
383
+ const achievedGoals = [];
384
+ const fromGoalId = existing.goal_id ?? null;
346
385
  // A move to where it already is writes nothing at all — not the row, not the
347
386
  // audit, not a re-derive. Nothing about the world changed.
348
- if (movingGoal && fromGoalId !== targetGoalId) {
349
- const moved = await db.updateCriterionGoal(id, targetGoalId);
350
- if (!moved) return res.fail('criterion_not_found', 404);
351
- criterion = moved;
352
- await goalEdits.recordCriterionGoalMove(
353
- { criterionId: id, fromGoalId, toGoalId: targetGoalId }, req
354
- );
387
+ if (movingGoal && fromGoalId !== targetGoalId) {
388
+ const moved = await db.updateCriterionGoal(id, targetGoalId, { client });
389
+ if (!moved) return { fail: ['criterion_not_found', 404] };
390
+ criterion = moved;
355
391
  // BOTH ENDS are re-derived, because a move changes what each of them still
356
392
  // needs. The destination is the obvious half — adopting an already-satisfied
357
393
  // criterion can be the thing that completes a goal. The SOURCE is the half
@@ -361,13 +397,41 @@ module.exports = function buildDoneWhenRouter() {
361
397
  // will ever close again — no later ship re-checks it, because no task under
362
398
  // it changed. achieveGoalIfComplete is conservative and idempotent (open
363
399
  // goals only, and it never un-achieves), so asking twice is two reads.
364
- for (const goalId of [fromGoalId, targetGoalId]) {
365
- if (goalId === null) continue;
366
- const outcome = await db.achieveGoalIfComplete({ goalId });
367
- if (outcome && outcome.achieved) achievedGoals.push(goalId);
400
+ for (const goalId of [fromGoalId, targetGoalId]) {
401
+ if (goalId === null) continue;
402
+ const achieved = await db.achieveGoalIfComplete({ goalId }, { client });
403
+ if (achieved && achieved.achieved) achievedGoals.push(goalId);
404
+ }
368
405
  }
406
+ return { criterion, deltas, unchanged, achievedGoals, moved: movingGoal && fromGoalId !== targetGoalId, fromGoalId, targetGoalId };
407
+ }, { pool: api.pool });
408
+
409
+ // Everything below runs only after COMMIT. The audit writes in particular
410
+ // belong out here: they are announcements of a change that happened, so
411
+ // recording one for a unit of work that rolled back would put an edit in the
412
+ // ledger that never landed — the ledger lying, which is the one thing it
413
+ // must not do.
414
+ if (outcome.fail) return res.fail(outcome.fail[0], outcome.fail[1]);
415
+ if (outcome.decision) return res.status(outcome.decision.status).json(outcome.decision.body);
416
+ if (outcome.refusal) {
417
+ const r = outcome.refusal;
418
+ return res.fail(r.code, r.status, r.message ? { message: r.message } : undefined);
369
419
  }
370
- return res.json({ ok: true, criterion, deltas, unchanged, achieved_goals: achievedGoals });
420
+ if (outcome.deltas && outcome.deltas.length) {
421
+ await goalEdits.recordProseEdit('criterion', id, req, outcome.deltas);
422
+ }
423
+ if (outcome.moved) {
424
+ await goalEdits.recordCriterionGoalMove(
425
+ { criterionId: id, fromGoalId: outcome.fromGoalId, toGoalId: outcome.targetGoalId }, req
426
+ );
427
+ }
428
+ return res.json({
429
+ ok: true,
430
+ criterion: outcome.criterion,
431
+ deltas: outcome.deltas,
432
+ unchanged: outcome.unchanged,
433
+ achieved_goals: outcome.achievedGoals,
434
+ });
371
435
  }, { errorCode: 'criterion_update_failed' }));
372
436
 
373
437
  // Auth: flip a criterion back to unsatisfied (rare; for corrections).
@@ -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.812",
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.812",
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.812",
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.812'; // 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');
@@ -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();