@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
@@ -0,0 +1,276 @@
1
+ // tests/abandon_route.mjs
2
+ //
3
+ // Regression guard for task 1002443 / ADR 0297 — the COMPOSITION layer of
4
+ // POST /tasks/:id/abandon (modules/lifecycle/routes/abandon-handler.js).
5
+ //
6
+ // WHY THIS FILE EXISTS. The route's authority is assembled from three pieces that
7
+ // each have their own tests and none of which cover the seams between them:
8
+ // • abandon-authz.js — the pure decision (tests/abandon_creator_authz.mjs)
9
+ // • abandonTask — the transaction seam (tests/abandon_task.mjs)
10
+ // • this handler — resolve, compose, map (nothing, until now)
11
+ // A mistake in the wiring — admitting when the government port is absent, mapping
12
+ // a refusal to the wrong status, naming the wrong atom in a denial, passing the
13
+ // wrong flags into the decision — would have passed both of the others. That gap
14
+ // was the grade finding on the first ship of this task; this closes it.
15
+ //
16
+ // No Express, no DB, no live port: the handler takes its collaborators as `deps`,
17
+ // so fakes are enough.
18
+ //
19
+ // Run: node --test tests/abandon_route.mjs
20
+
21
+ import { strict as assert } from 'node:assert';
22
+ import { createRequire } from 'node:module';
23
+ import { makeRunner } from './helpers.mjs';
24
+
25
+ const require = createRequire(import.meta.url);
26
+ const { makeAbandonHandler } = require('../modules/lifecycle/routes/abandon-handler.js');
27
+
28
+ const { test, summary } = makeRunner();
29
+
30
+ const ME = 7;
31
+ const ANY_KEY = 'task.abandon';
32
+ const OWN_KEY = 'task.abandon.own';
33
+
34
+ // A res that records instead of writing. `fail` mirrors the real envelope's two
35
+ // call shapes: fail(code, status) and fail(code, { status, message, details }).
36
+ function fakeRes() {
37
+ const rec = { json: null, fail: null };
38
+ return {
39
+ rec,
40
+ json(body) { rec.json = body; return this; },
41
+ fail(code, second, third) {
42
+ const opts = (second && typeof second === 'object') ? second : { status: second };
43
+ rec.fail = { code, status: opts.status, message: opts.message, details: opts.details ?? third };
44
+ return this;
45
+ },
46
+ };
47
+ }
48
+
49
+ // A government port with only what the handler actually consults.
50
+ function fakeGovernance({ holds = [], crossOwner = [ANY_KEY], resolveThrows = false, omit = [] } = {}) {
51
+ const port = {
52
+ resolveBuilderPermissions: async () => {
53
+ if (resolveThrows) throw new Error('DB blip');
54
+ return [...holds];
55
+ },
56
+ sameOwner: (a, b) => a != null && b != null && String(a) === String(b),
57
+ crossOwnerKeysFor: () => [...crossOwner],
58
+ permissionFloor: (k) => (k === ANY_KEY ? 'metic' : k === OWN_KEY ? 'xenos' : null),
59
+ };
60
+ for (const k of omit) delete port[k];
61
+ return port;
62
+ }
63
+
64
+ function harness({ governance = fakeGovernance(), abandon, taskId = 42, body = {} } = {}) {
65
+ const calls = { abandon: null, logged: [] };
66
+ const deps = {
67
+ db: {
68
+ abandonTask: async (args) => {
69
+ calls.abandon = args;
70
+ if (typeof abandon === 'function') return abandon(args);
71
+ return { taskId: args.taskId, taskTitle: 'fixture', taskStatus: 'abandoned', previousStatus: 'ready', alreadyAbandoned: false };
72
+ },
73
+ },
74
+ seams: { resolveOptional: (name) => (name === 'government' ? governance : null) },
75
+ log: { error: (...a) => calls.logged.push(a) },
76
+ validateOrRespond: () => false, // body is valid
77
+ parseId: () => taskId, // id parses
78
+ };
79
+ const req = { builder: { id: ME }, body, params: { id: String(taskId) } };
80
+ const res = fakeRes();
81
+ return { handler: makeAbandonHandler(deps), req, res, calls };
82
+ }
83
+
84
+ // ── the port is the wall, and its absence denies ──────────────────────────────
85
+ await test('a MISSING government port denies fail-closed, and never reaches the db', async () => {
86
+ // Admitting here would make disabling a module a privilege escalation — the
87
+ // inversion ADR 0016 exists to prevent.
88
+ const { handler, req, res, calls } = harness({ governance: null });
89
+ await handler(req, res);
90
+ assert.equal(res.rec.fail.status, 403);
91
+ assert.equal(res.rec.fail.code, 'permission_forbidden');
92
+ assert.equal(res.rec.fail.details.held, null, 'held is null when authority could not be ESTABLISHED');
93
+ assert.equal(calls.abandon, null, 'no transaction may be opened');
94
+ });
95
+
96
+ await test('a MALFORMED port (missing a function the decision needs) also denies', async () => {
97
+ for (const missing of ['resolveBuilderPermissions', 'sameOwner']) {
98
+ const { handler, req, res, calls } = harness({ governance: fakeGovernance({ holds: [ANY_KEY], omit: [missing] }) });
99
+ await handler(req, res);
100
+ assert.equal(res.rec.fail.status, 403, `a port without ${missing} must deny`);
101
+ assert.equal(calls.abandon, null);
102
+ }
103
+ });
104
+
105
+ await test('a THROWING resolver denies rather than surfacing as a 500', async () => {
106
+ const { handler, req, res, calls } = harness({ governance: fakeGovernance({ resolveThrows: true }) });
107
+ await handler(req, res);
108
+ assert.equal(res.rec.fail.status, 403);
109
+ assert.equal(res.rec.fail.details.held, null);
110
+ assert.equal(calls.abandon, null);
111
+ assert.equal(calls.logged.length, 1, 'and it is logged — a DB blip should be visible');
112
+ });
113
+
114
+ // ── the denial names the right atoms ──────────────────────────────────────────
115
+ await test('holding neither atom is a 403 naming BOTH ways in, with their floors', async () => {
116
+ const { handler, req, res, calls } = harness({ governance: fakeGovernance({ holds: [] }) });
117
+ await handler(req, res);
118
+ assert.equal(res.rec.fail.status, 403);
119
+ assert.deepEqual(res.rec.fail.details.required, [ANY_KEY, OWN_KEY],
120
+ 'a denial listing only the metic key would send a creator after the wrong permission');
121
+ assert.deepEqual(res.rec.fail.details.floors, { [ANY_KEY]: 'metic', [OWN_KEY]: 'xenos' });
122
+ assert.deepEqual(res.rec.fail.details.held, []);
123
+ assert.equal(calls.abandon, null, 'the cheap pre-check must not open a transaction');
124
+ });
125
+
126
+ await test('floors come from the PORT, so a moved floor cannot leave a lie in the denial', async () => {
127
+ const gov = fakeGovernance({ holds: [] });
128
+ gov.permissionFloor = (k) => (k === ANY_KEY ? 'archon' : null);
129
+ const { handler, req, res } = harness({ governance: gov });
130
+ await handler(req, res);
131
+ assert.deepEqual(res.rec.fail.details.floors, { [ANY_KEY]: 'archon' },
132
+ 'the floor is read at deny time, never hardcoded');
133
+ });
134
+
135
+ await test('a port that cannot answer floors still denies, without inventing one', async () => {
136
+ const { handler, req, res } = harness({ governance: fakeGovernance({ holds: [], omit: ['permissionFloor'] }) });
137
+ await handler(req, res);
138
+ assert.equal(res.rec.fail.status, 403);
139
+ assert.deepEqual(res.rec.fail.details.floors, {});
140
+ });
141
+
142
+ // ── the escalation path is read from the catalog ──────────────────────────────
143
+ await test('the cross-owner key is DERIVED from the catalog, not hardcoded', async () => {
144
+ // If the declared counterpart ever changes, the route must follow it rather than
145
+ // keep admitting on a stale pairing.
146
+ const { handler, req, res, calls } = harness({
147
+ governance: fakeGovernance({ holds: ['task.manage.any'], crossOwner: ['task.manage.any'] }),
148
+ });
149
+ await handler(req, res);
150
+ assert.equal(res.rec.fail, null, 'the declared counterpart must admit');
151
+ assert.equal(res.rec.json.ok, true);
152
+ assert.equal(calls.abandon.authorize({ createdBy: 999, status: 'completed' }).ok, true,
153
+ 'and it must compose as the CROSS-OWNER branch, unbounded by the creator window');
154
+ });
155
+
156
+ await test('holding the OLD atom does not admit once the catalog has moved on', async () => {
157
+ const { handler, req, res, calls } = harness({
158
+ governance: fakeGovernance({ holds: [ANY_KEY], crossOwner: ['task.manage.any'] }),
159
+ });
160
+ await handler(req, res);
161
+ assert.equal(res.rec.fail.status, 403, 'a catalog that moved on must not leave this gate wide');
162
+ assert.equal(calls.abandon, null);
163
+ });
164
+
165
+ await test('a port that cannot name the escalation falls back to the known atom, never to open', async () => {
166
+ const { handler, req, res } = harness({
167
+ governance: fakeGovernance({ holds: [ANY_KEY], omit: ['crossOwnerKeysFor'] }),
168
+ });
169
+ await handler(req, res);
170
+ assert.equal(res.rec.fail, null, 'the fallback keeps the metic path working');
171
+ assert.equal(res.rec.json.ok, true);
172
+ });
173
+
174
+ // ── composition: what the decision is actually handed ─────────────────────────
175
+ await test('the own-scoped holder reaches the row question, composed correctly', async () => {
176
+ const { handler, req, res, calls } = harness({ governance: fakeGovernance({ holds: [OWN_KEY] }) });
177
+ await handler(req, res);
178
+ assert.ok(calls.abandon, 'an own-holder must be let as far as the transaction');
179
+ const verdict = calls.abandon.authorize;
180
+ assert.equal(verdict({ createdBy: ME, status: 'ready' }).ok, true, 'their own ready task admits');
181
+ assert.equal(verdict({ createdBy: 999, status: 'ready' }).ok, false, 'someone else\'s does not');
182
+ assert.equal(verdict({ createdBy: ME, status: 'completed' }).ok, false, 'and not past the grader');
183
+ });
184
+
185
+ await test('the callback is handed the CALLER as the owner to compare against', async () => {
186
+ // A wiring slip here (comparing against the task id, or a constant) would make
187
+ // every creator either always or never the owner.
188
+ let sawArgs = null;
189
+ const gov = fakeGovernance({ holds: [OWN_KEY] });
190
+ gov.sameOwner = (a, b) => { sawArgs = [a, b]; return true; };
191
+ const { handler, req, res, calls } = harness({ governance: gov });
192
+ await handler(req, res);
193
+ calls.abandon.authorize({ createdBy: 'abc', status: 'ready' });
194
+ assert.deepEqual(sawArgs, ['abc', ME], 'the row owner and the requesting builder, in that order');
195
+ });
196
+
197
+ await test('the reason is passed through and clamped to 2000 characters', async () => {
198
+ const { handler, req, res, calls } = harness({
199
+ governance: fakeGovernance({ holds: [ANY_KEY] }),
200
+ body: { reason: 'x'.repeat(2500) },
201
+ });
202
+ await handler(req, res);
203
+ assert.equal(calls.abandon.reason.length, 2000);
204
+ });
205
+
206
+ // ── refusal mapping ──────────────────────────────────────────────────────────
207
+ await test('ABANDON_FORBIDDEN from the locked row maps to 403, carrying its reason', async () => {
208
+ const { handler, req, res } = harness({
209
+ governance: fakeGovernance({ holds: [OWN_KEY] }),
210
+ abandon: async () => { throw Object.assign(new Error('not yours'), { code: 'ABANDON_FORBIDDEN', reason: 'not_task_creator' }); },
211
+ });
212
+ await handler(req, res);
213
+ assert.equal(res.rec.fail.status, 403);
214
+ assert.equal(res.rec.fail.message, 'not yours', 'the row-scoped message reaches the caller');
215
+ assert.equal(res.rec.fail.details.reason, 'not_task_creator');
216
+ assert.deepEqual(res.rec.fail.details.held, [OWN_KEY], 'and it reports what they DO hold');
217
+ });
218
+
219
+ await test('the three lifecycle guards keep their own status codes', async () => {
220
+ const cases = [
221
+ ['TASK_NOT_FOUND', 404, 'task_not_found'],
222
+ ['CANNOT_ABANDON_SHIPPED', 409, 'cannot_abandon_shipped'],
223
+ ['TASK_HAS_ACTIVE_CLAIM', 409, 'task_has_active_claim'],
224
+ ];
225
+ for (const [code, status, envelope] of cases) {
226
+ const { handler, req, res } = harness({
227
+ governance: fakeGovernance({ holds: [ANY_KEY] }),
228
+ abandon: async () => { throw Object.assign(new Error(code), { code }); },
229
+ });
230
+ await handler(req, res);
231
+ assert.equal(res.rec.fail.status, status, `${code} must stay a ${status}`);
232
+ assert.equal(res.rec.fail.code, envelope);
233
+ }
234
+ });
235
+
236
+ await test('an unexpected error is a 500 and is logged, never a silent 403', async () => {
237
+ const { handler, req, res, calls } = harness({
238
+ governance: fakeGovernance({ holds: [ANY_KEY] }),
239
+ abandon: async () => { throw new Error('something else entirely'); },
240
+ });
241
+ await handler(req, res);
242
+ assert.equal(res.rec.fail.status, 500);
243
+ assert.equal(res.rec.fail.code, 'abandon_failed');
244
+ assert.equal(calls.logged.length, 1);
245
+ });
246
+
247
+ // ── the happy path ───────────────────────────────────────────────────────────
248
+ await test('a successful retire answers ok with the db result spread in', async () => {
249
+ const { handler, req, res } = harness({ governance: fakeGovernance({ holds: [ANY_KEY] }) });
250
+ await handler(req, res);
251
+ assert.equal(res.rec.fail, null);
252
+ assert.equal(res.rec.json.ok, true);
253
+ assert.equal(res.rec.json.taskStatus, 'abandoned');
254
+ assert.equal(res.rec.json.previousStatus, 'ready');
255
+ });
256
+
257
+ await test('an invalid body or id short-circuits before any authority work', async () => {
258
+ for (const stop of ['body', 'id']) {
259
+ const calls = { abandon: null, resolved: 0 };
260
+ const gov = fakeGovernance({ holds: [ANY_KEY] });
261
+ const realResolve = gov.resolveBuilderPermissions;
262
+ gov.resolveBuilderPermissions = async (...a) => { calls.resolved++; return realResolve(...a); };
263
+ const handler = makeAbandonHandler({
264
+ db: { abandonTask: async () => { calls.abandon = true; } },
265
+ seams: { resolveOptional: () => gov },
266
+ log: { error: () => {} },
267
+ validateOrRespond: () => stop === 'body',
268
+ parseId: () => (stop === 'id' ? null : 42),
269
+ });
270
+ await handler({ builder: { id: ME }, body: {}, params: {} }, fakeRes());
271
+ assert.equal(calls.abandon, null, `a bad ${stop} must not reach the db`);
272
+ assert.equal(calls.resolved, 0, `a bad ${stop} must not even resolve permissions`);
273
+ }
274
+ });
275
+
276
+ summary();
@@ -40,10 +40,17 @@ const { test, summary } = makeRunner();
40
40
  // status → the row's current status
