@bongos/core 1.19.661 → 1.19.663
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 +15 -15
- package/docs/module-api-changelog.md +4 -0
- package/modules/government/catalog.js +126 -16
- package/modules/lifecycle/db-claims.js +52 -25
- package/modules/lifecycle/db-rank-authz.js +48 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/government_claim_eligibility.mjs +132 -10
- package/tests/government_ownership_scope.mjs +210 -19
- package/tests/government_protected_surfaces.mjs +82 -4
package/.bongos-core.json
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
"artifact": "bongos-core",
|
|
3
3
|
"manifest_schema": 1,
|
|
4
4
|
"generator": "scripts/gds/package-core.js",
|
|
5
|
-
"core_version": "1.19.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.663",
|
|
6
|
+
"core_contract": "1.19.663",
|
|
7
|
+
"source_commit": "6acc4ae42df06ed3334386b6ed58fe312ae7e8a0",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-09-
|
|
9
|
+
"built_at": "2026-09-11T03:17:38.613Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
12
|
"docs_redacted": 473,
|
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
19
|
"file_count": 2610,
|
|
20
|
-
"tree_sha256": "
|
|
20
|
+
"tree_sha256": "42ab056c27124e80b07512a8787d678c3d74cef4f3b6dbeac2a248a39fa1dde8",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/ask-for-help/SKILL.md",
|
|
@@ -2787,7 +2787,7 @@
|
|
|
2787
2787
|
{
|
|
2788
2788
|
"path": "docs/module-api-changelog.md",
|
|
2789
2789
|
"mode": "0000644",
|
|
2790
|
-
"sha256": "
|
|
2790
|
+
"sha256": "d5e752a541b45079b1b23eb12eeb282e6f163a676c0d9b9f122fcef2864d120f"
|
|
2791
2791
|
},
|
|
2792
2792
|
{
|
|
2793
2793
|
"path": "docs/modules-contract.md",
|
|
@@ -4487,7 +4487,7 @@
|
|
|
4487
4487
|
{
|
|
4488
4488
|
"path": "modules/government/catalog.js",
|
|
4489
4489
|
"mode": "0000644",
|
|
4490
|
-
"sha256": "
|
|
4490
|
+
"sha256": "aeb505d13076e49bd92bab1d6b3897697b7b69d285c16c551289dba50a489f22"
|
|
4491
4491
|
},
|
|
4492
4492
|
{
|
|
4493
4493
|
"path": "modules/government/config.js",
|
|
@@ -5647,7 +5647,7 @@
|
|
|
5647
5647
|
{
|
|
5648
5648
|
"path": "modules/lifecycle/db-claims.js",
|
|
5649
5649
|
"mode": "0000644",
|
|
5650
|
-
"sha256": "
|
|
5650
|
+
"sha256": "1b0d394ee1788decd9ea713a4379ef5322eda28a2ac11ad7adaf68298e6f4075"
|
|
5651
5651
|
},
|
|
5652
5652
|
{
|
|
5653
5653
|
"path": "modules/lifecycle/db-deps-criteria.js",
|
|
@@ -5672,7 +5672,7 @@
|
|
|
5672
5672
|
{
|
|
5673
5673
|
"path": "modules/lifecycle/db-rank-authz.js",
|
|
5674
5674
|
"mode": "0000644",
|
|
5675
|
-
"sha256": "
|
|
5675
|
+
"sha256": "fb8180f875828664fcd5cb17aa39f68c70b6cea3c52d03fdbd0b3e835f8b1e64"
|
|
5676
5676
|
},
|
|
5677
5677
|
{
|
|
5678
5678
|
"path": "modules/lifecycle/db-shared.js",
|
|
@@ -7752,12 +7752,12 @@
|
|
|
7752
7752
|
{
|
|
7753
7753
|
"path": "package-lock.json",
|
|
7754
7754
|
"mode": "0000644",
|
|
7755
|
-
"sha256": "
|
|
7755
|
+
"sha256": "014c542d80e35f6e6a5572e169232eedf744e09bae83f6798c2d998ca86a18d5"
|
|
7756
7756
|
},
|
|
7757
7757
|
{
|
|
7758
7758
|
"path": "package.json",
|
|
7759
7759
|
"mode": "0000644",
|
|
7760
|
-
"sha256": "
|
|
7760
|
+
"sha256": "56f59c24967d3719f7341742409c6f57ef6551dbfd9600591d201888701dc926"
|
|
7761
7761
|
},
|
|
7762
7762
|
{
|
|
7763
7763
|
"path": "public-docs/index.html",
|
|
@@ -9512,7 +9512,7 @@
|
|
|
9512
9512
|
{
|
|
9513
9513
|
"path": "src/module-api.js",
|
|
9514
9514
|
"mode": "0000644",
|
|
9515
|
-
"sha256": "
|
|
9515
|
+
"sha256": "3d019bf197d89274ef460bd8b86038aca8418d5071d2d76c43479ba1743c556e"
|
|
9516
9516
|
},
|
|
9517
9517
|
{
|
|
9518
9518
|
"path": "src/module-loader/catalog.js",
|
|
@@ -10857,7 +10857,7 @@
|
|
|
10857
10857
|
{
|
|
10858
10858
|
"path": "tests/government_claim_eligibility.mjs",
|
|
10859
10859
|
"mode": "0000644",
|
|
10860
|
-
"sha256": "
|
|
10860
|
+
"sha256": "05a7c8f8d001bd0d2950619fb46f242d358ecae1a8901a10fbb9ecc7857500e7"
|
|
10861
10861
|
},
|
|
10862
10862
|
{
|
|
10863
10863
|
"path": "tests/government_config.mjs",
|
|
@@ -10902,7 +10902,7 @@
|
|
|
10902
10902
|
{
|
|
10903
10903
|
"path": "tests/government_ownership_scope.mjs",
|
|
10904
10904
|
"mode": "0000644",
|
|
10905
|
-
"sha256": "
|
|
10905
|
+
"sha256": "76b81db7769290d5ef5c26ece86ce0610c5aef6c8984ffdd54a6340db66db3bc"
|
|
10906
10906
|
},
|
|
10907
10907
|
{
|
|
10908
10908
|
"path": "tests/government_page_gates.mjs",
|
|
@@ -10922,7 +10922,7 @@
|
|
|
10922
10922
|
{
|
|
10923
10923
|
"path": "tests/government_protected_surfaces.mjs",
|
|
10924
10924
|
"mode": "0000644",
|
|
10925
|
-
"sha256": "
|
|
10925
|
+
"sha256": "3517e05c1264fbdbeadc533884942926689952c8995548863ca1cfbfc365428e"
|
|
10926
10926
|
},
|
|
10927
10927
|
{
|
|
10928
10928
|
"path": "tests/government_rank_sync.mjs",
|
|
@@ -1771,5 +1771,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1771
1771
|
landed since 1.19.659 with no explicit bump. run 34551485764. (task 1002620)
|
|
1772
1772
|
1.19.661 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1773
1773
|
landed since 1.19.660 with no explicit bump. run 34552525938. (task 1002620)
|
|
1774
|
+
1.19.662 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1775
|
+
landed since 1.19.661 with no explicit bump. run 34553965204. (task 1002620)
|
|
1776
|
+
1.19.663 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1777
|
+
landed since 1.19.662 with no explicit bump. run 34557840364. (task 1002620)
|
|
1774
1778
|
---------------------------------------------------------------------------
|
|
1775
1779
|
```
|
|
@@ -201,7 +201,7 @@ const PERMISSIONS = [
|
|
|
201
201
|
{ key: 'criterion.unsatisfy', system: false, floor: 'metic', guards: 'POST /done-when/:id/unsatisfy (ADR 0157, was archon)' },
|
|
202
202
|
{ key: 'security.report.adjudicate', system: false, floor: 'metic', guards: 'GET /security/reports, POST /security/reports/:id/{confirm,dispute,fix,link-fix}, /security/docs CRUD (ADR 0157, was archon)' },
|
|
203
203
|
{ key: 'provisioning.fleet.manage', system: false, floor: 'metic', guards: 'GET /provisioning/fleet[/cost-ledger], POST .../force-teardown (ADR 0157, was archon)' },
|
|
204
|
-
{ key: 'box.fleet.manage', system: false, floor: 'metic', scope: 'any', resource: 'box', guards: 'GET /boxes[/cost-ledger], POST /boxes/:b/close, PATCH .../block (ADR 0157, was archon)' },
|
|
204
|
+
{ key: 'box.fleet.manage', system: false, floor: 'metic', scope: 'any', resource: 'box', verb: 'manage', guards: 'GET /boxes[/cost-ledger], POST /boxes/:b/close, PATCH .../block (ADR 0157, was archon)' },
|
|
205
205
|
{ key: 'autonomy.precheck', system: false, floor: 'metic', guards: 'GET /autonomy/precheck — unsupervised-runner gate (ADR 0157, was archon)' },
|
|
206
206
|
{ key: 'discord.channels.manage', system: false, floor: 'metic', guards: 'GET /discord/channels/plan, POST .../reconcile (ADR 0157, was archon)' },
|
|
207
207
|
// Metic by the discord.channels.manage analogue — configuring a project-wide
|
|
@@ -213,7 +213,7 @@ const PERMISSIONS = [
|
|
|
213
213
|
{ key: 'project.settings.manage', system: false, floor: 'metic', guards: "PATCH /project-settings/* — the project's own live knobs (the rot timer today) and the work-category set's shape (PATCH /work-categories/:id). Metic, matching goal.create's reasoning that extending the project's vocabulary is a planning act rather than an administrative one — task 1003280, goal 1000072" },
|
|
214
214
|
{ key: 'project.curate', system: false, floor: 'metic', guards: 'platform-identity: GET/POST /projects, /projects/invite, PATCH /projects/:id/featured, PATCH /projects/:id/status (the takedown lever — ADR 0252 §5.1), GET /scouting (ADR 0157, was archon)' },
|
|
215
215
|
{ key: 'override_request.decide', system: false, floor: 'metic', guards: 'GET /override-requests, POST .../:id/decide (ADR 0157, was archon)' },
|
|
216
|
-
{ key: 'claim.release.any', system: false, floor: 'metic', scope: 'any', resource: 'claim', guards: 'POST /claims/:id/release-on-behalf (ADR 0157, was archon)' },
|
|
216
|
+
{ key: 'claim.release.any', system: false, floor: 'metic', scope: 'any', resource: 'claim', verb: 'release', guards: 'POST /claims/:id/release-on-behalf (ADR 0157, was archon)' },
|
|
217
217
|
{ key: 'idea.ratify', system: false, floor: 'metic', guards: 'POST /inbox/:id/ratify (ADR 0157, was archon)' },
|
|
218
218
|
// ── the Board Room (ADR 0175, goal 1000069) ──────────────────────────────────
|
|
219
219
|
// Declared BEFORE their routes exist (R07/R08 add them), and deliberately so:
|
|
@@ -236,7 +236,7 @@ const PERMISSIONS = [
|
|
|
236
236
|
{ key: 'goal.reorder', system: false, floor: 'metic', guards: 'POST /goals/reorder (ADR 0157, was archon)' },
|
|
237
237
|
{ key: 'builder.roster.read', system: false, floor: 'metic', guards: 'GET /builders/roster, /grades/by-builder, /builders/:id/active-claims (ADR 0157, was archon)' },
|
|
238
238
|
{ key: 'session.search', system: false, floor: 'metic', guards: 'GET /sessions/search (ADR 0157, was archon)' },
|
|
239
|
-
{ key: 'memory.read.any', system: false, floor: 'metic', scope: 'any', resource: 'memory', guards: 'GET /memory/builders/:id/file[s] — cross-builder memory read (ADR 0157, was archon)' },
|
|
239
|
+
{ key: 'memory.read.any', system: false, floor: 'metic', scope: 'any', resource: 'memory', verb: 'read', guards: 'GET /memory/builders/:id/file[s] — cross-builder memory read (ADR 0157, was archon)' },
|
|
240
240
|
{ key: 'audit_log.read', system: false, floor: 'metic', guards: 'GET /audit-log (ADR 0157, was archon)' },
|
|
241
241
|
// ADR 0157 §4. Split from `gate_approval.decide` (system, archon) so the Gate PAGE
|
|
242
242
|
// can open to Metic while APPROVING a held PR stays Archon. Without the split, a
|
|
@@ -265,20 +265,32 @@ const PERMISSIONS = [
|
|
|
265
265
|
// deliberately NOT lowered to xenos here (the board.vote.cast option): a floor
|
|
266
266
|
// feeds RANK_SEED and the live reset route, so widening it is an authority change
|
|
267
267
|
// that wants its own decision, not a note in a hardening batch.
|
|
268
|
-
{ key: 'goal.manage.own', system: false, floor: 'metic', scope: 'own', resource: 'goal', guards: 'PATCH /goals/:id/members/:b, POST /goals/:id/transfer — requireBuilder + in-handler owner|manager|Archon (ADR 0106); floor is not the whole gate, and sub-metic owners/managers hold it today' },
|
|
268
|
+
{ key: 'goal.manage.own', system: false, floor: 'metic', scope: 'own', resource: 'goal', verb: 'manage', counterpart: [], guards: 'PATCH /goals/:id/members/:b, POST /goals/:id/transfer — requireBuilder + in-handler owner|manager|Archon (ADR 0106); floor is not the whole gate, and sub-metic owners/managers hold it today' },
|
|
269
269
|
{ key: 'task.claim.newcomer', system: false, floor: 'xenos', guards: 'claim a newcomer_friendly task (xenosClaimAllowed; xenos = one active claim)' },
|
|
270
270
|
{ key: 'task.ship', system: false, floor: 'xenos', guards: '/builder-ship own claim' },
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
271
|
+
// counterpart is EMPTY, and that is the fix of task 1003195, not an omission.
|
|
272
|
+
// `act` is a BUNDLE — release + cost + notes — and the family's only cross-owner
|
|
273
|
+
// key, `claim.release.any`, carries just the release third. It is therefore not a
|
|
274
|
+
// counterpart: admitting on it would let a release-only grant post costs and notes
|
|
275
|
+
// against another builder's claim. A future `claim.manage.any` (verb `manage`)
|
|
276
|
+
// would dominate `act` and belongs here; `claim.release.any` never does.
|
|
277
|
+
{ key: 'task.act.own', system: false, floor: 'xenos', scope: 'own', resource: 'claim', verb: 'act', counterpart: [], guards: 'act on OWN active claim (release, cost, notes) — ownership-scoped' },
|
|
278
|
+
// The one family whose counterpart is REAL: `box.fleet.manage` is the same `manage`
|
|
279
|
+
// verb over every owner's box, so it genuinely subsumes managing your own.
|
|
280
|
+
{ key: 'box.manage.own', system: false, floor: 'xenos', scope: 'own', resource: 'box', verb: 'manage', counterpart: ['box.fleet.manage'], guards: 'own dev-box lifecycle — ownership-scoped' },
|
|
281
|
+
// Empty for the same reason as task.act.own: `memory.read.any` READS every
|
|
282
|
+
// builder's memory, which is not the authority to MANAGE one. This is the pair the
|
|
283
|
+
// task-1002671 verification probed (finding 12) — a read grant was admitting a
|
|
284
|
+
// write. A future `memory.manage.any` would belong here.
|
|
285
|
+
{ key: 'memory.manage.own', system: false, floor: 'xenos', scope: 'own', resource: 'memory', verb: 'manage', counterpart: [], guards: 'own builder memory — ownership-scoped' },
|
|
274
286
|
// No cross-owner counterpart, deliberately: nobody manages another builder's
|
|
275
287
|
// credential, Archon included. A `credential.manage.any` would be a key no route
|
|
276
288
|
// consumes — the catalog's first rule forbids minting one for symmetry.
|
|
277
|
-
{ key: 'credential.manage.own', system: false, floor: 'xenos', scope: 'own', resource: 'credential', guards: 'own API key / credential — ownership-scoped' },
|
|
289
|
+
{ key: 'credential.manage.own', system: false, floor: 'xenos', scope: 'own', resource: 'credential', verb: 'manage', counterpart: [], guards: 'own API key / credential — ownership-scoped' },
|
|
278
290
|
// Own-scoped WITHOUT a `.own` suffix — the `me.` namespace is the ownership
|
|
279
291
|
// marker here. Exactly why scope is declared data: the suffix rule only runs the
|
|
280
292
|
// safe direction ('.own' ⇒ scope 'own'), never the converse.
|
|
281
|
-
{ key: 'me.wandering.set', system: false, floor: 'xenos', scope: 'own', resource: 'builder_prefs', guards: 'PATCH /me/wandering (clamped by maxLevelForRank) — ownership-scoped' },
|
|
293
|
+
{ key: 'me.wandering.set', system: false, floor: 'xenos', scope: 'own', resource: 'builder_prefs', verb: 'set', counterpart: [], guards: 'PATCH /me/wandering (clamped by maxLevelForRank) — ownership-scoped' },
|
|
282
294
|
{ key: 'cost.log', system: false, floor: 'xenos', guards: 'POST /cost' },
|
|
283
295
|
{ key: 'learning.create', system: false, floor: 'xenos', guards: 'POST /learnings' },
|
|
284
296
|
{ key: 'blocker.file', system: false, floor: 'xenos', guards: 'POST /blockers' },
|
|
@@ -296,6 +308,31 @@ const PERMISSIONS = [
|
|
|
296
308
|
const PRINCIPAL_KEY_PREFIX = 'principal.';
|
|
297
309
|
const OWN_KEY_SUFFIX = '.own';
|
|
298
310
|
const OWNERSHIP_SCOPES = Object.freeze(['own', 'any']);
|
|
311
|
+
|
|
312
|
+
// ── the VERB axis of an ownership-scoped key (task 1003195) ────────────────────
|
|
313
|
+
// Scope answers "whose row"; VERB answers "how much of it". They are independent,
|
|
314
|
+
// and conflating them is exactly the bug this vocabulary closes: `crossOwnerKeysFor`
|
|
315
|
+
// used to hand back the resource family's whole `any` list VERB-BLIND, so holding
|
|
316
|
+
// only `memory.read.any` satisfied a `memory.manage.own` decision on someone else's
|
|
317
|
+
// row — a read grant admitting a write.
|
|
318
|
+
//
|
|
319
|
+
// The verb is DECLARED, never parsed out of the key string. A positional parse is
|
|
320
|
+
// wrong on the live population (`box.fleet.manage` is resource `box`, verb `manage`
|
|
321
|
+
// — the middle segment is not the verb), and the same reason `scope` is data:
|
|
322
|
+
// "the namespace can never outrun the data".
|
|
323
|
+
const OWNERSHIP_VERBS = Object.freeze(['read', 'set', 'release', 'act', 'manage']);
|
|
324
|
+
// Which verbs a verb SUBSUMES, beyond itself (every verb dominates itself). Kept
|
|
325
|
+
// deliberately small and explicit: `manage` is the full-control verb, so it covers
|
|
326
|
+
// every narrower slice; nothing else subsumes anything. A future pair that needs a
|
|
327
|
+
// wider relation adds one entry here and states why — it does not get inferred.
|
|
328
|
+
const VERB_DOMINANCE = Object.freeze({
|
|
329
|
+
manage: Object.freeze(['read', 'set', 'release', 'act']),
|
|
330
|
+
});
|
|
331
|
+
// Does `wide` carry at least everything `narrow` carries? Unknown verbs fail closed.
|
|
332
|
+
function verbDominates(wide, narrow) {
|
|
333
|
+
if (!OWNERSHIP_VERBS.includes(wide) || !OWNERSHIP_VERBS.includes(narrow)) return false;
|
|
334
|
+
return wide === narrow || (VERB_DOMINANCE[wide] || []).includes(narrow);
|
|
335
|
+
}
|
|
299
336
|
const _byKey = new Map();
|
|
300
337
|
const _byPrincipal = new Map();
|
|
301
338
|
const _byResource = new Map();
|
|
@@ -376,6 +413,36 @@ for (let i = 0; i < PERMISSIONS.length; i++) {
|
|
|
376
413
|
if (scope !== null && p.floor === null) {
|
|
377
414
|
throw new Error(`government catalog: ${p.key} is ownership-scoped but has no rank floor — the ownership axis narrows a RANK permission, never an identity one`);
|
|
378
415
|
}
|
|
416
|
+
// ── the VERB axis (task 1003195) ──────────────────────────────────────────
|
|
417
|
+
// Same partition posture as scope ⟺ resource: an ownership-scoped key declares
|
|
418
|
+
// HOW MUCH of the row it reaches, and an unscoped key has no owner axis to
|
|
419
|
+
// qualify. Both directions throw, so neither field can be added without the other.
|
|
420
|
+
const verb = p.verb ?? null;
|
|
421
|
+
if ((scope === null) !== (verb === null)) {
|
|
422
|
+
throw new Error(`government catalog: ${p.key} — scope and verb must come together (an ownership scope always names the verb it reaches the row with)`);
|
|
423
|
+
}
|
|
424
|
+
if (verb !== null && !OWNERSHIP_VERBS.includes(verb)) {
|
|
425
|
+
throw new Error(`government catalog: ${p.key} has unknown verb "${verb}" (expected one of ${OWNERSHIP_VERBS.join(', ')})`);
|
|
426
|
+
}
|
|
427
|
+
// ── the COUNTERPART declaration (task 1003195) ────────────────────────────
|
|
428
|
+
// REQUIRED on every own-scoped key and forbidden everywhere else. Required,
|
|
429
|
+
// because the bug being fixed was a silent DERIVATION: crossOwnerKeysFor fell
|
|
430
|
+
// back to the resource family's whole `any` list, so a new own key inherited
|
|
431
|
+
// every cross-owner key in its family — verbs unchecked — merely by existing.
|
|
432
|
+
// Making the escalation path an explicit per-key statement means a new own key
|
|
433
|
+
// fails LOUD at load until its author decides what may escalate past it, and
|
|
434
|
+
// `counterpart: []` is that decision written down, not its absence.
|
|
435
|
+
const hasCounterpart = p.counterpart !== undefined;
|
|
436
|
+
if (scope === 'own' && !hasCounterpart) {
|
|
437
|
+
throw new Error(`government catalog: ${p.key} is own-scoped and must declare counterpart: [] explicitly — the cross-owner escalation path is never derived from the resource family (task 1003195)`);
|
|
438
|
+
}
|
|
439
|
+
if (scope !== 'own' && hasCounterpart) {
|
|
440
|
+
throw new Error(`government catalog: ${p.key} declares a counterpart but is not own-scoped — only an ownership-scoped key has an escalation path`);
|
|
441
|
+
}
|
|
442
|
+
if (hasCounterpart && (!Array.isArray(p.counterpart) || p.counterpart.some((k) => typeof k !== 'string' || !k))) {
|
|
443
|
+
throw new Error(`government catalog: ${p.key} — counterpart must be an array of permission keys`);
|
|
444
|
+
}
|
|
445
|
+
const counterpart = hasCounterpart ? Object.freeze([...p.counterpart]) : null;
|
|
379
446
|
// ONE object per permission, shared by every reader. The frozen copy goes back
|
|
380
447
|
// into PERMISSIONS[i] as well as the lookup maps, because the alternative —
|
|
381
448
|
// freezing a COPY and leaving the original mutable in the array — splits the
|
|
@@ -383,7 +450,7 @@ for (let i = 0; i < PERMISSIONS.length; i++) {
|
|
|
383
450
|
// seals the array's SHAPE but not its entries, so a runtime mutation of
|
|
384
451
|
// PERMISSIONS[i] would change the floor seedForRank() reads (and with it the
|
|
385
452
|
// live POST /government/ranks/:rankKey/reset) while byKey() kept the old value.
|
|
386
|
-
const frozen = Object.freeze({ ...p, principal, scope, resource, substrate });
|
|
453
|
+
const frozen = Object.freeze({ ...p, principal, scope, resource, verb, counterpart, substrate });
|
|
387
454
|
PERMISSIONS[i] = frozen;
|
|
388
455
|
_byKey.set(p.key, frozen);
|
|
389
456
|
if (principal !== null) _byPrincipal.set(principal, frozen);
|
|
@@ -416,6 +483,32 @@ for (const [resource, fam] of _byResource) {
|
|
|
416
483
|
}
|
|
417
484
|
}
|
|
418
485
|
|
|
486
|
+
// ── the counterpart invariant: a declared escalation path must DOMINATE ────────
|
|
487
|
+
// The floor check above orders the two keys by PRICE; this one orders them by
|
|
488
|
+
// POWER, and only the pair together is the wall. A counterpart is an admit: the
|
|
489
|
+
// resolver returns true on it WITHOUT consulting the owner at all, so whatever it
|
|
490
|
+
// names must carry at least everything the own key carries. Naming a narrower verb
|
|
491
|
+
// converts the own key into that verb's cross-owner form for free — which is
|
|
492
|
+
// precisely how `memory.read.any` came to satisfy `memory.manage.own`.
|
|
493
|
+
for (const p of PERMISSIONS) {
|
|
494
|
+
if (p.scope !== 'own') continue;
|
|
495
|
+
for (const key of p.counterpart) {
|
|
496
|
+
const c = _byKey.get(key);
|
|
497
|
+
if (!c) {
|
|
498
|
+
throw new Error(`government catalog: ${p.key} names counterpart "${key}", which is not a permission in this catalog`);
|
|
499
|
+
}
|
|
500
|
+
if (c.scope !== 'any') {
|
|
501
|
+
throw new Error(`government catalog: ${p.key} names counterpart ${key}, which is not cross-owner (scope "${c.scope ?? 'none'}") — only an 'any' key reaches another owner's row`);
|
|
502
|
+
}
|
|
503
|
+
if (c.resource !== p.resource) {
|
|
504
|
+
throw new Error(`government catalog: ${p.key} (resource ${p.resource}) names counterpart ${key} from resource ${c.resource} — an escalation path never crosses resource families`);
|
|
505
|
+
}
|
|
506
|
+
if (!verbDominates(c.verb, p.verb)) {
|
|
507
|
+
throw new Error(`government catalog: ${p.key} names counterpart ${key}, but verb "${c.verb}" does not dominate "${p.verb}" — a cross-owner key may only satisfy an own key it carries at least as much authority as (task 1003195)`);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
|
|
419
512
|
// resource → { own: [keys], any: [keys] }. The pairing data a gate, a test, or the
|
|
420
513
|
// Archon tab reads to relate "act on own X" to "act on any X"; DERIVED from the
|
|
421
514
|
// rows above so it cannot drift from them.
|
|
@@ -547,6 +640,12 @@ function resourceOf(key) {
|
|
|
547
640
|
const p = _byKey.get(key);
|
|
548
641
|
return p ? p.resource : null;
|
|
549
642
|
}
|
|
643
|
+
// How much of the row this key reaches. Null for an unscoped or unknown key — the
|
|
644
|
+
// verb only means anything alongside an ownership scope.
|
|
645
|
+
function verbOf(key) {
|
|
646
|
+
const p = _byKey.get(key);
|
|
647
|
+
return p ? p.verb : null;
|
|
648
|
+
}
|
|
550
649
|
// Does holding this key require an ownership predicate against the concrete row?
|
|
551
650
|
// This is the question a gate must ask BEFORE treating a held permission as an
|
|
552
651
|
// admit — an ownership-scoped key is never sufficient on its own.
|
|
@@ -563,14 +662,21 @@ function ownershipScopedKeys() {
|
|
|
563
662
|
function crossOwnerKeys() {
|
|
564
663
|
return PERMISSIONS.filter((p) => p.scope === 'any').map((p) => p.key);
|
|
565
664
|
}
|
|
566
|
-
// The
|
|
567
|
-
// the
|
|
568
|
-
//
|
|
665
|
+
// The DECLARED escalation path for `key` — what a route offers as the alternative
|
|
666
|
+
// to owning the row ("your own box, or box.fleet.manage"). Empty when nothing may
|
|
667
|
+
// escalate past it, which is the common case.
|
|
668
|
+
//
|
|
669
|
+
// Read straight off the key's own `counterpart`, NOT derived from its resource
|
|
670
|
+
// family (task 1003195). The family form was verb-blind: it handed back every `any`
|
|
671
|
+
// key over the resource, so `memory.read.any` — a READ grant — satisfied a
|
|
672
|
+
// `memory.manage.own` WRITE decision on another builder's row. Every entry returned
|
|
673
|
+
// here has been checked at load to be cross-owner, same-resource, and carrying a
|
|
674
|
+
// verb that dominates this key's, so the resolver can admit on one without
|
|
675
|
+
// consulting the owner. An unknown or non-own key has no path at all.
|
|
569
676
|
const NO_KEYS = Object.freeze([]);
|
|
570
677
|
function crossOwnerKeysFor(key) {
|
|
571
|
-
const
|
|
572
|
-
|
|
573
|
-
return fam ? fam.any : NO_KEYS;
|
|
678
|
+
const p = _byKey.get(key);
|
|
679
|
+
return p && p.counterpart ? p.counterpart : NO_KEYS;
|
|
574
680
|
}
|
|
575
681
|
|
|
576
682
|
// The compat seed, DERIVED from floors (cumulative supersets). A rank holds
|
|
@@ -615,4 +721,8 @@ module.exports = {
|
|
|
615
721
|
ownershipScopedKeys,
|
|
616
722
|
crossOwnerKeys,
|
|
617
723
|
crossOwnerKeysFor,
|
|
724
|
+
OWNERSHIP_VERBS,
|
|
725
|
+
VERB_DOMINANCE,
|
|
726
|
+
verbDominates,
|
|
727
|
+
verbOf,
|
|
618
728
|
};
|
|
@@ -15,10 +15,15 @@ const { pool, withTx } = api;
|
|
|
15
15
|
const log = api.logger('lifecycle');
|
|
16
16
|
const seams = api;
|
|
17
17
|
const { touchesOverlap } = api.pathMatch;
|
|
18
|
-
|
|
18
|
+
// CLAIM_ANY / CLAIM_NEWCOMER name the two atoms in the refusal message. The
|
|
19
|
+
// decision itself moved to db-rank-authz.claimGate (task 1003196), so this file
|
|
20
|
+
// no longer calls claimEligibilityAllows / rankAllowsTask / xenosClaimAllowed
|
|
21
|
+
// directly — composing them at three separate call sites is what made the gate
|
|
22
|
+
// un-testable except by counting.
|
|
23
|
+
const { CLAIM_ANY, CLAIM_NEWCOMER } = require('./claim-eligibility.js');
|
|
19
24
|
const { goalMembershipFailures } = require('./claim-feed.js');
|
|
20
25
|
const { unsatisfiedDeps } = require('./db-deps-criteria.js');
|
|
21
|
-
const { claimPermissionsFor, disambiguateWorktreeName, foldWorktreeName
|
|
26
|
+
const { claimGate, claimPermissionsFor, disambiguateWorktreeName, foldWorktreeName } = require('./db-rank-authz.js');
|
|
22
27
|
const { sealSessionLog } = require('./db-shared.js');
|
|
23
28
|
const { needsRebaseTasksForBuilder, shouldRefuseClaimResolve } = require('./db-ship.js');
|
|
24
29
|
const { REWARD_SUGGESTION_CAP, rewardGateAssignment } = require('./db-tasks.js');
|
|
@@ -142,8 +147,18 @@ async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSess
|
|
|
142
147
|
// is kept as an alias on the error object for callers on the pre-rename
|
|
143
148
|
// release. BV1.R106 (ADR 0151): same rule, now decided on the PERMISSIONS
|
|
144
149
|
// task.claim.any / task.claim.newcomer — see ./claim-eligibility.js.
|
|
150
|
+
// task 1003196: the queue+floor composition is decided ONCE, in
|
|
151
|
+
// db-rank-authz.claimGate, so a test can drive the real decision instead of
|
|
152
|
+
// counting `if`s in this file. The refusal SHAPES below are unchanged — that
|
|
153
|
+
// is why claimGate returns which gates refused rather than an error object.
|
|
145
154
|
const newcomerFriendly = task.newcomer_friendly;
|
|
146
|
-
|
|
155
|
+
const claimRefusals = claimGate({
|
|
156
|
+
builderRank,
|
|
157
|
+
newcomerFriendly,
|
|
158
|
+
requiresRank: task.requires_rank,
|
|
159
|
+
heldPermissions,
|
|
160
|
+
});
|
|
161
|
+
if (claimRefusals.includes('queue')) {
|
|
147
162
|
// task 1002613 (map §4.4): the bare code reported a PERMISSION decision as
|
|
148
163
|
// a rank one — no axis, no remedy. Copy INSUFFICIENT_RANK's shape below:
|
|
149
164
|
// name both sides of the wall and the way forward.
|
|
@@ -168,7 +183,7 @@ async function claimTask({ taskId, builderId, worktreeName, headSha, creatorSess
|
|
|
168
183
|
// blocks Xenos+Metic. The DB CHECK on tasks.requires_rank enforces the
|
|
169
184
|
// enum, so we trust the value here. INSUFFICIENT_RANK carries the actual
|
|
170
185
|
// and required ranks so the caller can render a clean error.
|
|
171
|
-
if (
|
|
186
|
+
if (claimRefusals.includes('floor')) {
|
|
172
187
|
throw {
|
|
173
188
|
code: 'INSUFFICIENT_RANK',
|
|
174
189
|
required: task.requires_rank,
|
|
@@ -496,25 +511,32 @@ async function claimTasksBatch({ taskIds, builderId, worktreePrefix, creatorSess
|
|
|
496
511
|
// never checked here, while claimTask checks every rank), so removing it would
|
|
497
512
|
// change who can batch-claim. That pre-existing asymmetry is reported, not
|
|
498
513
|
// fixed, under this task — closing it is an authority decision of its own.
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
514
|
+
//
|
|
515
|
+
// V3.R82 (#301): the per-task requires_rank floor rides the same decision.
|
|
516
|
+
// Mirrors the single claimTask gate so a Metic can't slip through batch-claim
|
|
517
|
+
// to grab an Archon-only task. The xenos sandbox check handles the xenos case
|
|
518
|
+
// exhaustively for requires_rank='xenos' (the default); the floor gate refuses
|
|
519
|
+
// requires_rank='metic'/'archon' when the builder is below.
|
|
520
|
+
//
|
|
521
|
+
// task 1003196: both gates now come from db-rank-authz.claimGate. A row can
|
|
522
|
+
// still collect BOTH refusals, and the two passes below are deliberate — this
|
|
523
|
+
// path groups every queue refusal ahead of every floor refusal, and `failures`
|
|
524
|
+
// is returned to the caller in order, so folding them into one pass per row
|
|
525
|
+
// would reorder a response for no reason.
|
|
526
|
+
const refusalsFor = (t) => claimGate({
|
|
527
|
+
builderRank,
|
|
528
|
+
newcomerFriendly: t.newcomer_friendly,
|
|
529
|
+
requiresRank: t.requires_rank,
|
|
530
|
+
heldPermissions,
|
|
531
|
+
queueGateApplies: builderRank === 'xenos',
|
|
532
|
+
});
|
|
533
|
+
for (const t of taskRows) {
|
|
534
|
+
if (refusalsFor(t).includes('queue')) {
|
|
535
|
+
failures.push({ task_id: t.id, reason: 'rank_too_low_for_task', legacy_reason: 'XENOS_RESTRICTED' });
|
|
508
536
|
}
|
|
509
537
|
}
|
|
510
|
-
|
|
511
|
-
// V3.R82 (#301): the per-task requires_rank floor. Mirrors the single
|
|
512
|
-
// claimTask gate so a Metic can't slip through batch-claim to grab an
|
|
513
|
-
// Archon-only task. The xenos sandbox check above handles the xenos case
|
|
514
|
-
// exhaustively for requires_rank='xenos' (the default); this gate refuses
|
|
515
|
-
// requires_rank='metic'/'archon' when the builder is below.
|
|
516
538
|
for (const t of taskRows) {
|
|
517
|
-
if (
|
|
539
|
+
if (refusalsFor(t).includes('floor')) {
|
|
518
540
|
failures.push({
|
|
519
541
|
task_id: t.id,
|
|
520
542
|
reason: 'INSUFFICIENT_RANK',
|
|
@@ -855,14 +877,19 @@ async function validateClaimBatch({ taskIds, builderId }) {
|
|
|
855
877
|
const t = byId.get(id);
|
|
856
878
|
if (!t) { push(id, { reason: 'TASK_NOT_FOUND' }); continue; }
|
|
857
879
|
if (t.status !== 'ready') push(id, { reason: 'TASK_NOT_READY', currentStatus: t.status });
|
|
858
|
-
|
|
859
|
-
|
|
880
|
+
// task 1003196: same composed decision as claimTask and claimTasksBatch, and
|
|
881
|
+
// the same deliberate xenos-only queue short-circuit the batch path carries.
|
|
882
|
+
const refusals = claimGate({
|
|
883
|
+
builderRank,
|
|
860
884
|
newcomerFriendly: t.newcomer_friendly,
|
|
861
|
-
|
|
862
|
-
|
|
885
|
+
requiresRank: t.requires_rank,
|
|
886
|
+
heldPermissions,
|
|
887
|
+
queueGateApplies: builderRank === 'xenos',
|
|
888
|
+
});
|
|
889
|
+
if (refusals.includes('queue')) {
|
|
863
890
|
push(id, { reason: 'rank_too_low_for_task', legacy_reason: 'XENOS_RESTRICTED' });
|
|
864
891
|
}
|
|
865
|
-
if (
|
|
892
|
+
if (refusals.includes('floor')) {
|
|
866
893
|
push(id, { reason: 'INSUFFICIENT_RANK', required: t.requires_rank, actual: builderRank });
|
|
867
894
|
}
|
|
868
895
|
}
|
|
@@ -14,6 +14,7 @@ const api = require('../../src/module-api');
|
|
|
14
14
|
const { pool, LIVE_RANK_LADDER, RANK_TIER } = api;
|
|
15
15
|
const seams = api;
|
|
16
16
|
const { matchProtected } = api.permissionPathCheck;
|
|
17
|
+
const { claimEligibilityAllows } = require('./claim-eligibility.js');
|
|
17
18
|
|
|
18
19
|
// -------------------------------------------------------------------------
|
|
19
20
|
// Builders
|
|
@@ -166,6 +167,51 @@ function rankAllowsTask(builderRank, requiredRank) {
|
|
|
166
167
|
return have >= needed;
|
|
167
168
|
}
|
|
168
169
|
|
|
170
|
+
// ── the COMPOSED per-task claim decision (task 1003196) ──────────────────────
|
|
171
|
+
//
|
|
172
|
+
// Claim eligibility is TWO independent gates — the QUEUE axis (permission atoms,
|
|
173
|
+
// ./claim-eligibility.js) and the FLOOR axis (`requires_rank`, rankAllowsTask
|
|
174
|
+
// above) — and the rule that matters is that BOTH must pass and neither
|
|
175
|
+
// substitutes for the other. That composition used to exist only as a pair of
|
|
176
|
+
// consecutive `if`s repeated at each of the three claim paths, which meant the
|
|
177
|
+
// only thing a test could check was that the source still CONTAINED them:
|
|
178
|
+
// `tests/government_claim_eligibility.mjs` counted occurrences, and neutering
|
|
179
|
+
// claimTask's gate with `&& false` left the count at 3 and the suite green
|
|
180
|
+
// (finding 10 of the task-1002671 verification). Counting is not a behaviour
|
|
181
|
+
// oracle, so the decision lives here, once, where a test can actually drive it.
|
|
182
|
+
//
|
|
183
|
+
// RETURNS THE GATES THAT REFUSED, in ladder order — `[]` means allowed. It
|
|
184
|
+
// deliberately does NOT return a ready-made error: each call site shapes its own
|
|
185
|
+
// refusal and those shapes are API surface (claimTask THROWS
|
|
186
|
+
// `{code:'rank_too_low_for_task', …, message, hint}`, while the batch paths PUSH
|
|
187
|
+
// `{task_id, reason, legacy_reason}` and accumulate both refusals for one row).
|
|
188
|
+
// Returning a decision instead of a wire error is what lets all three delegate
|
|
189
|
+
// without any of them changing what a caller sees.
|
|
190
|
+
//
|
|
191
|
+
// `queueGateApplies` carries a PRE-EXISTING asymmetry rather than quietly fixing
|
|
192
|
+
// it: claimTask runs the queue gate for every rank, while claimTasksBatch and
|
|
193
|
+
// validateClaimBatch run it only when `builderRank === 'xenos'`. That outer
|
|
194
|
+
// short-circuit is semantic, not a fast path — for the SEEDED sets the two agree
|
|
195
|
+
// exactly (thetes+ all hold task.claim.any), but a custom rank granted neither
|
|
196
|
+
// claim atom would be refused by claimTask and admitted by the batch paths.
|
|
197
|
+
// Closing that is an authority decision of its own, already reported and
|
|
198
|
+
// deliberately deferred at the call site; this parameter keeps each path's
|
|
199
|
+
// current meaning exact while still routing it through one decision.
|
|
200
|
+
const CLAIM_GATES = Object.freeze(['queue', 'floor']);
|
|
201
|
+
|
|
202
|
+
function claimGate({ builderRank, newcomerFriendly, requiresRank, heldPermissions, queueGateApplies = true }) {
|
|
203
|
+
const refused = [];
|
|
204
|
+
if (queueGateApplies && !claimEligibilityAllows({
|
|
205
|
+
heldPermissions,
|
|
206
|
+
newcomerFriendly,
|
|
207
|
+
rankRuleAllows: xenosClaimAllowed(builderRank, newcomerFriendly),
|
|
208
|
+
})) {
|
|
209
|
+
refused.push('queue');
|
|
210
|
+
}
|
|
211
|
+
if (!rankAllowsTask(builderRank, requiresRank)) refused.push('floor');
|
|
212
|
+
return Object.freeze(refused);
|
|
213
|
+
}
|
|
214
|
+
|
|
169
215
|
// deriveRequiredRank — task 1498 / ADR 0084. The per-task requires_rank floor
|
|
170
216
|
// (V3.R82 #301) shipped INERT: no create/edit path ever set it, so every task
|
|
171
217
|
// sat at the 'xenos' default and the claim gate never bit. We now DERIVE a floor
|
|
@@ -477,6 +523,8 @@ module.exports = {
|
|
|
477
523
|
RANKS,
|
|
478
524
|
REQUIRES_RANK_TIER,
|
|
479
525
|
THETES_GRADUATION_THRESHOLD,
|
|
526
|
+
CLAIM_GATES,
|
|
527
|
+
claimGate,
|
|
480
528
|
claimPermissionsFor,
|
|
481
529
|
countShippedTasksForBuilder,
|
|
482
530
|
deriveRequiredRank,
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.663",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.663",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.663",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.663'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -154,16 +154,138 @@ test('the permission predicate cannot see requires_rank at all (structural)', ()
|
|
|
154
154
|
}
|
|
155
155
|
});
|
|
156
156
|
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
157
|
+
// ── the COMPOSED gate, driven behaviourally (task 1003196, finding 10) ───────
|
|
158
|
+
//
|
|
159
|
+
// WHAT THIS REPLACED, AND WHY. Until task 1003196 this section grepped the
|
|
160
|
+
// lifecycle source for three occurrences of `rankAllowsTask(builderRank,
|
|
161
|
+
// t.requires_rank)` and three of `claimEligibilityAllows({`. That verifies
|
|
162
|
+
// PRESENCE, not EFFECT: neutering claimTask's gate with `&& false` left both
|
|
163
|
+
// counts at 3 and the suite green. The composition now lives in one pure export,
|
|
164
|
+
// db-rank-authz.claimGate, so these tests drive the real decision instead.
|
|
165
|
+
//
|
|
166
|
+
// claimGate returns the gates that REFUSED, in ladder order — `[]` means allowed.
|
|
167
|
+
|
|
168
|
+
const { claimGate, CLAIM_GATES } = rankAuthz;
|
|
169
|
+
const gatesFor = (o) => [...claimGate(o)];
|
|
170
|
+
|
|
171
|
+
test('claimGate: BOTH axes must pass, and neither substitutes for the other', () => {
|
|
172
|
+
// An Archon holding everything is still stopped by nothing at requires_rank xenos...
|
|
173
|
+
assert.deepEqual(gatesFor({
|
|
174
|
+
builderRank: 'archon', newcomerFriendly: false, requiresRank: 'xenos', heldPermissions: seedSet('archon'),
|
|
175
|
+
}), []);
|
|
176
|
+
|
|
177
|
+
// ...and a builder who clears the QUEUE axis is still refused by the FLOOR axis.
|
|
178
|
+
// This is the collapse the two-gate model exists to prevent: holding
|
|
179
|
+
// task.claim.any must not buy you an archon-floored task.
|
|
180
|
+
assert.deepEqual(gatesFor({
|
|
181
|
+
builderRank: 'metic', newcomerFriendly: false, requiresRank: 'archon', heldPermissions: seedSet('metic'),
|
|
182
|
+
}), ['floor']);
|
|
183
|
+
|
|
184
|
+
// The converse: clearing the FLOOR axis does not buy you the queue. A xenos on a
|
|
185
|
+
// default-floor task that is NOT newcomer_friendly holds only task.claim.newcomer.
|
|
186
|
+
assert.deepEqual(gatesFor({
|
|
187
|
+
builderRank: 'xenos', newcomerFriendly: false, requiresRank: 'xenos', heldPermissions: seedSet('xenos'),
|
|
188
|
+
}), ['queue']);
|
|
189
|
+
|
|
190
|
+
// Both can refuse the same row, and the order is the ladder order.
|
|
191
|
+
assert.deepEqual(gatesFor({
|
|
192
|
+
builderRank: 'xenos', newcomerFriendly: false, requiresRank: 'archon', heldPermissions: seedSet('xenos'),
|
|
193
|
+
}), ['queue', 'floor']);
|
|
194
|
+
|
|
195
|
+
// ...and a newcomer_friendly flag opens the queue without lowering the floor.
|
|
196
|
+
assert.deepEqual(gatesFor({
|
|
197
|
+
builderRank: 'xenos', newcomerFriendly: true, requiresRank: 'metic', heldPermissions: seedSet('xenos'),
|
|
198
|
+
}), ['floor'], 'newcomer_friendly must never lower requires_rank');
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
test('claimGate: the composed decision matches the two axes evaluated separately', () => {
|
|
202
|
+
// The independent oracle: each axis run through its OWN production predicate
|
|
203
|
+
// (db.rankAllowsTask and eligibility.claimEligibilityAllows), composed here in
|
|
204
|
+
// the test. A claimGate that silently dropped a conjunct disagrees immediately.
|
|
205
|
+
let refusalsSeen = 0;
|
|
206
|
+
for (const rank of LIVE_RANKS) {
|
|
207
|
+
for (const nf of NF_VALUES) {
|
|
208
|
+
for (const requiresRank of [...LIVE_RANKS, null]) {
|
|
209
|
+
for (const held of [seedSet(rank), new Set(), new Set([CLAIM_ANY]), new Set([CLAIM_NEWCOMER]), null]) {
|
|
210
|
+
const expected = [];
|
|
211
|
+
if (!claimEligibilityAllows({
|
|
212
|
+
heldPermissions: held, newcomerFriendly: nf, rankRuleAllows: db.xenosClaimAllowed(rank, nf),
|
|
213
|
+
})) expected.push('queue');
|
|
214
|
+
if (!db.rankAllowsTask(rank, requiresRank)) expected.push('floor');
|
|
215
|
+
refusalsSeen += expected.length;
|
|
216
|
+
assert.deepEqual(
|
|
217
|
+
gatesFor({ builderRank: rank, newcomerFriendly: nf, requiresRank, heldPermissions: held }),
|
|
218
|
+
expected,
|
|
219
|
+
`${rank}/nf=${String(nf)}/req=${String(requiresRank)}/held=${held ? [...held].length : 'null'}`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
assert.ok(refusalsSeen > 0, 'a matrix that never refuses would pass vacuously');
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test('claimGate: fail-closed on ranks and floors it does not recognise', () => {
|
|
229
|
+
// An unknown rank clears nothing — the posture rankAllowsTask already takes.
|
|
230
|
+
assert.ok(gatesFor({
|
|
231
|
+
builderRank: 'bogus', newcomerFriendly: true, requiresRank: 'xenos', heldPermissions: new Set([CLAIM_ANY]),
|
|
232
|
+
}).includes('floor'), 'an unrecognised builder rank must not clear the floor');
|
|
233
|
+
// A requires_rank the DB CHECK should have blocked refuses rather than defaults.
|
|
234
|
+
assert.ok(gatesFor({
|
|
235
|
+
builderRank: 'archon', newcomerFriendly: true, requiresRank: 'nonsense', heldPermissions: seedSet('archon'),
|
|
236
|
+
}).includes('floor'), 'an unrecognised requires_rank must fail closed');
|
|
237
|
+
// A null requires_rank is the column default — no extra gate.
|
|
238
|
+
assert.deepEqual(gatesFor({
|
|
239
|
+
builderRank: 'xenos', newcomerFriendly: true, requiresRank: null, heldPermissions: seedSet('xenos'),
|
|
240
|
+
}), []);
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
test('claimGate: queueGateApplies carries the batch paths\' pre-existing asymmetry', () => {
|
|
244
|
+
// claimTask runs the queue gate for EVERY rank; claimTasksBatch and
|
|
245
|
+
// validateClaimBatch run it only when builderRank === 'xenos'. For the SEEDED
|
|
246
|
+
// sets the two agree exactly, which is why the asymmetry has never bitten...
|
|
247
|
+
for (const rank of LIVE_RANKS) {
|
|
248
|
+
for (const nf of NF_VALUES) {
|
|
249
|
+
const held = seedSet(rank);
|
|
250
|
+
assert.deepEqual(
|
|
251
|
+
gatesFor({ builderRank: rank, newcomerFriendly: nf, requiresRank: 'xenos', heldPermissions: held }),
|
|
252
|
+
gatesFor({ builderRank: rank, newcomerFriendly: nf, requiresRank: 'xenos', heldPermissions: held, queueGateApplies: rank === 'xenos' }),
|
|
253
|
+
`${rank}/nf=${String(nf)}: seeded sets must agree across both call-site flavours`,
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
// ...and here is the case where they DIVERGE, pinned so it stays a known,
|
|
258
|
+
// deliberate difference rather than a surprise: a CUSTOM rank granted neither
|
|
259
|
+
// claim atom is refused by claimTask and admitted by the batch paths. Closing
|
|
260
|
+
// this is an authority decision of its own (see the note at the call site).
|
|
261
|
+
const neither = new Set(['cost.log']);
|
|
262
|
+
assert.deepEqual(gatesFor({
|
|
263
|
+
builderRank: 'metic', newcomerFriendly: false, requiresRank: 'xenos', heldPermissions: neither,
|
|
264
|
+
}), ['queue'], 'claimTask refuses a rank holding no claim atom');
|
|
265
|
+
assert.deepEqual(gatesFor({
|
|
266
|
+
builderRank: 'metic', newcomerFriendly: false, requiresRank: 'xenos', heldPermissions: neither, queueGateApplies: false,
|
|
267
|
+
}), [], 'the batch paths admit it — the asymmetry, recorded');
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
test('every claim path delegates to claimGate rather than re-composing the axes', () => {
|
|
271
|
+
// The cheap STRUCTURAL complement to the behavioural tests above. It can still
|
|
272
|
+
// only see presence, so it is no longer the only thing standing: what it adds is
|
|
273
|
+
// that a call site cannot quietly go back to composing the gates itself, which
|
|
274
|
+
// is what let the three copies drift apart in the first place.
|
|
275
|
+
// Counted in db-claims.js alone: the whole-family source also carries the
|
|
276
|
+
// DEFINITION in db-rank-authz.js, which would make the count read 4 and quietly
|
|
277
|
+
// tolerate one call site dropping out.
|
|
278
|
+
const claims = fs.readFileSync(path.join(LIFECYCLE, 'db-claims.js'), 'utf8');
|
|
279
|
+
const delegations = claims.match(/claimGate\(\{/g) || [];
|
|
280
|
+
assert.equal(delegations.length, 3,
|
|
281
|
+
`claimTask, claimTasksBatch and validateClaimBatch must each call claimGate — found ${delegations.length}`);
|
|
282
|
+
// ...and the raw conjuncts are gone from the claim paths, so there is exactly
|
|
283
|
+
// one place where "both gates must pass" is written down.
|
|
284
|
+
for (const banned of ['rankAllowsTask(', 'claimEligibilityAllows(']) {
|
|
285
|
+
assert.ok(!claims.includes(banned),
|
|
286
|
+
`db-claims.js must reach the axes through claimGate, not call "${banned}" itself`);
|
|
287
|
+
}
|
|
288
|
+
assert.deepEqual([...CLAIM_GATES], ['queue', 'floor'], 'the gate names are ladder-ordered');
|
|
167
289
|
});
|
|
168
290
|
|
|
169
291
|
// ── the "authority could not be established" fallback ────────────────────────
|
|
@@ -40,6 +40,7 @@ const {
|
|
|
40
40
|
PERMISSIONS, RANK_ORDER, RANK_SEED, RESOURCE_FAMILIES, permissionKeys, byKey,
|
|
41
41
|
scopeOf, resourceOf, isOwnershipScoped, isCrossOwner, ownershipScopedKeys, crossOwnerKeys,
|
|
42
42
|
crossOwnerKeysFor, isPrincipalKey, seedForRank,
|
|
43
|
+
OWNERSHIP_VERBS, VERB_DOMINANCE, verbDominates, verbOf,
|
|
43
44
|
} = catalog;
|
|
44
45
|
const {
|
|
45
46
|
sameOwner, authorizeOwned, authorizeOwnedOrAny, builderHasPermissions, effectivePermissions,
|
|
@@ -106,12 +107,24 @@ test('A3: the modelled families are the ones the ownership sweep names', () => {
|
|
|
106
107
|
// The cross-owner side is NOT derivable from spelling: box's is `box.fleet.manage`.
|
|
107
108
|
assert.deepEqual([...crossOwnerKeys()].sort(),
|
|
108
109
|
['box.fleet.manage', 'claim.release.any', 'memory.read.any']);
|
|
109
|
-
|
|
110
|
+
// 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.
|
|
110
114
|
assert.deepEqual(crossOwnerKeysFor('box.manage.own'), ['box.fleet.manage']);
|
|
115
|
+
// claim and memory each HAVE an `any` key in their family and still declare no
|
|
116
|
+
// counterpart — the distinction the family-derived version could not make.
|
|
117
|
+
// `claim.release.any` carries one third of `act` (release, not cost or notes);
|
|
118
|
+
// `memory.read.any` is a read, and reading every builder's memory is not the
|
|
119
|
+
// authority to manage one. A family-derived answer returned both.
|
|
120
|
+
assert.deepEqual(crossOwnerKeysFor('task.act.own'), []);
|
|
121
|
+
assert.deepEqual(crossOwnerKeysFor('memory.manage.own'), []);
|
|
111
122
|
// Some resources have NO cross-owner form by design — nobody manages another
|
|
112
123
|
// builder's credential, Archon included.
|
|
113
124
|
assert.deepEqual(crossOwnerKeysFor('credential.manage.own'), []);
|
|
114
125
|
assert.deepEqual(crossOwnerKeysFor('nope.not.a.key'), []);
|
|
126
|
+
// A cross-owner key is not itself own-scoped, so it has no path of its own.
|
|
127
|
+
assert.deepEqual(crossOwnerKeysFor('claim.release.any'), []);
|
|
115
128
|
});
|
|
116
129
|
|
|
117
130
|
test('A4: RESOURCE_FAMILIES is frozen and agrees with each row it lists', () => {
|
|
@@ -246,20 +259,112 @@ test('C5: builderHasPermissions REFUSES an ownership-scoped key rather than answ
|
|
|
246
259
|
|
|
247
260
|
// ═══ D — `.any` DOES reach another builder's resource ═════════════════════════
|
|
248
261
|
|
|
249
|
-
test('D1:
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
262
|
+
test('D1: a DECLARED cross-owner counterpart reaches a row the caller does not own', async () => {
|
|
263
|
+
// box is the catalog's one real pair, and it must keep working: `box.fleet.manage`
|
|
264
|
+
// is the whole point of a cross-owner key. Asserted BOTH with and without the own
|
|
265
|
+
// key, because the counterpart is a standalone admit — it does not merely widen a
|
|
266
|
+
// grant the builder already has.
|
|
267
|
+
await holding(['box.manage.own', 'box.fleet.manage'], async () => {
|
|
268
|
+
assert.equal(await authorizeOwnedOrAny(ME, 'box.manage.own', { ownerId: SOMEONE_ELSE }), true,
|
|
269
|
+
'box.fleet.manage is exactly the authority to manage someone else\'s box');
|
|
270
|
+
assert.equal(await authorizeOwnedOrAny(ME, 'box.manage.own', { ownerId: ME }), true);
|
|
254
271
|
});
|
|
272
|
+
await holding(['box.fleet.manage'], async () => {
|
|
273
|
+
assert.equal(await authorizeOwnedOrAny(ME, 'box.manage.own', { ownerId: SOMEONE_ELSE }), true);
|
|
274
|
+
});
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
// ═══ D1b — a cross-owner key with a WEAKER VERB is not a counterpart ═══════════
|
|
278
|
+
//
|
|
279
|
+
// task 1003195, from the task-1002671 adversarial verification (finding 12). The
|
|
280
|
+
// admit branch used to hand back the resource family's whole `any` list VERB-BLIND,
|
|
281
|
+
// so any cross-owner key over the resource satisfied EVERY own key over it. The
|
|
282
|
+
// escalation is now a per-key declaration checked for verb dominance at load.
|
|
283
|
+
//
|
|
284
|
+
// ORACLE DISCIPLINE, per this file's header: these two cases are driven by holding
|
|
285
|
+
// the weak `any` key ALONE. The old D1 always held the own key TOO, so the admit it
|
|
286
|
+
// recorded could have come from either branch — which is exactly why the defect sat
|
|
287
|
+
// unpinned. Holding only the weak key leaves the counterpart branch as the sole
|
|
288
|
+
// possible source of a `true`.
|
|
289
|
+
test('D1b: a weaker-verb cross-owner key does not satisfy an own key', async () => {
|
|
290
|
+
// A READ grant over every builder's memory is not the authority to MANAGE one.
|
|
291
|
+
await holding(['memory.read.any'], async () => {
|
|
292
|
+
assert.equal(await authorizeOwnedOrAny(ME, 'memory.manage.own', { ownerId: SOMEONE_ELSE }), false,
|
|
293
|
+
'memory.read.any reads every memory; it does not manage another builder\'s');
|
|
294
|
+
});
|
|
295
|
+
// `act` is a BUNDLE (release + cost + notes); `claim.release.any` carries the
|
|
296
|
+
// release third only, so it must not admit the other two against someone else.
|
|
297
|
+
await holding(['claim.release.any'], async () => {
|
|
298
|
+
assert.equal(await authorizeOwnedOrAny(ME, 'task.act.own', { ownerId: SOMEONE_ELSE }), false,
|
|
299
|
+
'claim.release.any releases a claim; it does not post costs or notes on one');
|
|
300
|
+
});
|
|
301
|
+
// Holding the own key as well changes nothing about the OTHER owner's row — the
|
|
302
|
+
// shape the old D1 asserted as an admit.
|
|
255
303
|
await holding(['memory.manage.own', 'memory.read.any'], async () => {
|
|
256
|
-
assert.equal(await authorizeOwnedOrAny(ME, 'memory.manage.own', { ownerId: SOMEONE_ELSE }),
|
|
304
|
+
assert.equal(await authorizeOwnedOrAny(ME, 'memory.manage.own', { ownerId: SOMEONE_ELSE }), false);
|
|
305
|
+
assert.equal(await authorizeOwnedOrAny(ME, 'memory.manage.own', { ownerId: ME }), true,
|
|
306
|
+
'the own branch is untouched — your own row is still yours');
|
|
257
307
|
});
|
|
258
|
-
await holding(['
|
|
259
|
-
assert.equal(await authorizeOwnedOrAny(ME, '
|
|
308
|
+
await holding(['task.act.own', 'claim.release.any'], async () => {
|
|
309
|
+
assert.equal(await authorizeOwnedOrAny(ME, 'task.act.own', { ownerId: SOMEONE_ELSE }), false);
|
|
310
|
+
assert.equal(await authorizeOwnedOrAny(ME, 'task.act.own', { ownerId: ME }), true);
|
|
311
|
+
});
|
|
312
|
+
// And the strongest form: EVERY permission in the catalog still does not reach
|
|
313
|
+
// another owner's memory or claim, because nothing in it dominates those verbs.
|
|
314
|
+
await holding(EVERYTHING, async () => {
|
|
315
|
+
assert.equal(await authorizeOwnedOrAny(ME, 'memory.manage.own', { ownerId: SOMEONE_ELSE }), false,
|
|
316
|
+
'no key in the catalog dominates memory.manage.own');
|
|
317
|
+
assert.equal(await authorizeOwnedOrAny(ME, 'task.act.own', { ownerId: SOMEONE_ELSE }), false,
|
|
318
|
+
'no key in the catalog dominates task.act.own');
|
|
260
319
|
});
|
|
261
320
|
});
|
|
262
321
|
|
|
322
|
+
// The declaration itself, held against the verb rule that admits it. This is the
|
|
323
|
+
// catalog-level twin of D1b: it fails if a future edit declares a counterpart whose
|
|
324
|
+
// verb does not dominate, even before any resolver call exercises it.
|
|
325
|
+
test('D1c: every declared counterpart is cross-owner, same-resource, and verb-dominant', () => {
|
|
326
|
+
let declared = 0;
|
|
327
|
+
for (const p of PERMISSIONS) {
|
|
328
|
+
if (p.scope !== 'own') continue;
|
|
329
|
+
assert.ok(Array.isArray(p.counterpart),
|
|
330
|
+
`${p.key} is own-scoped and must declare counterpart explicitly`);
|
|
331
|
+
assert.ok(OWNERSHIP_VERBS.includes(p.verb), `${p.key} must declare a known verb`);
|
|
332
|
+
assert.equal(verbOf(p.key), p.verb, 'verbOf must agree with the row it reads');
|
|
333
|
+
for (const key of p.counterpart) {
|
|
334
|
+
declared++;
|
|
335
|
+
const c = byKey(key);
|
|
336
|
+
assert.ok(c, `${p.key} names counterpart ${key}, which must exist`);
|
|
337
|
+
assert.equal(c.scope, 'any', `${p.key}'s counterpart ${key} must be cross-owner`);
|
|
338
|
+
assert.equal(c.resource, p.resource, `${p.key}'s counterpart ${key} must share its resource`);
|
|
339
|
+
assert.ok(verbDominates(c.verb, p.verb),
|
|
340
|
+
`${p.key}'s counterpart ${key} (verb ${c.verb}) must dominate verb ${p.verb}`);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
assert.ok(declared >= 1, 'expected at least the box pair to be declared');
|
|
344
|
+
// A verb never dominates a wider one, and an unknown verb fails closed — the
|
|
345
|
+
// relation is asserted directly so a future edit to the table is caught here.
|
|
346
|
+
assert.equal(verbDominates('manage', 'read'), true);
|
|
347
|
+
assert.equal(verbDominates('manage', 'act'), true);
|
|
348
|
+
assert.equal(verbDominates('read', 'read'), true);
|
|
349
|
+
assert.equal(verbDominates('read', 'manage'), false);
|
|
350
|
+
assert.equal(verbDominates('release', 'act'), false);
|
|
351
|
+
assert.equal(verbDominates('act', 'manage'), false);
|
|
352
|
+
assert.equal(verbDominates('manage', 'nonsense'), false);
|
|
353
|
+
assert.equal(verbDominates(null, 'manage'), false);
|
|
354
|
+
// The table is the whole relation: only `manage` subsumes anything, and it is the
|
|
355
|
+
// one verb nothing else dominates. Pinned so a widening needs a deliberate edit.
|
|
356
|
+
assert.deepEqual(Object.keys(VERB_DOMINANCE), ['manage']);
|
|
357
|
+
for (const v of OWNERSHIP_VERBS) {
|
|
358
|
+
assert.equal(verbDominates('manage', v), true, `manage must dominate ${v}`);
|
|
359
|
+
if (v !== 'manage') {
|
|
360
|
+
assert.equal(verbDominates(v, 'manage'), false, `${v} must not dominate manage`);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
// An unscoped key has no verb at all — the axis only means something with a scope.
|
|
364
|
+
assert.equal(verbOf('cost.log'), null);
|
|
365
|
+
assert.equal(verbOf('nope.not.a.key'), null);
|
|
366
|
+
});
|
|
367
|
+
|
|
263
368
|
test('D2: holding only the OWN key never reaches someone else, in either entry point', async () => {
|
|
264
369
|
for (const [, fam] of PAIRED) {
|
|
265
370
|
for (const ownKey of fam.own) {
|
|
@@ -354,7 +459,7 @@ test('F3: a cross-owner key cheaper than its own-scoped counterpart throws at mo
|
|
|
354
459
|
assert.throws(
|
|
355
460
|
() => loadMutatedCatalog('inverted floors', (s) => s.replace(
|
|
356
461
|
` { key: 'goal.manage.own',`,
|
|
357
|
-
` { key: 'goal.manage.any', system: false, floor: 'xenos', scope: 'any', resource: 'goal', guards: 'fixture' },\n { key: 'goal.manage.own',`)),
|
|
462
|
+
` { key: 'goal.manage.any', system: false, floor: 'xenos', scope: 'any', resource: 'goal', verb: 'manage', guards: 'fixture' },\n { key: 'goal.manage.own',`)),
|
|
358
463
|
/must never be cheaper than own-only/,
|
|
359
464
|
);
|
|
360
465
|
});
|
|
@@ -363,22 +468,108 @@ test('F4: a cross-owner key with no ownership-scoped counterpart throws at modul
|
|
|
363
468
|
assert.throws(
|
|
364
469
|
() => loadMutatedCatalog('lone any', (s) => s.replace(
|
|
365
470
|
` { key: 'goal.manage.own',`,
|
|
366
|
-
` { key: 'widget.manage.any', system: false, floor: 'archon', scope: 'any', resource: 'widget', guards: 'fixture' },\n { key: 'goal.manage.own',`)),
|
|
471
|
+
` { key: 'widget.manage.any', system: false, floor: 'archon', scope: 'any', resource: 'widget', verb: 'manage', guards: 'fixture' },\n { key: 'goal.manage.own',`)),
|
|
367
472
|
/with no ownership-scoped counterpart/,
|
|
368
473
|
);
|
|
369
474
|
});
|
|
370
475
|
|
|
476
|
+
// F5–F8 are task 1003195's half: the counterpart declaration and its verb rule.
|
|
477
|
+
// Same harness, so what is proven is the SHIPPED assertion block. Each fixture
|
|
478
|
+
// reintroduces the exact defect the task was filed for, or a near neighbour of it.
|
|
479
|
+
|
|
480
|
+
test('F5: a counterpart whose verb does not dominate throws at module load', () => {
|
|
481
|
+
// The literal finding-12 defect: memory.read.any declared as the escalation path
|
|
482
|
+
// for memory.manage.own — a read grant admitting a write on another owner's row.
|
|
483
|
+
assert.throws(
|
|
484
|
+
() => loadMutatedCatalog('weak verb counterpart', (s) => s.replace(
|
|
485
|
+
`resource: 'memory', verb: 'manage', counterpart: []`,
|
|
486
|
+
`resource: 'memory', verb: 'manage', counterpart: ['memory.read.any']`)),
|
|
487
|
+
/does not dominate/,
|
|
488
|
+
);
|
|
489
|
+
// And its sibling: release is one third of act.
|
|
490
|
+
assert.throws(
|
|
491
|
+
() => loadMutatedCatalog('partial verb counterpart', (s) => s.replace(
|
|
492
|
+
`resource: 'claim', verb: 'act', counterpart: []`,
|
|
493
|
+
`resource: 'claim', verb: 'act', counterpart: ['claim.release.any']`)),
|
|
494
|
+
/does not dominate/,
|
|
495
|
+
);
|
|
496
|
+
});
|
|
497
|
+
|
|
498
|
+
test('F6: an own-scoped key that declares no counterpart at all throws at module load', () => {
|
|
499
|
+
// The fail-loud that replaces the silent family derivation: a new own key cannot
|
|
500
|
+
// inherit an escalation path merely by belonging to a resource.
|
|
501
|
+
assert.throws(
|
|
502
|
+
() => loadMutatedCatalog('missing counterpart', (s) => s.replace(
|
|
503
|
+
`resource: 'credential', verb: 'manage', counterpart: []`,
|
|
504
|
+
`resource: 'credential', verb: 'manage'`)),
|
|
505
|
+
/must declare counterpart/,
|
|
506
|
+
);
|
|
507
|
+
});
|
|
508
|
+
|
|
509
|
+
test('F7: a counterpart that is dangling, cross-resource, or not cross-owner throws', () => {
|
|
510
|
+
assert.throws(
|
|
511
|
+
() => loadMutatedCatalog('dangling counterpart', (s) => s.replace(
|
|
512
|
+
`resource: 'credential', verb: 'manage', counterpart: []`,
|
|
513
|
+
`resource: 'credential', verb: 'manage', counterpart: ['credential.manage.any']`)),
|
|
514
|
+
/not a permission in this catalog/,
|
|
515
|
+
);
|
|
516
|
+
assert.throws(
|
|
517
|
+
() => loadMutatedCatalog('cross-resource counterpart', (s) => s.replace(
|
|
518
|
+
`resource: 'credential', verb: 'manage', counterpart: []`,
|
|
519
|
+
`resource: 'credential', verb: 'manage', counterpart: ['box.fleet.manage']`)),
|
|
520
|
+
/never crosses resource families/,
|
|
521
|
+
);
|
|
522
|
+
assert.throws(
|
|
523
|
+
() => loadMutatedCatalog('own key as counterpart', (s) => s.replace(
|
|
524
|
+
`resource: 'box', verb: 'manage', counterpart: ['box.fleet.manage']`,
|
|
525
|
+
`resource: 'box', verb: 'manage', counterpart: ['memory.manage.own']`)),
|
|
526
|
+
/is not cross-owner/,
|
|
527
|
+
);
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
test('F8: the verb field itself is partitioned with scope and checked against the vocabulary', () => {
|
|
531
|
+
assert.throws(
|
|
532
|
+
() => loadMutatedCatalog('scope without verb', (s) => s.replace(
|
|
533
|
+
`resource: 'builder_prefs', verb: 'set', counterpart: []`,
|
|
534
|
+
`resource: 'builder_prefs', counterpart: []`)),
|
|
535
|
+
/scope and verb must come together/,
|
|
536
|
+
);
|
|
537
|
+
assert.throws(
|
|
538
|
+
() => loadMutatedCatalog('unknown verb', (s) => s.replace(
|
|
539
|
+
`resource: 'builder_prefs', verb: 'set', counterpart: []`,
|
|
540
|
+
`resource: 'builder_prefs', verb: 'obliterate', counterpart: []`)),
|
|
541
|
+
/unknown verb/,
|
|
542
|
+
);
|
|
543
|
+
// A counterpart on a key that has no owner axis is a mis-declaration, not a no-op.
|
|
544
|
+
assert.throws(
|
|
545
|
+
() => loadMutatedCatalog('counterpart on an any key', (s) => s.replace(
|
|
546
|
+
`resource: 'memory', verb: 'read', guards:`,
|
|
547
|
+
`resource: 'memory', verb: 'read', counterpart: [], guards:`)),
|
|
548
|
+
/is not own-scoped/,
|
|
549
|
+
);
|
|
550
|
+
});
|
|
551
|
+
|
|
371
552
|
// ═══ G — the R94/R95/R98 invariants this slice must not have disturbed ════════
|
|
372
553
|
|
|
373
|
-
test('G1:
|
|
554
|
+
test('G1: RANK_SEED is still DERIVED from seedForRank, not hand-maintained', () => {
|
|
555
|
+
// task 1003196 (finding 8) trimmed this test, and it is worth saying exactly what
|
|
556
|
+
// it does and does not prove. It USED to re-spell seedForRank's formula
|
|
557
|
+
// (`floor !== null && RANK_ORDER.indexOf(p.floor) <= tier`) and assert it against
|
|
558
|
+
// RANK_SEED — but RANK_SEED is BUILT by seedForRank, so the two sides moved
|
|
559
|
+
// together: the copy could detect drift from the other copies of itself and never
|
|
560
|
+
// a wrong formula. That formula oracle is kept ONCE, in tests/government_catalog.mjs
|
|
561
|
+
// ("a role holds a permission iff its floor is a real rank at-or-below"), with the
|
|
562
|
+
// real independent oracles being government_parity.mjs Part A and the pinned
|
|
563
|
+
// concrete values beside it.
|
|
564
|
+
//
|
|
565
|
+
// What survives here is narrow and genuine: RANK_SEED must remain a DERIVATION.
|
|
566
|
+
// The catalog's "zero day-one change" guarantee (R103) is structural only while
|
|
567
|
+
// the seed is computed from floors — the day someone replaces RANK_SEED with a
|
|
568
|
+
// hand-written list, parity stops being a property of the code and becomes a
|
|
569
|
+
// promise someone has to keep by hand. That is what this catches.
|
|
374
570
|
for (const rank of RANK_ORDER) {
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
for (const p of PERMISSIONS) {
|
|
378
|
-
assert.equal(held.has(p.key), p.floor !== null && RANK_ORDER.indexOf(p.floor) <= tier,
|
|
379
|
-
`${rank} vs ${p.key}: scope must not touch the floor derivation`);
|
|
380
|
-
}
|
|
381
|
-
assert.deepEqual([...seedForRank(rank)].sort(), [...RANK_SEED[rank]].sort());
|
|
571
|
+
assert.deepEqual([...seedForRank(rank)].sort(), [...RANK_SEED[rank]].sort(),
|
|
572
|
+
`${rank}: RANK_SEED must be what seedForRank computes, never a maintained copy`);
|
|
382
573
|
}
|
|
383
574
|
});
|
|
384
575
|
|
|
@@ -214,11 +214,89 @@ test('every entry maps to a permission the catalog defines as system:true', () =
|
|
|
214
214
|
assert.deepEqual(registry.SYSTEM_PERMISSIONS, ['path.protected.modify']);
|
|
215
215
|
});
|
|
216
216
|
|
|
217
|
-
|
|
218
|
-
|
|
217
|
+
// Feed a BAD registry through the real loader in a cold subprocess and assert the
|
|
218
|
+
// module refuses to load. The pattern is the sibling probe below ("a missing
|
|
219
|
+
// registry fails CLOSED"): patch fs.readFileSync for the registry path only, then
|
|
220
|
+
// require the module under test and let its load-time throw be the result.
|
|
221
|
+
//
|
|
222
|
+
// WHY A SUBPROCESS AND NOT AN IN-PROCESS ASSERTION (task 1003196, finding 9). The
|
|
223
|
+
// version of this test before that task read the catalog and checked that a
|
|
224
|
+
// grantable key exists and an unknown key returns null — both true regardless of
|
|
225
|
+
// whether the guard exists. Demonstrated: deleting the whole validation loop from
|
|
226
|
+
// modules/government/protected-surfaces.js left the entire suite green. The guard
|
|
227
|
+
// is load-time, so only a fresh module load can exercise it, and the registry is
|
|
228
|
+
// read from disk, so only a patched read can make it bad.
|
|
229
|
+
//
|
|
230
|
+
// This matters because the sibling test above ("every entry maps to a permission
|
|
231
|
+
// the catalog defines as system:true") only checks the COMMITTED file. The
|
|
232
|
+
// load-time throw is what protects an out-of-band registry write — the Government
|
|
233
|
+
// tab editing an instance file at runtime — and nothing was watching it.
|
|
234
|
+
function loadWithRegistry(registryJson) {
|
|
235
|
+
execFileSync(process.execPath, ['-e',
|
|
236
|
+
"const fs=require('node:fs');const real=fs.readFileSync;" +
|
|
237
|
+
'const fake=' + JSON.stringify(JSON.stringify(registryJson)) + ';' +
|
|
238
|
+
"fs.readFileSync=(p,...r)=>(String(p).includes('protected-surfaces.json') ? fake : real(p,...r));" +
|
|
239
|
+
"require('./modules/government/protected-surfaces.js');",
|
|
240
|
+
], { cwd: ROOT, stdio: 'pipe' });
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
test('a surface mapped to a GRANTABLE permission fails loud at load', () => {
|
|
244
|
+
// A grantable (system:false) key is the dangerous case ADR 0151 §4 names: a
|
|
245
|
+
// custom rank could be handed the very power the substrate reserves.
|
|
219
246
|
const grantable = catalog.grantableKeys()[0];
|
|
220
|
-
assert.ok(grantable
|
|
221
|
-
assert.equal(catalog.
|
|
247
|
+
assert.ok(grantable, 'the catalog must define at least one grantable key for this probe');
|
|
248
|
+
assert.equal(catalog.isSystem(grantable), false, 'fixture sanity: the probe key must be grantable');
|
|
249
|
+
assert.throws(
|
|
250
|
+
() => loadWithRegistry({
|
|
251
|
+
floor: 'metic',
|
|
252
|
+
permission: grantable,
|
|
253
|
+
surfaces: [{ glob: 'src/probe/grantable-surface.js' }],
|
|
254
|
+
}),
|
|
255
|
+
/which is not system:true/,
|
|
256
|
+
'a surface mapping to a grantable permission must refuse to load',
|
|
257
|
+
);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test('a surface mapped to an UNKNOWN permission fails loud at load', () => {
|
|
261
|
+
assert.equal(catalog.byKey('not.a.real.permission'), null, 'fixture sanity: the key must be unknown');
|
|
262
|
+
assert.throws(
|
|
263
|
+
() => loadWithRegistry({
|
|
264
|
+
floor: 'metic',
|
|
265
|
+
permission: 'not.a.real.permission',
|
|
266
|
+
surfaces: [{ glob: 'src/probe/unknown-surface.js' }],
|
|
267
|
+
}),
|
|
268
|
+
/which the permission catalog does not define/,
|
|
269
|
+
'a surface mapping to a permission the catalog never defines must refuse to load',
|
|
270
|
+
);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test('the probe itself is honest — a GOOD registry loads clean through the same path', () => {
|
|
274
|
+
// Without this, both assertions above would pass if the subprocess threw for an
|
|
275
|
+
// unrelated reason (a bad patch, a missing cwd, a syntax error in the -e script).
|
|
276
|
+
assert.doesNotThrow(() => loadWithRegistry({
|
|
277
|
+
floor: 'metic',
|
|
278
|
+
permission: registry.SYSTEM_PERMISSIONS[0],
|
|
279
|
+
surfaces: [{ glob: 'src/probe/good-surface.js' }],
|
|
280
|
+
}), 'a well-formed registry must still load — otherwise the probes above prove nothing');
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test('an unknown FLOOR is refused before the catalog check ever sees it', () => {
|
|
284
|
+
// The validation loop's third branch (`!catalog.RANK_ORDER.includes(s.floor)`) is
|
|
285
|
+
// a DRIFT guard, not dead code, and this test records why it cannot be provoked
|
|
286
|
+
// through the registry: the kernel loader keeps its own local RANK_TIER map
|
|
287
|
+
// (permission-path-check.js) and rejects an unknown floor first. The government
|
|
288
|
+
// branch only becomes reachable if those two rank lists ever disagree — which is
|
|
289
|
+
// exactly the drift it exists to catch, and is not craftable from JSON alone.
|
|
290
|
+
assert.throws(
|
|
291
|
+
() => loadWithRegistry({
|
|
292
|
+
floor: 'emperor',
|
|
293
|
+
permission: registry.SYSTEM_PERMISSIONS[0],
|
|
294
|
+
surfaces: [{ glob: 'src/probe/bad-floor.js' }],
|
|
295
|
+
}),
|
|
296
|
+
/unknown floor/,
|
|
297
|
+
);
|
|
298
|
+
assert.deepEqual(catalog.RANK_ORDER, ['xenos', 'thetes', 'metic', 'archon'],
|
|
299
|
+
'if this list and the kernel RANK_TIER drift, the government floor branch wakes up');
|
|
222
300
|
});
|
|
223
301
|
|
|
224
302
|
// ---------- synchronous, no DB, no server ----------
|