@bongos/core 1.19.813 → 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 +60 -30
- 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 +2 -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-ship.js +59 -5
- package/modules/lifecycle/routes/abandon-handler.js +151 -0
- 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/government_ownership_scope.mjs +13 -7
|
@@ -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();
|
|
@@ -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();
|
package/tests/abandon_task.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
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();
|
|
@@ -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);
|