@bongos/core 1.19.662 → 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 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.662",
6
- "core_contract": "1.19.662",
7
- "source_commit": "29f8be216571b2ec145cab49a5a7c230c79270a0",
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-11T02:16:53.563Z",
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": "c1546f9ff797a30c5dff74fa40f96d201e25291c3bb8061ca8aae6ff845e1d6f",
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": "6551b2dbf9ed520f152e8aa2ddc2888dcaae6c4f96d4babf71059fb1550f9ca6"
2790
+ "sha256": "d5e752a541b45079b1b23eb12eeb282e6f163a676c0d9b9f122fcef2864d120f"
2791
2791
  },
2792
2792
  {
2793
2793
  "path": "docs/modules-contract.md",
@@ -5647,7 +5647,7 @@
5647
5647
  {
5648
5648
  "path": "modules/lifecycle/db-claims.js",
5649
5649
  "mode": "0000644",
5650
- "sha256": "23ed6051b8b9283410f23e10862b6b65361cbf2f95489cd1fc50dfd4584f5c71"
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": "e5feff1eb42ad631b6c0165c23224d8ac56ef7f40e5d238afb09013501e9db7c"
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": "7eb2f5389815f661c90a4c42111d3caf64a5ede4977a66caf27baf2edd10a59f"
7755
+ "sha256": "014c542d80e35f6e6a5572e169232eedf744e09bae83f6798c2d998ca86a18d5"
7756
7756
  },
7757
7757
  {
7758
7758
  "path": "package.json",
7759
7759
  "mode": "0000644",
7760
- "sha256": "eb2e51ad62585ffd964894ae722d41e98372978ac8a58456efa28b945a21c4b1"
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": "c273737ee3e60e60268081525c1bc144940583038cd23c1779a3671a39444a93"
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": "1989a7e5e39a6bb177dfd86e610c0806c96fdc97f9149800de431af816f2c31a"
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": "bdcb0266f38c50b0e3085271bdec4fbe726c32c8af1052104703d0afa7e89d52"
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": "cd56facc3522f17cd83b067ee1ae9dcb3e4e03dac13157013c44d7ba23508af2"
10925
+ "sha256": "3517e05c1264fbdbeadc533884942926689952c8995548863ca1cfbfc365428e"
10926
10926
  },
10927
10927
  {
10928
10928
  "path": "tests/government_rank_sync.mjs",
@@ -1773,5 +1773,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
1773
1773
  landed since 1.19.660 with no explicit bump. run 34552525938. (task 1002620)
1774
1774
  1.19.662 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1775
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)
1776
1778
  ---------------------------------------------------------------------------
1777
1779
  ```
@@ -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
- const { claimEligibilityAllows, CLAIM_ANY, CLAIM_NEWCOMER } = require('./claim-eligibility.js');
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, rankAllowsTask, xenosClaimAllowed } = require('./db-rank-authz.js');
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
- if (!claimEligibilityAllows({ heldPermissions, newcomerFriendly, rankRuleAllows: xenosClaimAllowed(builderRank, newcomerFriendly) })) {
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 (!rankAllowsTask(builderRank, task.requires_rank)) {
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
- if (builderRank === 'xenos') {
500
- for (const t of taskRows) {
501
- if (!claimEligibilityAllows({
502
- heldPermissions,
503
- newcomerFriendly: t.newcomer_friendly,
504
- rankRuleAllows: xenosClaimAllowed(builderRank, t.newcomer_friendly),
505
- })) {
506
- failures.push({ task_id: t.id, reason: 'rank_too_low_for_task', legacy_reason: 'XENOS_RESTRICTED' });
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 (!rankAllowsTask(builderRank, t.requires_rank)) {
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
- if (builderRank === 'xenos' && !claimEligibilityAllows({
859
- heldPermissions,
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
- rankRuleAllows: xenosClaimAllowed(builderRank, t.newcomer_friendly),
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 (!rankAllowsTask(builderRank, t.requires_rank)) {
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.662",
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.662",
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.662",
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.662'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
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
- test('every claim path in db.js still gates on requires_rank as its own check', () => {
158
- // task 1003204: the lifecycle data layer is db.js + db-*.js now — read the family.
159
- const src = lifecycleDbSource();
160
- // claimTask, claimTasksBatch, validateClaimBatch three call sites, three
161
- // separate rankAllowsTask conjuncts. If a refactor ever folds the floor into the
162
- // permission predicate, this count drops and the test reds.
163
- const floorChecks = src.match(/rankAllowsTask\((builderRank|builderRank), (task|t)\.requires_rank\)/g) || [];
164
- assert.equal(floorChecks.length, 3, `expected 3 requires_rank gates in the claim paths, found ${floorChecks.length}`);
165
- const queueChecks = src.match(/claimEligibilityAllows\(\{/g) || [];
166
- assert.equal(queueChecks.length, 3, `expected 3 permission-expressed queue gates, found ${queueChecks.length}`);
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 ────────────────────────
@@ -551,15 +551,25 @@ test('F8: the verb field itself is partitioned with scope and checked against th
551
551
 
552
552
  // ═══ G — the R94/R95/R98 invariants this slice must not have disturbed ════════
553
553
 
554
- test('G1: the seed is still floor-derived and cumulative the ownership axis adds no grants', () => {
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.
555
570
  for (const rank of RANK_ORDER) {
556
- const held = new Set(RANK_SEED[rank]);
557
- const tier = RANK_ORDER.indexOf(rank);
558
- for (const p of PERMISSIONS) {
559
- assert.equal(held.has(p.key), p.floor !== null && RANK_ORDER.indexOf(p.floor) <= tier,
560
- `${rank} vs ${p.key}: scope must not touch the floor derivation`);
561
- }
562
- 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`);
563
573
  }
564
574
  });
565
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
- test('a surface mapped to a grantable or unknown permission fails loud', () => {
218
- // The guard must actually catch a bad mapping, not pass vacuously.
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 && catalog.isSystem(grantable) === false);
221
- assert.equal(catalog.byKey('not.a.real.permission'), null);
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 ----------