@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
|
@@ -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:
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
}
|
|
49
|
-
|
|
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).
|
|
112
|
-
// `
|
|
113
|
-
// genuinely subsumes
|
|
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);
|