@bongos/core 1.19.710 → 1.19.711

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/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.710",
3
+ "version": "1.19.711",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.710",
9
+ "version": "1.19.711",
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.710",
3
+ "version": "1.19.711",
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",
@@ -446,33 +446,11 @@ function parseArgv(argv = []) {
446
446
  return { name, question, outArg, asJson, error: null };
447
447
  }
448
448
 
449
- /**
450
- * The gate. Decides whether this definition may be invoked on demand, and gives
451
- * the reason in the same breath — rule 2 needs the reason as much as the verdict,
452
- * because the reason is the whole content of the ledger row a refusal writes.
453
- *
454
- * PURE: takes the registry row, returns `{ decision, reason }`.
455
- */
456
- function gateFor(row) {
457
- if (!row) return { decision: 'no-go', reason: 'no such agent in the registry' };
458
- if (row.trigger_type !== 'on-demand') {
459
- return {
460
- decision: 'no-go',
461
- reason: `trigger_type is '${row.trigger_type}', not 'on-demand' — this agent is dispatched by an event, not asked a question`,
462
- };
463
- }
464
- // Checked before `enabled` even though the schema's flagged_not_armed CHECK
465
- // makes a flagged row necessarily disabled: the two states have the same
466
- // verdict and completely different fixes, and the reason is what the caller
467
- // acts on.
468
- if (isNonEmptyString(row.scope_violation)) {
469
- return { decision: 'no-go', reason: `disarmed by the scope wall: ${row.scope_violation}` };
470
- }
471
- if (row.enabled !== true) {
472
- return { decision: 'no-go', reason: 'the definition is present in the registry but not enabled' };
473
- }
474
- return { decision: 'go', reason: null };
475
- }
449
+ // THE GATE lives in the module, not here: the same question is asked by
450
+ // POST /agents/:name/invoke on the other side of the trust boundary, and a gate
451
+ // with two implementations has two behaviours the day one is edited. Required
452
+ // directly from a script the way agents-sync requires the validator.
453
+ const { gateFor } = require('../../modules/agents/lib/gate.js');
476
454
 
477
455
  // ---- the chain -------------------------------------------------------------
