@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
@@ -36,21 +36,54 @@ const api = require('../src/module-api.js');
36
36
  // db.updateCriterionGoal instead would leave its SQL with no coverage at all —
37
37
  // a mutation that made the UPDATE write nothing stayed green until this landed.
38
38
  const sqlLog = [];
39
+
40
+ // One statement handler, shared by the pool and by the transaction client it
41
+ // hands out, so a statement is recorded identically wherever it runs — otherwise
42
+ // "the UPDATE reached the pool" would quietly stop being true the moment the
43
+ // route started using a transaction, and the assertions would pass over nothing.
44
+ const runSql = async (sql, params, via = 'pool') => {
45
+ sqlLog.push({ sql, params, via });
46
+ if (/UPDATE done_when_criteria/.test(sql)) {
47
+ const [goalId, id] = params;
48
+ criterion = { ...criterion, id: Number(id), goal_id: goalId };
49
+ return { rows: [{ ...criterion }] };
50
+ }
51
+ if (/COALESCE\(MAX\(sort_order\)/.test(sql)) return { rows: [{ next: 7 }] };
52
+ if (/INSERT INTO done_when_criteria/.test(sql)) {
53
+ const [versionId, criterionId, criterionMd, goalId, sortOrder] = params;
54
+ return { rows: [{ id: 99, version_id: versionId, criterion_id: criterionId, criterion_md: criterionMd, goal_id: goalId, sort_order: sortOrder, satisfied: false }] };
55
+ }
56
+ if (/^\s*(BEGIN|COMMIT|ROLLBACK)\s*$/i.test(sql)) return { rows: [] };
57
+ throw new Error(`unexpected SQL reached the pool: ${sql}`);
58
+ };
59
+
60
+ // connect() makes withTx work against the fake (task 1003840). Every client it
61
+ // returns is recorded, so a test can assert the transaction BOUNDARY — that a
62
+ // write happened between BEGIN and COMMIT — not merely that the write happened.
63
+ const clients = [];
39
64
  Object.defineProperty(api, 'pool', {
40
65
  configurable: true,
41
66
  value: {
42
- query: async (sql, params) => {
43
- sqlLog.push({ sql, params });
44
- if (/UPDATE done_when_criteria/.test(sql)) {
45
- const [goalId, id] = params;
46
- criterion = { ...criterion, id: Number(id), goal_id: goalId };
47
- return { rows: [{ ...criterion }] };
48
- }
49
- throw new Error(`unexpected SQL reached the pool: ${sql}`);
67
+ query: (sql, params) => runSql(sql, params, 'pool'),
68
+ connect: async () => {
69
+ const client = {
70
+ query: (sql, params) => runSql(sql, params, 'client'),
71
+ release: () => { client.released = true; },
72
+ released: false,
73
+ };
74
+ clients.push(client);
75
+ return client;
50
76
  },
51
77
  },
52
78
  });
53
79
 
80
+ // The statements of the LAST transaction, in order — BEGIN … COMMIT included.
81
+ function txStatements() {
82
+ const begin = sqlLog.findIndex((q) => /^\s*BEGIN\s*$/i.test(q.sql));
83
+ if (begin === -1) return [];
84
+ return sqlLog.slice(begin).map((q) => q.sql.trim().split(/\s+/).slice(0, 3).join(' '));
85
+ }
86
+
54
87
  const db = require('../modules/lifecycle/db.js');
55
88
  const doneWhen = require('../modules/lifecycle/done-when.js');
56
89
  const goalEdits = require('../modules/lifecycle/goal-edits.js');
@@ -71,6 +104,7 @@ function resetWorld() {
71
104
  actor = METIC;
72
105
  completable = new Set([3]);
73
106
  sqlLog.length = 0;
107
+ clients.length = 0;
74
108
  calls = { achieve: [], auditMove: [], prose: [] };
75
109
  // Derived from the SQL the real updateCriterionGoal actually sent, as
76
110
  // [criterionId, goalId] — so "nothing was written" means no UPDATE reached the
@@ -140,6 +174,15 @@ async function patch(body) {
140
174
  return { status: res.status, body: await res.json().catch(() => ({})) };
141
175
  }
142
176
 
177
+ async function post(versionId, body) {
178
+ const res = await fetch(`${base}/versions/${versionId}/done-when`, {
179
+ method: 'POST',
180
+ headers: { 'content-type': 'application/json' },
181
+ body: JSON.stringify(body),
182
+ });
183
+ return { status: res.status, body: await res.json().catch(() => ({})) };
184
+ }
185
+
143
186
  let passed = 0, failed = 0;
144
187
  async function test(name, fn) {
145
188
  resetWorld();
@@ -342,6 +385,188 @@ await test('the UPDATE sets goal_id from the parameter, keyed by criterion id',
342
385
  assert.deepEqual(update.params, [3, 55]);
343
386
  });
344
387
 
388
+
389
+ // ---- the TOCTOU window (task 1003840) --------------------------------------
390
+ //
391
+ // The route used to resolve its facts on the pool, decide, and then write on
392
+ // another connection. Between the decision and the write the goal could be
393
+ // transferred or closed and the membership revoked, and the write would land on
394
+ // authority that no longer held. These pin the three properties that close it:
395
+ // one transaction, the authority read LOCKED inside it, and nothing written (or
396
+ // announced) when the decision refuses.
397
+
398
+ // Record what the route ASKS FOR, since getGoal is stubbed here and its SQL never
399
+ // reaches the recording pool.
400
+ const goalReads = [];
401
+ const versionReads = [];
402
+ const realGetGoal = db.getGoal;
403
+ const realGetVersion = db.getVersion;
404
+ db.getGoal = async (id, deps = {}) => { goalReads.push({ id: Number(id), deps }); return realGetGoal(id, deps); };
405
+ db.getVersion = async (id, deps = {}) => { versionReads.push({ id, deps }); return realGetVersion(id, deps); };
406
+
407
+ await test('the authority read is LOCKED and inside the transaction (task 1003840)', async () => {
408
+ actor = ARCHON;
409
+ goalReads.length = 0;
410
+ await patch({ goal_id: 3 });
411
+ assert.ok(goalReads.length > 0, 'the route read a goal at all');
412
+ for (const r of goalReads) {
413
+ assert.ok(r.deps && r.deps.client, `goal ${r.id} was read OUTSIDE the transaction`);
414
+ assert.equal(r.deps.forUpdate, true, `goal ${r.id} was read without FOR UPDATE — a re-read that is not locked still races`);
415
+ }
416
+ });
417
+
418
+ await test('the write lands BETWEEN begin and commit (task 1003840)', async () => {
419
+ actor = ARCHON;
420
+ await patch({ goal_id: 3 });
421
+ const seq = sqlLog.map((q) => q.sql.trim().split(/\s+/).slice(0, 2).join(' ').toUpperCase());
422
+ const begin = seq.findIndex((x) => x.startsWith('BEGIN'));
423
+ const update = seq.findIndex((x) => x.startsWith('UPDATE DONE_WHEN_CRITERIA'));
424
+ const commit = seq.findIndex((x) => x.startsWith('COMMIT'));
425
+ assert.ok(begin !== -1, 'a transaction was opened');
426
+ assert.ok(update !== -1, 'the move was written');
427
+ assert.ok(commit !== -1, 'the transaction was committed');
428
+ assert.ok(begin < update && update < commit,
429
+ `expected BEGIN < UPDATE < COMMIT, got ${begin} / ${update} / ${commit}`);
430
+ // ORDER IS NOT ENOUGH. A write sent to the POOL while a transaction happens to
431
+ // be open still lands between BEGIN and COMMIT in this log, and is exactly the
432
+ // bug — it is outside the transaction and rolls back with nothing. Caught by
433
+ // probing: dropping the client from updateCriterionGoal left the ordering
434
+ // assertion green. So the connection is asserted, not just the sequence.
435
+ const writes = sqlLog.filter((q) => /UPDATE done_when_criteria/.test(q.sql));
436
+ assert.ok(writes.length > 0, 'the move was written');
437
+ for (const w of writes) {
438
+ assert.equal(w.via, 'client', 'the write must run ON the transaction client, not the pool');
439
+ }
440
+ });
441
+
442
+ await test('the transaction client is always released (task 1003840)', async () => {
443
+ // A leaked client is a pool slot that never comes back; under load that is an
444
+ // outage, and it would not show up in any assertion about the response.
445
+ actor = ARCHON;
446
+ await patch({ goal_id: 3 });
447
+ assert.ok(clients.length > 0, 'a client was checked out');
448
+ for (const c of clients) assert.equal(c.released, true, 'every checked-out client is released');
449
+ });
450
+
451
+ await test('a REFUSED move writes nothing and announces nothing (task 1003840)', async () => {
452
+ // The rollback path. A refusal must leave no row written and no audit entry:
453
+ // recording a move that did not happen would put an edit in the ledger that
454
+ // never landed.
455
+ actor = METIC; // not the owner of goal 3
456
+ goals.set(3, { id: 3, version_id: V1, owner_builder_id: 999, status: 'open' });
457
+ const res = await patch({ goal_id: 3 });
458
+ assert.ok(res.status >= 400, `expected a refusal, got ${res.status}`);
459
+ assert.deepEqual(calls.updateGoal, [], 'no UPDATE reached the pool');
460
+ assert.deepEqual(calls.auditMove, [], 'no move was announced for a write that never landed');
461
+ assert.equal(clients.every((c) => c.released), true, 'the client is released on the refusal path too');
462
+ });
463
+
464
+ await test('FOR UPDATE outside a transaction is refused, not silently downgraded (task 1003840)', async () => {
465
+ // The lock is the whole fix, so asking for one where it cannot be held must be
466
+ // an error rather than a quiet plain read — otherwise a future caller drops the
467
+ // client, keeps the flag, and believes it is safe.
468
+ // The REAL implementation, not this file's stub of it: db.getGoal is replaced
469
+ // above, so asserting against that would only prove the stub ignores the flag.
470
+ // It throws before touching a pool, so this needs no database.
471
+ const realGoals = require('../modules/lifecycle/db-goals.js');
472
+ await assert.rejects(
473
+ () => realGoals.getGoal(3, { forUpdate: true }),
474
+ /requires deps\.client/,
475
+ 'forUpdate without a client must throw',
476
+ );
477
+ });
478
+
479
+
480
+ // ---- CREATE route: the same machinery, the same proof (task 1003840, round 2) --
481
+ //
482
+ // The first cut shipped the CREATE route's transaction with no runtime coverage —
483
+ // only a source-regex on the sentinel shape — while its PATCH sibling got four
484
+ // behavioural tests. Identical machinery deserves identical proof: a regex cannot
485
+ // tell whether the INSERT actually ran on the transaction client.
486
+
487
+ await test('CREATE: the goal is locked, inside the transaction (task 1003840)', async () => {
488
+ actor = ARCHON;
489
+ goalReads.length = 0;
490
+ const r = await post(V1, { criterion_id: 'C9', criterion_md: 'x', goal_id: 3 });
491
+ assert.equal(r.status, 201, JSON.stringify(r.body));
492
+ assert.ok(goalReads.length > 0, 'the route read the goal');
493
+ for (const g of goalReads) {
494
+ assert.ok(g.deps && g.deps.client, 'the goal was read OUTSIDE the transaction');
495
+ assert.equal(g.deps.forUpdate, true, 'the goal was read without FOR UPDATE');
496
+ }
497
+ });
498
+
499
+ await test('CREATE: the INSERT runs ON the transaction client (task 1003840)', async () => {
500
+ actor = ARCHON;
501
+ await post(V1, { criterion_id: 'C9', criterion_md: 'x', goal_id: 3 });
502
+ const inserts = sqlLog.filter((q) => /INSERT INTO done_when_criteria/.test(q.sql));
503
+ assert.equal(inserts.length, 1, 'exactly one INSERT');
504
+ assert.equal(inserts[0].via, 'client', 'the INSERT must not leave the transaction');
505
+ // Its sort_order read is part of the SAME decision and must share the client,
506
+ // or two concurrent creates can read the same next order.
507
+ const maxRead = sqlLog.find((q) => /COALESCE\(MAX\(sort_order\)/.test(q.sql));
508
+ assert.ok(maxRead, 'the sort_order read happened');
509
+ assert.equal(maxRead.via, 'client', 'the read that decides sort_order shares the transaction');
510
+ });
511
+
512
+ await test('CREATE: a refusal writes nothing and releases the client (task 1003840)', async () => {
513
+ actor = METIC;
514
+ goals.set(3, { id: 3, version_id: V1, owner_builder_id: 999, status: 'open' });
515
+ const r = await post(V1, { criterion_id: 'C9', criterion_md: 'x', goal_id: 3 });
516
+ assert.ok(r.status >= 400, `expected a refusal, got ${r.status}`);
517
+ assert.equal(sqlLog.filter((q) => /INSERT INTO done_when_criteria/.test(q.sql)).length, 0, 'nothing was inserted');
518
+ assert.ok(clients.length > 0 && clients.every((c) => c.released), 'the client is released on the refusal path');
519
+ });
520
+
521
+ await test('the VERSION is locked too, not only the goal (task 1003840)', async () => {
522
+ // The task names three facts; the first cut locked one. A plain read is never
523
+ // blocked by another session's row lock under READ COMMITTED, so an unlocked
524
+ // version could still be frozen between this read and this COMMIT.
525
+ actor = ARCHON;
526
+ versionReads.length = 0;
527
+ await post(V1, { criterion_id: 'C9', criterion_md: 'x', goal_id: 3 });
528
+ assert.ok(versionReads.length > 0, 'the route read the version');
529
+ for (const v of versionReads) {
530
+ assert.ok(v.deps && v.deps.client, 'the version was read outside the transaction');
531
+ assert.equal(v.deps.forUpdate, true, 'the version was read without FOR UPDATE');
532
+ }
533
+ });
534
+
535
+ await test('a move locks both goals in ASCENDING ID ORDER, not call order (task 1003840)', async () => {
536
+ // Call order is source-then-destination, so 10->5 and 5->10 running at once
537
+ // would take the two locks in opposite orders and deadlock. One agreed order
538
+ // makes them queue. Asserted for the descending move, where the two differ.
539
+ actor = ARCHON;
540
+ goals.set(10, { id: 10, version_id: V1, owner_builder_id: ARCHON.id, status: 'open' });
541
+ criterion.goal_id = 10;
542
+ goalReads.length = 0;
543
+ const r = await patch({ goal_id: 3 });
544
+ assert.equal(r.status, 200, JSON.stringify(r.body));
545
+ const lockOrder = goalReads.filter((g) => g.deps && g.deps.forUpdate).map((g) => g.id);
546
+ assert.deepEqual(lockOrder, [...lockOrder].sort((a, b) => a - b),
547
+ `goals were locked in ${lockOrder.join(',')} — not ascending id order`);
548
+ assert.deepEqual(lockOrder, [3, 10], 'both ends are locked, lowest id first');
549
+ });
550
+
551
+ await test('setGoalStatus writes on the transaction client (task 1003840)', async () => {
552
+ // The auto-achieve's actual writer. Missed in the first cut, where it kept its
553
+ // own copy of the pool rule — so the UPDATE ran on the pool while the route
554
+ // held that same row FOR UPDATE on its client: a self-deadlock against real
555
+ // Postgres, not merely an untransacted write.
556
+ const realGoals = require('../modules/lifecycle/db-goals.js');
557
+ const seen = [];
558
+ const fakeClient = { query: async (sql, params) => { seen.push({ sql, params }); return { rows: [{ id: 3, status: 'achieved' }] }; } };
559
+ await realGoals.setGoalStatus({ goalId: 3, status: 'achieved' }, { client: fakeClient });
560
+ assert.equal(seen.length, 1, 'the UPDATE reached the injected client');
561
+ assert.match(seen[0].sql, /UPDATE goals/);
562
+ assert.deepEqual(seen[0].params, [3, 'achieved']);
563
+ });
564
+
565
+ // The wrapper installed above is removed, so nothing after this file's own tests
566
+ // inherits a patched module export.
567
+ db.getGoal = realGetGoal;
568
+ db.getVersion = realGetVersion;
569
+
345
570
  await new Promise((r) => server.close(r));
346
571
  console.log(`\ncriterion_goal_attach: ${passed} passed, ${failed} failed`);
347
572
  process.exit(failed === 0 ? 0 : 1);
@@ -100,18 +100,24 @@ test('A2: every ".own"-suffixed key DECLARES scope own — the namespace never o
100
100
  });
101
101
 
102
102
  test('A3: the modelled families are the ones the ownership sweep names', () => {
103
+ // `task` joined the modelled families in task 1002443 (ADR 0297) — the first
104
+ // family added because a ROUTE needed it rather than to complete the model.
103
105
  assert.deepEqual(Object.keys(RESOURCE_FAMILIES).sort(),
104
- ['box', 'builder_prefs', 'claim', 'credential', 'goal', 'memory']);
106
+ ['box', 'builder_prefs', 'claim', 'credential', 'goal', 'memory', 'task']);
105
107
  assert.deepEqual([...ownershipScopedKeys()].sort(),
106
- ['box.manage.own', 'credential.manage.own', 'goal.manage.own', 'me.wandering.set', 'memory.manage.own', 'task.act.own']);
107
- // The cross-owner side is NOT derivable from spelling: box's is `box.fleet.manage`.
108
+ ['box.manage.own', 'credential.manage.own', 'goal.manage.own', 'me.wandering.set', 'memory.manage.own', 'task.abandon.own', 'task.act.own']);
109
+ // The cross-owner side is NOT derivable from spelling: box's is `box.fleet.manage`,
110
+ // and `task.abandon` carries no suffix at all — it predates the axis and was given
111
+ // scope/resource/verb in place rather than renamed, because it is seeded in live
112
+ // rank rows (governance_002) and a key rename is a migration, not a declaration.
108
113
  assert.deepEqual([...crossOwnerKeys()].sort(),
109
- ['box.fleet.manage', 'claim.release.any', 'memory.read.any']);
114
+ ['box.fleet.manage', 'claim.release.any', 'memory.read.any', 'task.abandon']);
110
115
  // The escalation path is DECLARED per key, never derived from the resource
111
- // family (task 1003195). box is the one REAL pair in the catalog today:
112
- // `box.fleet.manage` is the same `manage` verb over every owner's box, so it
113
- // genuinely subsumes managing your own.
116
+ // family (task 1003195). TWO real pairs now: `box.fleet.manage` is the same
117
+ // `manage` verb over every owner's box, and `task.abandon` the same `act` verb
118
+ // over every creator's task, so each genuinely subsumes doing it to your own.
114
119
  assert.deepEqual(crossOwnerKeysFor('box.manage.own'), ['box.fleet.manage']);
120
+ assert.deepEqual(crossOwnerKeysFor('task.abandon.own'), ['task.abandon']);
115
121
  // claim and memory each HAVE an `any` key in their family and still declare no
116
122
  // counterpart — the distinction the family-derived version could not make.
117
123
  // `claim.release.any` carries one third of `act` (release, not cost or notes);