@bongos/core 1.19.710 → 1.19.712

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.712",
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.712",
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.712",
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
 
@@ -44,10 +44,27 @@ const HEALTH_POLL_MS = 2_000;
44
44
  // GAME-ONLY entry (src/preview-server.js, ADR 0052), so only game-rendering paths
45
45
  // are previewable — GDS/internal-tooling diffs have nothing to look at there.
46
46
  // src/preview-server.js + server.js are the entrypoints that render the game.
47
- const PREVIEWABLE_PREFIXES = ['public/', 'src/world/', 'src/rooms/'];
48
- const PREVIEWABLE_FILES = new Set(['server.js', 'src/preview-server.js']);
49
- // Same surfaces as git pathspecs (no trailing slash) for scoped plumbing calls.
50
- const PREVIEWABLE_PATHSPECS = ['public', 'src/world', 'src/rooms', 'server.js', 'src/preview-server.js'];
47
+ // The GAME CLIENT directories the sandbox renders. These MOVED in the module
48
+ // carve (ADR 0083, BV1.R49 / task 1418): public/ -> modules/game/public/,
49
+ // src/world/ -> modules/game/world/, src/rooms/ -> modules/game/rooms/. The
50
+ // pre-carve spelling sat here matching NOTHING, and staging still reported
51
+ // success because the two entrypoints below kept syncing — so a builder staged,
52
+ // saw 'synced previewable surfaces', and got a sandbox serving whatever client
53
+ // was already on /workspace (task 1002062; found when a new /studio page 404'd).
54
+ const PREVIEWABLE_DIRS = ['modules/game/public', 'modules/game/world', 'modules/game/rooms'];
55
+ // The single directory the preview serves AT THE URL ROOT: modules/game/public/x
56
+ // is served as /x. pickVerifyTarget maps between the two, so it must be exactly
57
+ // one dir — hence its own constant rather than PREVIEWABLE_DIRS[0].
58
+ const PREVIEWABLE_STATIC_DIR = 'modules/game/public';
59
+ // The entrypoints that render the game. A sparse box (STARTER_SPARSE_PATHS) may
60
+ // legitimately lack these, which is why a miss here is not on its own an error.
61
+ const PREVIEWABLE_ENTRYPOINTS = ['server.js', 'src/preview-server.js'];
62
+ // DERIVED, all three of them. They used to be three hand-maintained copies of the
63
+ // same list, and the carve updated none of them; deriving removes the drift class
64
+ // rather than just this instance of it.
65
+ const PREVIEWABLE_PREFIXES = PREVIEWABLE_DIRS.map((d) => `${d}/`);
66
+ const PREVIEWABLE_FILES = new Set(PREVIEWABLE_ENTRYPOINTS);
67
+ const PREVIEWABLE_PATHSPECS = [...PREVIEWABLE_DIRS, ...PREVIEWABLE_ENTRYPOINTS];
51
68
 
52
69
  // On a box, box-game-preview.service has WorkingDirectory=/workspace and serves
53
70
  // /workspace/public — the MAIN checkout. A Claude session, though, runs in a git