41
41
  // hasActiveClaim → the claims lookup returns an unreleased claim
42
42
  // updatedStatus → what the UPDATE ... RETURNING reports (restore returns 'backlog')
43
- function fakePool({ status = 'ready', notFound = false, hasActiveClaim = false, updatedStatus = 'abandoned' }) {
43
+ // createdBy (task 1002443) rides the locked SELECT so the ownership predicate and
44
+ // the UPDATE read the same row version; null is the honest default (every task
45
+ // filed before core_227, and every system vector, has no creator).
46
+ function fakePool({ status = 'ready', notFound = false, hasActiveClaim = false, updatedStatus = 'abandoned', createdBy = null }) {
44
47
  return makeSqlAwareClient((s, params) => {
45
- if (/^SELECT id, title, status FROM tasks WHERE id = \$1 FOR UPDATE/.test(s)) {
46
- return { rows: notFound ? [] : [{ id: params[0], title: 'fixture task', status }] };
48
+ // Both locked SELECTs, and the optional group is load-bearing: abandonTask
49
+ // reads created_by (task 1002443) and restoreTask does not, so a regex
50
+ // pinned to either one alone silently returns no row for the other — which
51
+ // surfaces as TASK_NOT_FOUND and looks like a code bug, not a fake bug.
52
+ if (/^SELECT id, title, status(, created_by)? FROM tasks WHERE id = \$1 FOR UPDATE/.test(s)) {
53
+ return { rows: notFound ? [] : [{ id: params[0], title: 'fixture task', status, created_by: createdBy }] };
47
54
  }
48
55
  if (/SELECT id FROM claims WHERE task_id = \$1 AND released_at IS NULL/.test(s)) {
49
56
  return { rows: hasActiveClaim ? [{ id: 999 }] : [] };
@@ -207,5 +214,140 @@ await test('restore: never consults claims (an abandoned task has none by constr
207
214
  assert.equal(all(pool._client.queries, /SELECT id FROM claims/).length, 0);
208
215
  });
209
216
 
217
+ // =========================================================================
218
+ // task 1002443 / ADR 0297 — the `authorize` seam: the row-scoped veto that lets a
219
+ // task's CREATOR retire their own work without holding the metic-floor
220
+ // task.abandon. The DECISION itself is pinned branch-by-branch in
221
+ // tests/abandon_creator_authz.mjs; what is pinned HERE is the seam's mechanics —
222
+ // what it is handed, when it runs, and that every unhappy path denies.
223
+
224
+ await test('authorize is handed the LOCKED row: created_by and status', async () => {
225
+ // The whole point of doing this inside the transaction. If the callback were fed
226
+ // anything other than the row abandonTask just locked FOR UPDATE, the ownership
227
+ // check would be reading a different version than the UPDATE writes — the TOCTOU
228
+ // window this seam exists to close.
229
+ const pool = fakePool({ status: 'ready', createdBy: 7 });
230
+ let seen = null;
231
+ await abandonTask({
232
+ taskId: TASK,
233
+ reason: 'mine to retire',
234
+ authorize: (row) => { seen = row; return { ok: true }; },
235
+ }, { pool });
236
+ assert.equal(seen.createdBy, 7, 'created_by must reach the predicate');
237
+ assert.equal(seen.status, 'ready', 'and so must the row status');
238
+ assert.equal(seen.taskId, TASK);
239
+ assert.ok(find(pool._client.queries, /^UPDATE tasks/), 'an admit still performs the write');
240
+ assert.ok(find(pool._client.queries, /^COMMIT/));
241
+ });
242
+
243
+ await test('a refusing authorize throws ABANDON_FORBIDDEN, rolls back, writes nothing', async () => {
244
+ const pool = fakePool({ status: 'ready', createdBy: 8 });
245
+ await assert.rejects(
246
+ () => abandonTask({
247
+ taskId: TASK,
248
+ authorize: () => ({ ok: false, reason: 'not_task_creator', message: 'not yours' }),
249
+ }, { pool }),
250
+ (err) => err.code === 'ABANDON_FORBIDDEN' && err.reason === 'not_task_creator',
251
+ );
252
+ assert.equal(all(pool._client.queries, /^UPDATE tasks/).length, 0, 'a refusal must never write');
253
+ assert.ok(find(pool._client.queries, /^ROLLBACK/), 'must ROLLBACK');
254
+ });
255
+
256
+ await test('authorize runs BEFORE the already-abandoned idempotent return', async () => {
257
+ // Ordering, and it is a disclosure rule rather than a cosmetic one: if the
258
+ // idempotent branch came first, a builder with no authority over this row could
259
+ // probe it and read `alreadyAbandoned: true` back as a 200. An unauthorized retry
260
+ // is a refusal, not a no-op.
261
+ const pool = fakePool({ status: 'abandoned', createdBy: 8 });
262
+ await assert.rejects(
263
+ () => abandonTask({ taskId: TASK, authorize: () => ({ ok: false, reason: 'not_task_creator' }) }, { pool }),
264
+ (err) => err.code === 'ABANDON_FORBIDDEN',
265
+ );
266
+ assert.ok(!find(pool._client.queries, /^COMMIT/), 'must not COMMIT the idempotent no-op to an unauthorized caller');
267
+ assert.ok(find(pool._client.queries, /^ROLLBACK/));
268
+ });
269
+
270
+ await test('authorize also precedes the shipped and active-claim guards', async () => {
271
+ // Same reason: the 409s are informative, so an unauthorized caller must not be
272
+ // able to distinguish them. A refusal outranks every status branch below it.
273
+ for (const setup of [{ status: 'shipped' }, { status: 'ready', hasActiveClaim: true }]) {
274
+ const pool = fakePool({ ...setup, createdBy: 8 });
275
+ await assert.rejects(
276
+ () => abandonTask({ taskId: TASK, authorize: () => ({ ok: false }) }, { pool }),
277
+ (err) => err.code === 'ABANDON_FORBIDDEN',
278
+ `an unauthorized caller must get ABANDON_FORBIDDEN, not the ${setup.status} guard`,
279
+ );
280
+ }
281
+ });
282
+
283
+ await test('an authorized creator is STILL stopped by the existing guards', async () => {
284
+ // The widening adds an authority; it removes no guard. A creator who genuinely
285
+ // owns the row still cannot retire shipped history, nor a task someone is
286
+ // actively holding.
287
+ const shipped = fakePool({ status: 'shipped', createdBy: 7 });
288
+ await assert.rejects(
289
+ () => abandonTask({ taskId: TASK, authorize: () => ({ ok: true }) }, { pool: shipped }),
290
+ (err) => err.code === 'CANNOT_ABANDON_SHIPPED',
291
+ );
292
+ const claimed = fakePool({ status: 'ready', createdBy: 7, hasActiveClaim: true });
293
+ await assert.rejects(
294
+ () => abandonTask({ taskId: TASK, authorize: () => ({ ok: true }) }, { pool: claimed }),
295
+ (err) => err.code === 'TASK_HAS_ACTIVE_CLAIM',
296
+ );
297
+ assert.equal(all(claimed._client.queries, /^UPDATE tasks/).length, 0);
298
+ });
299
+
300
+ await test('the seam is FAIL-CLOSED: anything but an explicit { ok: true } denies', async () => {
301
+ for (const verdict of [undefined, null, false, {}, { ok: 'yes' }, { ok: 1 }, 'ok', { okay: true }]) {
302
+ const pool = fakePool({ status: 'ready', createdBy: 7 });
303
+ await assert.rejects(
304
+ () => abandonTask({ taskId: TASK, authorize: () => verdict }, { pool }),
305
+ (err) => err.code === 'ABANDON_FORBIDDEN',
306
+ `${JSON.stringify(verdict)} must not be read as an admit`,
307
+ );
308
+ assert.equal(all(pool._client.queries, /^UPDATE tasks/).length, 0);
309
+ }
310
+ });
311
+
312
+ await test('a THROWING authorize denies rather than escaping as a 500', async () => {
313
+ const pool = fakePool({ status: 'ready', createdBy: 7 });
314
+ await assert.rejects(
315
+ () => abandonTask({ taskId: TASK, authorize: () => { throw new Error('resolver blew up'); } }, { pool }),
316
+ (err) => err.code === 'ABANDON_FORBIDDEN' && err.reason === 'authorize_threw',
317
+ );
318
+ assert.equal(all(pool._client.queries, /^UPDATE tasks/).length, 0);
319
+ assert.ok(find(pool._client.queries, /^ROLLBACK/));
320
+ });
321
+
322
+ await test('authorize never runs for a task that does not exist', async () => {
323
+ const pool = fakePool({ notFound: true });
324
+ let called = false;
325
+ await assert.rejects(
326
+ () => abandonTask({ taskId: 424242, authorize: () => { called = true; return { ok: true }; } }, { pool }),
327
+ (err) => err.code === 'TASK_NOT_FOUND',
328
+ );
329
+ assert.equal(called, false, 'there is no row to be the owner of');
330
+ });
331
+
332
+ await test('OMITTING authorize preserves the pre-1002443 behaviour exactly', async () => {
333
+ // Every caller whose route already gated the transition (and the merge path,
334
+ // which mirrors this function) must be untouched by the new parameter.
335
+ const pool = fakePool({ status: 'ready', createdBy: 8 });
336
+ const out = await abandonTask({ taskId: TASK, reason: 'no authorize supplied' }, { pool });
337
+ assert.equal(out.taskStatus, 'abandoned');
338
+ assert.equal(out.previousStatus, 'ready');
339
+ assert.ok(find(pool._client.queries, /^UPDATE tasks/));
340
+ assert.ok(find(pool._client.queries, /^COMMIT/));
341
+ });
342
+
343
+ await test('a non-function authorize is ignored, not treated as a refusal', async () => {
344
+ // `authorize: null` is the documented "no row-scoped veto" default; only a real
345
+ // function is consulted. This pins that a caller passing null does not
346
+ // accidentally brick the gated path it already guards in middleware.
347
+ const pool = fakePool({ status: 'ready', createdBy: 8 });
348
+ const out = await abandonTask({ taskId: TASK, authorize: null }, { pool });
349
+ assert.equal(out.taskStatus, 'abandoned');
350
+ });
351
+
210
352
  // -------------------------------------------------------------------------
211
353
  summary();
@@ -144,8 +144,29 @@ await test('authority is checked BEFORE goal state — an owner sees goal_not_op
144
144
 
145
145
  await test('bad_goal_id now means exactly what it says — the mask branch is gone from the route', () => {
146
146
  assert.match(doneWhenSrc, /const badGoalId = \(\) => res\.fail\('bad_goal_id'/, 'one helper builds the absent/foreign refusal');
147
- assert.match(doneWhenSrc, /if \(!goal \|\| String\(goal\.version_id\) !== String\(versionId\)\) return badGoalId\(\);/, 'absent/foreign goal path');
147
+ // The absent/foreign check and the refusal it renders are now separated by the
148
+ // transaction boundary (task 1003840): the check runs inside withTx and returns
149
+ // a sentinel, because sending a response from inside the transaction would
150
+ // commit a unit of work the route is in the middle of refusing. So the chain is
151
+ // asserted in two links rather than as one literal line — the invariant is
152
+ // "an absent or foreign goal reaches badGoalId and nothing else", which is what
153
+ // this test was always about.
154
+ assert.match(
155
+ doneWhenSrc,
156
+ /if \(!goal \|\| String\(goal\.version_id\) !== String\(versionId\)\) return \{ badGoal: true \};/,
157
+ 'absent/foreign goal path still refuses, inside the transaction',
158
+ );
159
+ assert.match(
160
+ doneWhenSrc,
161
+ /if \(outcome\.badGoal\) return badGoalId\(\);/,
162
+ 'and that sentinel is rendered by badGoalId once the transaction resolves',
163
+ );
148
164
  assert.doesNotMatch(doneWhenSrc, /maskAsBadGoalId/, 'no code path renders an authorization refusal as bad_goal_id');
165
+ // The sentinel must be answered exactly where it is raised — a second producer
166
+ // with no consumer, or vice versa, is how a refusal quietly becomes a 201.
167
+ const raised = (doneWhenSrc.match(/badGoal: true/g) || []).length;
168
+ const answered = (doneWhenSrc.match(/outcome\.badGoal/g) || []).length;
169
+ assert.equal(raised, answered, 'every badGoal sentinel raised is answered');
149
170
  });
150
171
 
151
172
  await test('a SHIPPED version is refused (409) — closed scope', () => {