@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.
- package/.bongos-core.json +73 -38
- package/clients/bongos-client/bongos-client.global.js +1 -1
- package/clients/bongos-client/index.cjs +1 -1
- package/clients/bongos-client/index.d.ts +2 -3
- package/clients/bongos-client/index.mjs +1 -1
- package/docs/adr/0297-a-tasks-creator-may-retire-it.md +128 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +5 -28
- package/docs/api-reference.md +2 -2
- package/docs/canonical-permissions.md +30 -1
- package/docs/copy-inventory.md +49 -45
- package/docs/copy-registry.json +86 -50
- package/docs/module-api-changelog.md +4 -0
- package/modules/dev-box/app/src/vendor/bongos-client.cjs +1 -1
- package/modules/government/catalog.js +28 -1
- package/modules/government/migrations/government_015_task_abandon_own.sql +64 -0
- package/modules/hall-ui/public/task.js +50 -1
- package/modules/lifecycle/abandon-authz.js +132 -0
- package/modules/lifecycle/db-executor.js +45 -0
- package/modules/lifecycle/db-goals.js +29 -7
- package/modules/lifecycle/db-ship.js +59 -5
- package/modules/lifecycle/db-tasks.js +11 -5
- package/modules/lifecycle/db-versions.js +17 -3
- package/modules/lifecycle/done-when.js +5 -2
- package/modules/lifecycle/goal-edits.js +5 -1
- package/modules/lifecycle/routes/abandon-handler.js +151 -0
- package/modules/lifecycle/routes/done-when.js +150 -86
- package/modules/lifecycle/routes/tasks.js +26 -38
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/bongos/route-rank-check.js +12 -1
- package/src/module-api.js +1 -1
- package/tests/abandon_creator_authz.mjs +176 -0
- package/tests/abandon_route.mjs +276 -0
- package/tests/abandon_task.mjs +145 -3
- package/tests/criterion_authoring_gate.mjs +22 -1
- package/tests/criterion_goal_attach.mjs +233 -8
- 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
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
//
|
|
301
|
-
//
|
|
302
|
-
//
|
|
303
|
-
const
|
|
304
|
-
const
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
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
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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
|
-
|
|
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:
|
|
672
|
-
//
|
|
673
|
-
//
|
|
674
|
-
//
|
|
675
|
-
//
|
|
676
|
-
//
|
|
677
|
-
//
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
if (err.code === 'CANNOT_ABANDON_SHIPPED') {
|
|
691
|
-
return res.fail('cannot_abandon_shipped', { status: 409, message: 'shipped tasks are immutable history' });
|
|
692
|
-
}
|
|
693
|
-
if (err.code === 'TASK_HAS_ACTIVE_CLAIM') {
|
|
694
|
-
return res.fail('task_has_active_claim', { status: 409, message: 'release the active claim before abandoning' });
|
|
695
|
-
}
|
|
696
|
-
log.error('[gds] POST /tasks/:id/abandon', err);
|
|
697
|
-
res.fail('abandon_failed', { status: 500, message: 'internal error' });
|
|
698
|
-
}
|
|
699
|
-
});
|
|
678
|
+
// rank: any-builder — DELIBERATELY, and the middleware list is not the whole gate
|
|
679
|
+
// (ADR 0297). Two authorities reach this transition: `task.abandon` (floor metic)
|
|
680
|
+
// retires ANY task, and `task.abandon.own` (floor xenos) retires one you CREATED
|
|
681
|
+
// while it is still backlog/ready/blocked. The second is a predicate over
|
|
682
|
+
// tasks.created_by, which no rank floor can express — the ADR 0151 ownership axis,
|
|
683
|
+
// and this is its first route. requirePermission structurally cannot carry it: an
|
|
684
|
+
// own-scoped key has no unconditional answer and that gate refuses one outright
|
|
685
|
+
// (auth.js, R100), so `.own` can never collapse into `.any`.
|
|
686
|
+
//
|
|
687
|
+
// The two facts are resolved in two places ON PURPOSE. The RANK fact is
|
|
688
|
+
// request-scoped and resolves here, once, uncached (ADR 0016); the OWNER fact is a
|
|
689
|
+
// column that can change under us, so it is evaluated inside abandonTask's FOR
|
|
690
|
+
// UPDATE transaction. Reading created_by here would be a TOCTOU window; resolving
|
|
691
|
+
// permissions in there would hold a row lock across a second pool acquire.
|
|
692
|
+
//
|
|
693
|
+
// Existing guards (shipped refused, active claim refused, idempotent re-abandon)
|
|
694
|
+
// and the audit_log entry are unchanged on both paths.
|
|
695
|
+
router.post('/tasks/:id/abandon', auth.requireBuilder,
|
|
696
|
+
makeAbandonHandler({ db, seams, log, validateOrRespond, parseId }));
|
|
700
697
|
|
|
701
|
-
// POST /tasks/:id/merge — mark a duplicate task merged into its canonical one
|
|
702
|
-
// (task 1001478 — the /bug-triage counterpart to idea_inbox's merge verdict).
|
|
703
|
-
// Implemented as `abandonTask` plus a target (db.mergeTask, db-ship.js) rather
|
|
704
|
-
// than a new `tasks.status` value — see that function's header for why. Refuses
|
|
705
|
-
// a self-merge, a shipped task, a task with an active claim, a target that
|
|
706
|
-
// doesn't exist, and a target that is itself merged elsewhere (no chains);
|
|
707
|
-
// idempotent when re-merging into the SAME target already recorded.
|
|
708
|
-
// rank: metic+archon — same floor as task.abandon, which this reuses under the
|
|
709
|
-
// hood; a Metic who may retire a task may also say which task it duplicates.
|
|
710
698
|
router.post('/tasks/:id/merge', auth.requireBuilder, auth.requirePermission('task.merge'), async (req, res) => {
|
|
711
699
|
if (validateOrRespond(req, res, {
|
|
712
700
|
into_task_id: { required: true, type: 'integer', min: 1 },
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.814",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.814",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.814",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
|
@@ -311,7 +311,18 @@ const MODULE_EXPECTED_RANKS = {
|
|
|
311
311
|
// it; /promote carries the abandoned→backlog undo at the same rank.
|
|
312
312
|
'POST /tasks': 'metic+archon',
|
|
313
313
|
'POST /tasks/:id/promote': 'metic+archon',
|
|
314
|
-
|
|
314
|
+
// task 1002443 — DELIBERATELY 'any-builder', widened from 'metic+archon'. Two
|
|
315
|
+
// authorities reach this route and only one is rank-shaped: `task.abandon`
|
|
316
|
+
// (floor metic) retires ANY task, and `task.abandon.own` (floor xenos) retires
|
|
317
|
+
// one you CREATED while it is still backlog/ready/blocked. The second is the
|
|
318
|
+
// ADR 0151 ownership axis, which requirePermission structurally cannot gate (an
|
|
319
|
+
// own-scoped key has no unconditional answer), so the wall is requireBuilder +
|
|
320
|
+
// an in-handler authorizeOwnedOrAny — the POST /goals/:id/members shape.
|
|
321
|
+
//
|
|
322
|
+
// Pinning it here keeps catching the accidental TIGHTENING back to a rank floor,
|
|
323
|
+
// which would lock creators out of retiring their own work and silently revert
|
|
324
|
+
// the owner's 2026-07-25 request, as loudly as it catches a gate removal.
|
|
325
|
+
'POST /tasks/:id/abandon': 'any-builder',
|
|
315
326
|
// override-request adjudication (lands credit + ship attribution).
|
|
316
327
|
// ADR 0157: `override_request.decide` lowered archon→metic; pinned by atom.
|
|
317
328
|
'GET /override-requests': 'perm:override_request.decide',
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.814'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -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();
|