478
456
 
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.710'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.711'; // 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');
@@ -0,0 +1,306 @@
1
+ // tests/agents_authoring.mjs — the decisions behind the agent WRITE surface
2
+ // (task 1002493, goal 1000038 Phase 2a).
3
+ //
4
+ // Four rules, each a way an authoring surface turns into an escalation, plus the
5
+ // pickup counter a server-side fire delivers its answer through. All pure — no
6
+ // database, no HTTP, no model. The routes that consume these are asserted
7
+ // structurally in tests/agents_routes.mjs.
8
+ //
9
+ // In tests/ rather than modules/agents/tests/ for the reason task 1002487 gave:
10
+ // `agents` is default:false, so a module-local suite would never run.
11
+ //
12
+ // Run: node tests/agents_authoring.mjs
13
+
14
+ import { strict as assert } from 'node:assert';
15
+ import { createRequire } from 'node:module';
16
+
17
+ const require = createRequire(import.meta.url);
18
+ const authoring = require('../modules/agents/lib/authoring.js');
19
+ const { gateFor, ON_DEMAND } = require('../modules/agents/lib/gate.js');
20
+ const { createAnswerHold } = require('../modules/agents/lib/answer-hold.js');
21
+ const { createFireBudget, MAX_TRACKED_BUILDERS } = require('../modules/agents/lib/fire-budget.js');
22
+ const validate = require('../modules/agents/lib/validate.js');
23
+
24
+ let passed = 0;
25
+ let failed = 0;
26
+ function t(name, fn) {
27
+ try { fn(); passed += 1; console.log(` ok ${name}`); }
28
+ catch (err) { failed += 1; console.error(` FAIL ${name}\n ${err.message}`); }
29
+ }
30
+
31
+ const DB_ROW = Object.freeze({
32
+ name: 'librarian', title: 'The Librarian', persona: 'P',
33
+ trigger_type: 'on-demand', trigger_spec: {}, model_tier: 'default',
34
+ scope_modules: [], scope_paths: [], scope_violation: null,
35
+ source: 'db', provenance: 'instance', author_rank: 'metic', enabled: false,
36
+ });
37
+
38
+ console.log('\nrule 1 — authority fields are never read from the request:');
39
+
40
+ t('every server-owned field is refused BY NAME, with the reason', () => {
41
+ for (const field of Object.keys(authoring.SERVER_OWNED_FIELDS)) {
42
+ const hits = authoring.forbiddenFields({ name: 'x', [field]: 'anything' });
43
+ assert.equal(hits.length, 1, `${field} was not refused`);
44
+ assert.equal(hits[0].field, field);
45
+ assert.ok(hits[0].reason.length > 0, `${field} was refused without saying why`);
46
+ }
47
+ });
48
+
49
+ t('author_rank is on that list, and the reason names the live-rank rule', () => {
50
+ const [hit] = authoring.forbiddenFields({ author_rank: 'archon' });
51
+ assert.equal(hit.field, 'author_rank');
52
+ assert.match(hit.reason, /live DB rank/);
53
+ });
54
+
55
+ t('a clean body is clean, and a non-object body is not an error to report here', () => {
56
+ assert.deepEqual(authoring.forbiddenFields({ name: 'x', persona: 'p' }), []);
57
+ assert.deepEqual(authoring.forbiddenFields(null), []);
58
+ assert.deepEqual(authoring.forbiddenFields('nope'), []);
59
+ assert.deepEqual(authoring.forbiddenFields([{ author_rank: 'archon' }]), []);
60
+ });
61
+
62
+ t('the authorable set and the server-owned set do not overlap', () => {
63
+ // A field in both would be decided in two places, and the one that ran last
64
+ // would win silently.
65
+ for (const f of authoring.AUTHORABLE_FIELDS) {
66
+ assert.ok(!Object.prototype.hasOwnProperty.call(authoring.SERVER_OWNED_FIELDS, f),
67
+ `${f} is both authorable and server-owned`);
68
+ }
69
+ });
70
+
71
+ console.log('\nrule 3 — a DB-authored definition may not reach a protected surface:');
72
+
73
+ const PROTECTED_MODULES = ['government', 'lifecycle'];
74
+ const IS_PROTECTED = (p) => p.startsWith('src/bongos/') || p.startsWith('migrations/');
75
+
76
+ t('a protected MODULE is a hit', () => {
77
+ const h = authoring.protectedScopeHits({ scope_modules: ['government', 'copy-desk'] },
78
+ { protectedModules: PROTECTED_MODULES, isProtectedPath: IS_PROTECTED });
79
+ assert.equal(h.protected, true);
80
+ assert.deepEqual(h.modules, ['government']);
81
+ });
82
+
83
+ t('a protected PATH is a hit — the same surface, spelled the other way', () => {
84
+ const h = authoring.protectedScopeHits({ scope_paths: ['src/bongos/auth.js', 'public/app.css'] },
85
+ { protectedModules: PROTECTED_MODULES, isProtectedPath: IS_PROTECTED });
86
+ assert.equal(h.protected, true);
87
+ assert.deepEqual(h.paths, ['src/bongos/auth.js']);
88
+ });
89
+
90
+ t('a clean scope is clean, and so is no scope at all', () => {
91
+ const ctx = { protectedModules: PROTECTED_MODULES, isProtectedPath: IS_PROTECTED };
92
+ assert.equal(authoring.protectedScopeHits({ scope_modules: ['copy-desk'], scope_paths: ['docs/'] }, ctx).protected, false);
93
+ assert.equal(authoring.protectedScopeHits({}, ctx).protected, false);
94
+ assert.equal(authoring.protectedScopeHits(null, ctx).protected, false);
95
+ });
96
+
97
+ t('FAIL-CLOSED: declared paths with NO matcher are all hits', () => {
98
+ // "Nobody checked" must not read as "nothing was protected" — the same rule
99
+ // the validator's scope_paths wall applies (task 1002492).
100
+ const h = authoring.protectedScopeHits({ scope_paths: ['anything/at/all'] }, { protectedModules: [] });
101
+ assert.equal(h.protected, true);
102
+ assert.deepEqual(h.paths, ['anything/at/all']);
103
+ });
104
+
105
+ t('FAIL-CLOSED: a throwing matcher is a hit, and only an explicit false clears a path', () => {
106
+ const ctx = (fn) => ({ protectedModules: [], isProtectedPath: fn });
107
+ assert.equal(authoring.protectedScopeHits({ scope_paths: ['x'] }, ctx(() => { throw new Error('boom'); })).protected, true);
108
+ assert.equal(authoring.protectedScopeHits({ scope_paths: ['x'] }, ctx(() => undefined)).protected, true);
109
+ assert.equal(authoring.protectedScopeHits({ scope_paths: ['x'] }, ctx(() => null)).protected, true);
110
+ assert.equal(authoring.protectedScopeHits({ scope_paths: ['x'] }, ctx(() => 'yes')).protected, true);
111
+ assert.equal(authoring.protectedScopeHits({ scope_paths: ['x'] }, ctx(() => false)).protected, false);
112
+ });
113
+
114
+ t('this wall is STRICTER than the validator, which is the point', () => {
115
+ // An Archon clears validate.js's Metic+ rank floor on a protected scope...
116
+ const verdict = validate.validateAgentDefinition(
117
+ { ...DB_ROW, scope_modules: ['government'] },
118
+ { authorRank: 'archon', allowedModules: ['government'], protectedModules: PROTECTED_MODULES, isProtectedPath: () => false },
119
+ );
120
+ assert.equal(verdict.ok, true, 'the rank floor admits an Archon');
121
+ // ...and still cannot author it over HTTP, because the reviewed path and the
122
+ // unreviewed one do not get the same wall.
123
+ assert.equal(
124
+ authoring.protectedScopeHits(verdict.value, { protectedModules: PROTECTED_MODULES, isProtectedPath: () => false }).protected,
125
+ true,
126
+ );
127
+ });
128
+
129
+ console.log('\nrule 4 — the sync owns its own rows:');
130
+
131
+ t('a file-sourced definition is not editable here, and the reason names the file', () => {
132
+ const w = authoring.writableByApi({ ...DB_ROW, source: 'file', source_path: '.claude/agents/historian.md' });
133
+ assert.equal(w.ok, false);
134
+ assert.match(w.reason, /\.claude\/agents\/historian\.md/);
135
+ assert.match(w.reason, /silently undone/);
136
+ // ...and it points at the one write that DOES work on a file row.
137
+ assert.match(w.reason, /enable\/disable/);
138
+ });
139
+
140
+ t('a db-authored definition is editable; a missing one is not', () => {
141
+ assert.equal(authoring.writableByApi(DB_ROW).ok, true);
142
+ assert.equal(authoring.writableByApi(null).ok, false);
143
+ });
144
+
145
+ console.log('\nthe merge — a PATCH is judged as a whole definition:');
146
+
147
+ t('unsent fields come from the stored row', () => {
148
+ const merged = authoring.mergeForValidation(DB_ROW, { title: 'Renamed' });
149
+ assert.equal(merged.title, 'Renamed');
150
+ assert.equal(merged.persona, 'P');
151
+ assert.equal(merged.trigger_type, 'on-demand');
152
+ });
153
+
154
+ t('a partial trigger change is caught BECAUSE the whole definition is re-judged', () => {
155
+ // Switch to an event trigger without sending trigger_spec. Validating the
156
+ // delta alone would see nothing wrong; validating the merged definition sees a
157
+ // trigger that can never fire.
158
+ const merged = authoring.mergeForValidation(DB_ROW, { trigger_type: 'event' });
159
+ const verdict = validate.validateAgentDefinition(merged, { allowedModules: [], protectedModules: [], authorRank: 'metic' });
160
+ assert.equal(verdict.ok, false);
161
+ assert.ok(verdict.errors.some((e) => e.code === 'trigger_event_missing'), verdict.errors.map((e) => e.code).join(','));
162
+ });
163
+
164
+ t('a server-owned key in the patch cannot ride in through the merge', () => {
165
+ const merged = authoring.mergeForValidation(DB_ROW, { enabled: true, author_rank: 'archon', source: 'file' });
166
+ assert.equal(merged.enabled, undefined);
167
+ assert.equal(merged.author_rank, undefined);
168
+ assert.equal(merged.source, undefined);
169
+ });
170
+
171
+ t('an explicit null clears an optional field rather than being ignored', () => {
172
+ const merged = authoring.mergeForValidation({ ...DB_ROW, title: 'The Librarian' }, { title: null });
173
+ assert.equal(merged.title, null);
174
+ });
175
+
176
+ console.log('\nthe gate — shared with the CLI, so the two cannot drift:');
177
+
178
+ t('off means off on the on-demand path too', () => {
179
+ assert.equal(gateFor({ ...DB_ROW, enabled: true }).decision, 'go');
180
+ const off = gateFor({ ...DB_ROW, enabled: false });
181
+ assert.equal(off.decision, 'no-go');
182
+ assert.match(off.reason, /not enabled/);
183
+ });
184
+
185
+ t('an event agent cannot be asked, a flagged one is named as flagged, a missing one is named', () => {
186
+ assert.match(gateFor({ ...DB_ROW, enabled: true, trigger_type: 'event' }).reason, /not 'on-demand'/);
187
+ assert.match(gateFor({ ...DB_ROW, scope_violation: 'reaches src/bongos/auth.js' }).reason, /scope wall/);
188
+ assert.match(gateFor(null).reason, /no such agent/);
189
+ });
190
+
191
+ t("the gate's trigger-type constant matches the validator's vocabulary", () => {
192
+ assert.ok(validate.TRIGGER_TYPES.includes(ON_DEMAND),
193
+ 'gate.js spells a trigger type the validator does not admit');
194
+ });
195
+
196
+ console.log('\nthe answer hold — a pickup counter, not a database:');
197
+
198
+ t('an answer is held for the caller and survives being read twice', () => {
199
+ const hold = createAnswerHold();
200
+ hold.put(7, 'the answer');
201
+ assert.equal(hold.take(7), 'the answer');
202
+ // A caller polling while the fire is in flight must not consume it with the
203
+ // first successful poll.
204
+ assert.equal(hold.take(7), 'the answer');
205
+ assert.equal(hold.take('7'), 'the answer', 'the id is keyed by value, not by type');
206
+ });
207
+
208
+ t('nothing held is null, not an error', () => {
209
+ const hold = createAnswerHold();
210
+ assert.equal(hold.take(99), null);
211
+ assert.equal(hold.take(null), null);
212
+ assert.equal(hold.take(undefined), null);
213
+ });
214
+
215
+ t('a runless put is refused rather than keyed under "null"', () => {
216
+ const hold = createAnswerHold();
217
+ assert.equal(hold.put(null, 'x'), false);
218
+ assert.equal(hold.put(undefined, 'x'), false);
219
+ assert.equal(hold.size(), 0);
220
+ });
221
+
222
+ t('it EXPIRES — an uncollected answer is a copy of what the agent read, sitting in memory', () => {
223
+ let clock = 1000;
224
+ const hold = createAnswerHold({ ttlMs: 500, now: () => clock });
225
+ hold.put(1, 'early');
226
+ clock += 499;
227
+ assert.equal(hold.take(1), 'early');
228
+ clock += 2;
229
+ assert.equal(hold.take(1), null);
230
+ assert.equal(hold.size(), 0);
231
+ });
232
+
233
+ t('it is BOUNDED — a loop of invocations cannot grow the process', () => {
234
+ const hold = createAnswerHold({ maxHeld: 3 });
235
+ for (let i = 1; i <= 10; i++) hold.put(i, `a${i}`);
236
+ assert.equal(hold.size(), 3);
237
+ assert.equal(hold.take(1), null, 'the oldest went first');
238
+ assert.equal(hold.take(10), 'a10', 'the newest is still there');
239
+ });
240
+
241
+ t('re-putting an entry moves it to the BACK of the eviction queue', () => {
242
+ // Without the delete-then-set, a refreshed entry keeps its original position
243
+ // and can be evicted while it is the newest thing here.
244
+ const hold = createAnswerHold({ maxHeld: 2 });
245
+ hold.put(1, 'a');
246
+ hold.put(2, 'b');
247
+ hold.put(1, 'a-again');
248
+ hold.put(3, 'c');
249
+ assert.equal(hold.take(2), null, '2 was the oldest once 1 was refreshed');
250
+ assert.equal(hold.take(1), 'a-again');
251
+ assert.equal(hold.take(3), 'c');
252
+ });
253
+
254
+ console.log('\nthe fire budget — a blast radius, not an accounting:');
255
+
256
+ t('a builder gets exactly `limit` fires per window, then is refused with a retry', () => {
257
+ let clock = 0;
258
+ const budget = createFireBudget({ limit: 3, windowMs: 1000, now: () => clock });
259
+ for (let i = 0; i < 3; i++) assert.equal(budget.check(5).ok, true, `fire ${i + 1}`);
260
+ const refused = budget.check(5);
261
+ assert.equal(refused.ok, false);
262
+ assert.ok(refused.retryAfterSeconds > 0, 'a refusal has to say when to come back');
263
+ });
264
+
265
+ t('the window SLIDES — it is a rate, not a quota that never refills', () => {
266
+ let clock = 0;
267
+ const budget = createFireBudget({ limit: 2, windowMs: 1000, now: () => clock });
268
+ budget.check(5); budget.check(5);
269
+ assert.equal(budget.check(5).ok, false);
270
+ clock += 1001;
271
+ assert.equal(budget.check(5).ok, true, 'the oldest fire aged out of the window');
272
+ });
273
+
274
+ t('the ceiling is per BUILDER — one caller cannot exhaust another', () => {
275
+ const budget = createFireBudget({ limit: 1, windowMs: 1000, now: () => 0 });
276
+ assert.equal(budget.check(5).ok, true);
277
+ assert.equal(budget.check(5).ok, false);
278
+ assert.equal(budget.check(6).ok, true, 'a different builder has their own window');
279
+ });
280
+
281
+ t('FAIL-CLOSED on an unidentified caller — never a shared "unknown" bucket', () => {
282
+ // A shared key is worse than no limit: one caller could exhaust it for
283
+ // everyone. An unattributable fire is what the ledger exists to prevent.
284
+ const budget = createFireBudget({ limit: 5, windowMs: 1000, now: () => 0 });
285
+ for (const id of [null, undefined, '']) assert.equal(budget.check(id).ok, false, String(id));
286
+ assert.equal(budget.size(), 0, 'no bucket was created for an unidentified caller');
287
+ });
288
+
289
+ t('a numeric and a string builder id are the same builder', () => {
290
+ const budget = createFireBudget({ limit: 1, windowMs: 1000, now: () => 0 });
291
+ assert.equal(budget.check(5).ok, true);
292
+ assert.equal(budget.check('5').ok, false, 'pg returns bigints as strings — these must not be two windows');
293
+ });
294
+
295
+ t('the bucket map is bounded, and eviction forgives the QUIETEST builder', () => {
296
+ let clock = 0;
297
+ const budget = createFireBudget({ limit: 1, windowMs: 1_000_000, now: () => (clock += 1) });
298
+ for (let i = 0; i < MAX_TRACKED_BUILDERS + 10; i++) budget.check(i);
299
+ assert.ok(budget.size() <= MAX_TRACKED_BUILDERS);
300
+ // The most recent spender keeps their bucket; the least recent is the one let go.
301
+ assert.equal(budget.check(MAX_TRACKED_BUILDERS + 9).ok, false, 'the newest spender is still tracked');
302
+ assert.equal(budget.check(0).ok, true, 'the quietest was forgiven, not the busiest');
303
+ });
304
+
305
+ console.log(`\n${passed} passed, ${failed} failed`);
306
+ if (failed > 0) process.exit(1);
@@ -20,6 +20,7 @@ const require = createRequire(import.meta.url);
20
20
  const routes = require('../modules/agents/routes/agents.js');