@@ -316,6 +333,11 @@ function syncWorktreeToServed(deps = {}) {
316
333
  // then overlay the session branch's content.
317
334
  let synced = false;
318
335
  let lastErr = null;
336
+ // task 1002062: this collapsed to a single boolean, so a run that synced ONLY
337
+ // the entrypoints and missed every client directory reported plain success.
338
+ // Track them by name — the caller warns on a missed DIRECTORY, which is the
339
+ // shape a stale pathspec list takes.
340
+ const missed = [];
319
341
  for (const ps of PREVIEWABLE_PATHSPECS) {
320
342
  try {
321
343
  run('git', ['-C', servedRoot, 'checkout', '--', ps], { stdio: 'ignore' });
@@ -327,12 +349,16 @@ function syncWorktreeToServed(deps = {}) {
327
349
  synced = true;
328
350
  } catch (e) {
329
351
  lastErr = e; // pathspec absent in the branch (sparse/missing) — skip it
352
+ missed.push(ps);
330
353
  }
331
354
  }
332
355
  if (!synced) {
333
- return { ok: false, synced: false, reason: `git checkout ${branch} -- <previewable> in ${servedRoot} synced nothing${lastErr ? ` (${lastErr.message})` : ''}` };
356
+ return { ok: false, synced: false, missed, reason: `git checkout ${branch} -- <previewable> in ${servedRoot} synced nothing${lastErr ? ` (${lastErr.message})` : ''}` };
334
357
  }
335
- return { ok: true, synced: true, branch };
358
+ // A missed ENTRYPOINT is normal on a sparse box. A missed client DIRECTORY means
359
+ // the sandbox is about to serve a client this stage never touched.
360
+ const missedDirs = missed.filter((m) => PREVIEWABLE_DIRS.includes(m));
361
+ return { ok: true, synced: true, branch, missed, missedDirs };
336
362
  }
337
363
 
338
364
  // Pick the smallest cheap-to-compare changed previewable static file to verify,
@@ -343,21 +369,26 @@ function pickVerifyTarget(deps = {}) {
343
369
  const cwd = deps.cwd || process.cwd();
344
370
  const f = deps.fs || fs;
345
371
  try {
346
- const out = run('git', ['status', '--porcelain', '--', 'public'], { cwd, encoding: 'utf8' });
372
+ // task 1002062: this hardcoded 'public' three times, independently of the
373
+ // constants above, so it kept the pre-carve spelling even after they moved.
374
+ // Derived from PREVIEWABLE_STATIC_DIR now, so there is one place to change.
375
+ const staticDir = deps.staticDir || PREVIEWABLE_STATIC_DIR;
376
+ const prefix = `${staticDir}/`;
377
+ const out = run('git', ['status', '--porcelain', '--', staticDir], { cwd, encoding: 'utf8' });
347
378
  let chosen = out
348
379
  .split('\n')
349
380
  .map((line) => line.slice(3).replace(/^"|"$/g, '').trim())
350
- .find((rel) => rel && rel.startsWith('public/') && f.existsSync(path.join(cwd, rel)));
381
+ .find((rel) => rel && rel.startsWith(prefix) && f.existsSync(path.join(cwd, rel)));
351
382
  if (!chosen) {
352
383
  // committed-only change: fall back to the diff vs the frozen origin/main
353
- const diff = run('git', ['diff', '--name-only', 'origin/main...HEAD', '--', 'public'], { cwd, encoding: 'utf8' });
384
+ const diff = run('git', ['diff', '--name-only', 'origin/main...HEAD', '--', staticDir], { cwd, encoding: 'utf8' });
354
385
  chosen = diff
355
386
  .split('\n')
356
387
  .map((s) => s.trim())
357
- .find((p) => p.startsWith('public/') && f.existsSync(path.join(cwd, p)));
388
+ .find((p) => p.startsWith(prefix) && f.existsSync(path.join(cwd, p)));
358
389
  }
359
390
  if (!chosen) return null;
360
- return { relPath: chosen, urlPath: '/' + chosen.replace(/^public\//, '') };
391
+ return { relPath: chosen, urlPath: '/' + chosen.slice(prefix.length) };
361
392
  } catch {
362
393
  return null;
363
394
  }
@@ -374,7 +405,7 @@ async function verifyServedFresh(deps = {}) {
374
405
  const cwd = deps.cwd || process.cwd();
375
406
  const base = (deps.healthUrl || HEALTH_URL).replace(/\/$/, '');
376
407
  const target = pickVerifyTarget(deps);
377
- if (!target) return { ok: true, skipped: true, reason: 'no served static change to verify (e.g. only src/rooms changed)' };
408
+ if (!target) return { ok: true, skipped: true, reason: `no served static change to verify (e.g. only ${PREVIEWABLE_DIRS.filter((d) => d !== PREVIEWABLE_STATIC_DIR).join(' or ')} changed)` };
378
409
  let want;
379
410
  try {
380
411
  want = f.readFileSync(path.join(cwd, target.relPath), 'utf8');
@@ -455,6 +486,16 @@ async function stage({ taskId } = {}, deps = {}) {
455
486
  return { ok: false, hard: true, reason: `could not sync your worktree to the served checkout (${sync.reason}); the sandbox would show stale ${deps.servedRoot || BOX_SERVED_ROOT} code` };
456
487
  }
457
488
  if (sync.synced) console.log(` synced previewable surfaces from ${sync.branch} into ${deps.servedRoot || BOX_SERVED_ROOT}`);
489
+ // task 1002062 — the tell that went unsaid for 69 days. Staging "succeeded"
490
+ // while every game-client directory matched nothing, so the sandbox served a
491
+ // client the stage never touched and the only hint was a freshness-verify skip.
492
+ if (sync.missedDirs && sync.missedDirs.length) {
493
+ console.warn(
494
+ ` ⚠ these previewable directories matched NOTHING in ${sync.branch}: ${sync.missedDirs.join(', ')}`
495
+ + ' — the sandbox will serve whatever client is already on the served checkout.'
496
+ + ' If this repo carved or renamed them, PREVIEWABLE_DIRS in sandbox-stage.js is stale.'
497
+ );
498
+ }
458
499
  }
459
500
  try {
460
501
  ctx.restart(deps);
@@ -699,6 +740,9 @@ module.exports = {
699
740
  BOX_ENV_PATH,
700
741
  BOX_SERVED_ROOT,
701
742
  PREVIEWABLE_PATHSPECS,
743
+ PREVIEWABLE_DIRS,
744
+ PREVIEWABLE_ENTRYPOINTS,
745
+ PREVIEWABLE_STATIC_DIR,
702
746
  sessionCheckoutRoot,
703
747
  syncWorktreeToServed,
704
748
  pickVerifyTarget,
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.712'; // 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