21
21
  const validate = require('../modules/agents/lib/validate.js');
22
22
  const api = require('../src/module-api.js');
23
+ const catalog = require('../modules/government/catalog.js');
23
24
 
24
25
  const { serializeDefinition } = routes;
25
26
 
@@ -128,28 +129,88 @@ t('null arrays degrade to [], and a missing row serializes to null', () => {
128
129
  assert.equal(serializeDefinition(undefined), null);
129
130
  });
130
131
 
131
- console.log('\nthe gate — self-gated means requireBuilder, on EVERY route:');
132
+ console.log('\nthe gate — nothing here is anonymous, and every WRITE is rank-gated:');
132
133
 
133
- t('both routes mount requireBuilder, and neither is left ungated', () => {
134
- const router = routes();
135
- const mounted = router.stack.filter((l) => l.route);
136
- assert.equal(mounted.length, 2, 'exactly two routes — a third needs its own gate and its own row here');
137
- const paths = mounted.map((l) => l.route.path).sort();
138
- assert.deepEqual(paths, ['/agents', '/agents/:name']);
139
- for (const layer of mounted) {
134
+ // The surface, spelled out. A route added without a row here fails the roster
135
+ // check below, which is the point: a new verb must state its floor deliberately
136
+ // rather than inherit whatever its neighbour happened to have.
137
+ //
138
+ // The third column is the AUTHORITY ATOM a write gates on, or 'builder' for a
139
+ // self-gated read. Atoms rather than ranks because BV1.R105 requires it of every
140
+ // modules/*/routes gate tests/government_require_permission.mjs fails the
141
+ // build on a module route that still spells requireRank.
142
+ const SURFACE = Object.freeze([
143
+ ['get', '/agents', 'builder'],
144
+ ['get', '/agents/:name', 'builder'],
145
+ ['post', '/agents', 'agent.author'],
146
+ ['patch', '/agents/:name', 'agent.author'],
147
+ ['delete', '/agents/:name', 'agent.author'],
148
+ ['post', '/agents/:name/enable', 'agent.arm'],
149
+ ['post', '/agents/:name/disable', 'agent.arm'],
150
+ ['post', '/agents/:name/invoke', 'builder'],
151
+ ['get', '/agent-runs/:id', 'builder'],
152
+ ]);
153
+
154
+ t('the mounted surface is exactly the declared one — a new route needs a row here', () => {
155
+ const mounted = routes().stack.filter((l) => l.route)
156
+ .map((l) => [Object.keys(l.route.methods)[0], l.route.path]);
157
+ assert.deepEqual(
158
+ mounted.map((m) => m.join(' ')).sort(),
159
+ SURFACE.map(([m, p]) => `${m} ${p}`).sort(),
160
+ );
161
+ });
162
+
163
+ t('every route mounts requireBuilder — nothing on this surface is anonymous', () => {
164
+ for (const layer of routes().stack.filter((l) => l.route)) {
140
165
  const handlers = layer.route.stack.map((s) => s.handle);
141
166
  assert.ok(handlers.includes(api.requireBuilder),
142
- `${layer.route.path} does not mount requireBuilder — a registry read must not be anonymous`);
167
+ `${layer.route.path} does not mount requireBuilder`);
143
168
  assert.ok(handlers.length >= 2, `${layer.route.path} has no handler behind its gate`);
144
169
  }
145
170
  });
146
171
 
147
- t('every mounted route is a GET this surface writes nothing', () => {
148
- // The read half is a separate task from authoring on purpose. A write verb
149
- // appearing here would carry no author-rank clamp and no scope wall.
150
- for (const layer of routes().stack.filter((l) => l.route)) {
151
- assert.deepEqual(Object.keys(layer.route.methods), ['get'],
152
- `${layer.route.path} exposes a non-GET verb on the read surface`);
172
+ t('EVERY WRITE carries a gate behind requireBuilder, and the reads carry none', () => {
173
+ // The wall this whole task is about: a write verb with no authority gate would
174
+ // carry no author-rank clamp and no scope wall, because both of those run
175
+ // inside a handler only a permitted caller reaches.
176
+ const layers = routes().stack.filter((l) => l.route);
177
+ for (const [method, path, atom] of SURFACE) {
178
+ const layer = layers.find((l) => l.route.path === path && l.route.methods[method]);
179
+ assert.ok(layer, `${method} ${path} is not mounted`);
180
+ const handlers = layer.route.stack.map((s) => s.handle);
181
+ // requirePermission returns a fresh closure per call, so it is identified by
182
+ // POSITION: gate, then handler. A self-gated read has exactly two layers.
183
+ assert.equal(handlers.length >= 3, atom !== 'builder',
184
+ `${method} ${path} should be ${atom === 'builder' ? 'self-gated' : `gated on ${atom}`} and is not`);
185
+ assert.equal(handlers[0], api.requireBuilder, `${method} ${path}: requireBuilder must come first`);
186
+ }
187
+ });
188
+
189
+ t('each write names its atom, and no gate has regressed to requireRank', () => {
190
+ // Two failures this catches. A modules/*/routes gate spelling requireRank
191
+ // fails BV1.R105 outright; and an atom the catalog does not declare makes
192
+ // requirePermission deny everyone, so a typo'd key reads as a locked route
193
+ // rather than an open one — fail-closed, but silent without this.
194
+ const raw = require('node:fs').readFileSync(new URL('../modules/agents/routes/agents.js', import.meta.url), 'utf8');
195
+ // Comments stripped first: the header explains WHY these are atoms and not
196
+ // ranks, and naming requireRank in that explanation must not read as using it.
197
+ const src = raw.replace(/^\s*\/\/.*$/gm, '');
198
+ assert.doesNotMatch(src, /requireRank\(/, 'a module route gate must be an authority atom (BV1.R105)');
199
+ for (const atom of new Set(SURFACE.map(([, , a]) => a).filter((a) => a !== 'builder'))) {
200
+ const uses = SURFACE.filter(([, , a]) => a === atom).length;
201
+ const mounted = [...src.matchAll(new RegExp(`api\\.requirePermission\\('${atom}'\\)`, 'g'))].length;
202
+ assert.equal(mounted, uses, `${atom}: ${uses} routes declared, ${mounted} gates mounted`);
203
+ assert.ok(catalog.byKey(atom), `${atom} is not declared in the permission catalog — requirePermission would deny everyone`);
204
+ }
205
+ });
206
+
207
+ t('the two agent atoms are operational and seed at the Metic floor', () => {
208
+ // Operational (system:false) so an instance can delegate them from the
209
+ // Government tab; Metic so this admits exactly who the rank gate would have.
210
+ for (const key of ['agent.author', 'agent.arm']) {
211
+ const perm = catalog.byKey(key);
212
+ assert.equal(perm.system, false, `${key} must be delegable`);
213
+ assert.equal(perm.floor, 'metic', `${key} must seed at the Metic floor`);
153
214
  }
154
215
  });
155
216