@bongos/core 1.24.6 → 1.24.8

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.
Files changed (52) hide show
  1. package/.bongos-core.json +106 -46
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +8 -0
  4. package/clients/bongos-client/index.cjs +8 -0
  5. package/clients/bongos-client/index.d.ts +13 -1
  6. package/clients/bongos-client/index.mjs +8 -0
  7. package/docs/adr/0371-auto-pickup-applies-a-submitted-tweak-on-the-runner.md +1 -0
  8. package/docs/api/openapi.json +264 -4
  9. package/docs/api-reference.md +7 -3
  10. package/docs/copy-inventory.md +41 -13
  11. package/docs/copy-registry.json +319 -35
  12. package/docs/module-api-changelog.md +4 -0
  13. package/docs/page-inventory.json +40 -4
  14. package/docs/page-readings.json +69 -9
  15. package/modules/government/decision-board.js +5 -0
  16. package/modules/government/decision-rules.js +3 -0
  17. package/modules/government/routes/government.js +2 -0
  18. package/modules/hall-ui/public/market-watch-rules-lib.js +207 -0
  19. package/modules/hall-ui/public/market-watch-rules.css +68 -0
  20. package/modules/hall-ui/public/market-watch-rules.html +54 -0
  21. package/modules/hall-ui/public/market-watch-rules.js +129 -0
  22. package/modules/hall-ui/public/market-watch-rules.states.json +17 -0
  23. package/modules/hall-ui/public/project-settings.html +4 -4
  24. package/modules/hall-ui/public/project-settings.js +51 -1
  25. package/modules/lifecycle/autopickup.js +26 -9
  26. package/modules/lifecycle/db-claims.js +1 -1
  27. package/modules/lifecycle/routes/project-settings.js +71 -10
  28. package/modules/market-watch/matter-acts.js +59 -0
  29. package/modules/market-watch/module.json +5 -4
  30. package/modules/market-watch/proposals.js +214 -0
  31. package/modules/market-watch/research.js +49 -8
  32. package/modules/market-watch/routes/rules.js +202 -0
  33. package/modules/market-watch/rules-logic.js +264 -0
  34. package/modules/market-watch/rules-view.js +165 -0
  35. package/package-lock.json +2 -2
  36. package/package.json +1 -1
  37. package/release-notes.json +12 -0
  38. package/scripts/bongos/government-walk.js +18 -2
  39. package/scripts/bongos/run-unit-tests.js +3 -0
  40. package/src/bongos/module-scope-map.js +2 -0
  41. package/src/bongos/project-settings.js +59 -15
  42. package/src/bongos/serve-internal.js +2 -1
  43. package/src/module-api.js +1 -1
  44. package/tests/autopickup_cap.mjs +203 -11
  45. package/tests/government_act_alone_buildings.mjs +3 -0
  46. package/tests/government_decision_rules.mjs +4 -1
  47. package/tests/governor_matter_acts.mjs +5 -1
  48. package/tests/hall_page_gate_map.mjs +3 -0
  49. package/tests/market_watch_module.mjs +1 -0
  50. package/tests/market_watch_rules.mjs +496 -0
  51. package/tests/market_watch_rules_db.mjs +133 -0
  52. package/tests/project_settings_page.mjs +8 -5
@@ -52,6 +52,9 @@ const SETTING_KEYS = {
52
52
  SHIP_MODE: 'ship.mode',
53
53
  TWEAK_AUTOPICKUP: 'tweak.autopickup',
54
54
  TWEAK_AUTOPICKUP_CAP: 'tweak.autopickup_monthly_cap',
55
+ // Per-rank caps (task 1005003): one row per live rank, this prefix + the rank
56
+ // (rankCapKey). An empty value means the rank follows the project cap.
57
+ TWEAK_AUTOPICKUP_RANK_CAP_PREFIX: 'tweak.autopickup_rank_cap.',
55
58
  SPEND_CAP: 'treasury.spend_cap_usd',
56
59
  // Testing new module versions first (task 1003802, ADR 0344 D1): module-testing.js.
57
60
  MODULE_TESTER: 'modules.tester',
@@ -292,9 +295,10 @@ function describeShipMode(resolved) {
292
295
  // (modules/lifecycle/autopickup.js); this file only resolves the numbers.
293
296
  const AUTOPICKUP_MODES = Object.freeze(['off', 'on']);
294
297
  const DEFAULT_AUTOPICKUP = 'off';
295
- // 20 rounds: a grade costs the project a few dollars, so 20 is a bounded, legible
296
- // monthly spend at about the level of one active artist (ADR 0371 D5).
297
- const DEFAULT_AUTOPICKUP_CAP = 20;
298
+ // 200 rounds: the owner's decision on task 1004971 (2026-10-29, Q7), raising ADR
299
+ // 0371 D5's first figure of 20. A project can set its own number, and a cap per
300
+ // builder rank (task 1005003); this is only the answer for a project that has not.
301
+ const DEFAULT_AUTOPICKUP_CAP = 200;
298
302
  // Past 1000 the cap is an off switch typed as a big number; that should be asked
299
303
  // for out loud, the same reasoning as MAX_ROT_DAYS.
300
304
  const MAX_AUTOPICKUP_CAP = 1000;
@@ -318,6 +322,16 @@ function parseAutopickupCap(raw) {
318
322
 
319
323
  // One knob through its parser: { value, source, invalid }. Never throws; an
320
324
  // unreadable database answers the default, the resolveShipMode posture.
325
+ // `row` is the already-read row (or undefined); parseRow never touches the DB.
326
+ function parseRow(key, row, parse, pick, fallback) {
327
+ if (row && row.value != null && row.value !== '') {
328
+ const got = parse(row.value);
329
+ if (pick(got) != null) return { value: pick(got), source: 'project_settings', invalid: null };
330
+ return { value: fallback, source: 'default', invalid: { where: `project_settings['${key}']`, raw: String(row.value), reason: got.reason } };
331
+ }
332
+ return { value: fallback, source: 'default', invalid: null };
333
+ }
334
+
321
335
  async function resolveOne(key, parse, pick, fallback) {
322
336
  let row = null;
323
337
  try {
@@ -326,31 +340,60 @@ async function resolveOne(key, parse, pick, fallback) {
326
340
  logger.error(`project_settings read failed; falling back to the default ${key}: ${err && err.message}`);
327
341
  return { value: fallback, source: 'default', invalid: null };
328
342
  }
329
- if (row && row.value != null && row.value !== '') {
330
- const got = parse(row.value);
331
- if (pick(got) != null) return { value: pick(got), source: 'project_settings', invalid: null };
332
- return { value: fallback, source: 'default', invalid: { where: `project_settings['${key}']`, raw: String(row.value), reason: got.reason } };
333
- }
334
- return { value: fallback, source: 'default', invalid: null };
343
+ return parseRow(key, row, parse, pick, fallback);
344
+ }
345
+
346
+ // rankCapKey(rank) — the row holding one rank's own cap (task 1005003). ONE ROW
347
+ // PER RANK, deliberately: setting or clearing a rank is a single upsert, so two
348
+ // people editing two different ranks at once can never overwrite each other (a
349
+ // shared JSON object would be read-modify-write and lose one edit). An empty
350
+ // value means "no cap of its own" (the null-is-meaningful rule above).
351
+ function rankCapKey(rank) {
352
+ return `${SETTING_KEYS.TWEAK_AUTOPICKUP_RANK_CAP_PREFIX}${rank}`;
335
353
  }
336
354
 
337
- // resolveTweakAutopickup() — { mode, cap, source: { mode, cap }, invalid: [...] }.
338
- // mode is always 'off' or 'on', cap always a usable positive integer.
355
+ // resolveTweakAutopickup() — { mode, cap, rank_caps, source: { mode, cap },
356
+ // invalid: [...] }. mode is always 'off' or 'on', cap always a usable positive
357
+ // integer, rank_caps always an object of live-ladder rank → cap (empty when no rank
358
+ // has its own). Every row is read in ONE query; each rank cap goes through
359
+ // parseAutopickupCap, the project cap's own parser, so the two cannot disagree.
339
360
  async function resolveTweakAutopickup() {
340
- const m = await resolveOne(SETTING_KEYS.TWEAK_AUTOPICKUP, parseAutopickup, (g) => g.mode, DEFAULT_AUTOPICKUP);
341
- const c = await resolveOne(SETTING_KEYS.TWEAK_AUTOPICKUP_CAP, parseAutopickupCap, (g) => g.cap, DEFAULT_AUTOPICKUP_CAP);
361
+ // Lazy: the ladder is the kernel's one copy (tests/rank_tier_single_source.mjs).
362
+ const ladder = require('./db-kernel').LIVE_RANK_LADDER;
363
+ const keys = [SETTING_KEYS.TWEAK_AUTOPICKUP, SETTING_KEYS.TWEAK_AUTOPICKUP_CAP, ...ladder.map(rankCapKey)];
364
+ let byKey = new Map();
365
+ try {
366
+ const { rows } = await pool.query('SELECT key, value FROM project_settings WHERE key = ANY($1::text[])', [keys]);
367
+ byKey = new Map(rows.map((row) => [row.key, row]));
368
+ } catch (err) {
369
+ logger.error(`project_settings read failed; falling back to the default tweak auto-pickup settings: ${err && err.message}`);
370
+ }
371
+ const m = parseRow(SETTING_KEYS.TWEAK_AUTOPICKUP, byKey.get(SETTING_KEYS.TWEAK_AUTOPICKUP), parseAutopickup, (g) => g.mode, DEFAULT_AUTOPICKUP);
372
+ const c = parseRow(SETTING_KEYS.TWEAK_AUTOPICKUP_CAP, byKey.get(SETTING_KEYS.TWEAK_AUTOPICKUP_CAP), parseAutopickupCap, (g) => g.cap, DEFAULT_AUTOPICKUP_CAP);
373
+ const rankCaps = {};
374
+ const invalid = [m.invalid, c.invalid];
375
+ for (const rank of ladder) {
376
+ const r = parseRow(rankCapKey(rank), byKey.get(rankCapKey(rank)), parseAutopickupCap, (g) => g.cap, null);
377
+ if (r.value != null) rankCaps[rank] = r.value;
378
+ invalid.push(r.invalid);
379
+ }
342
380
  return {
343
381
  mode: m.value,
344
382
  cap: c.value,
383
+ rank_caps: rankCaps,
345
384
  source: { mode: m.source, cap: c.source },
346
- invalid: [m.invalid, c.invalid].filter(Boolean),
385
+ invalid: invalid.filter(Boolean),
347
386
  };
348
387
  }
349
388
 
350
389
  function describeTweakAutopickup(resolved) {
351
390
  const r = resolved || {};
352
391
  if (r.mode !== 'on') return 'Off: a submitted page waits until a person runs /tweak for it.';
353
- return `On: a running Autobongos runner applies submitted pages on its own, up to ${r.cap} a month. The artist still approves every one.`;
392
+ const ranks = Object.entries(r.rank_caps || {});
393
+ const byRank = ranks.length
394
+ ? ` Some builder ranks have their own limit: ${ranks.map(([rank, n]) => `${rank} ${n}`).join(', ')}.`
395
+ : '';
396
+ return `On: a running Autobongos runner applies submitted pages on its own, up to ${r.cap} a month.${byRank} The artist still approves every one.`;
354
397
  }
355
398
 
356
399
  // THE SPEND CAP (task 1004712, ADR 0379). The owner's ruling: there is NO spend
@@ -416,6 +459,7 @@ module.exports = {
416
459
  MAX_AUTOPICKUP_CAP,
417
460
  parseAutopickup,
418
461
  parseAutopickupCap,
462
+ rankCapKey,
419
463
  resolveTweakAutopickup,
420
464
  describeTweakAutopickup,
421
465
  MAX_SPEND_CAP_USD,
@@ -1329,6 +1329,7 @@ function mountInternalSurfaces(app) {
1329
1329
  // The diagram image assets under /builders/diagrams/ ride the same tier as the
1330
1330
  // page that embeds them.
1331
1331
  const SIGNEDIN_BUILDERS_PREFIX_RE = /^\/builders\/diagrams\//;
1332
+ const MARKET_WATCH_PAGE_RE = /^\/builders\/market-watch(?:-[a-z]+)?(?:\.html)?\/?$/; // task 1004941: every read behind them is requireBuilder
1332
1333
  app.use((req, res, next) => {
1333
1334
  if (isModuleSurfaceHost(req)) return next();
1334
1335
  // Cheap early-out: only /builders/* paths can ever be gated, so skip the
@@ -1342,7 +1343,7 @@ function mountInternalSurfaces(app) {
1342
1343
  // behind the proxy's path normalization.
1343
1344
  const norm = path.posix.normalize(req.path);
1344
1345
  if (SIGNEDIN_BUILDERS_PAGE_RE.test(norm) || SIGNEDIN_BUILDERS_PREFIX_RE.test(norm)
1345
- || TASK_DETAIL_PAGE_RE.test(norm) || IDEA_DETAIL_PAGE_RE.test(norm)) {
1346
+ || TASK_DETAIL_PAGE_RE.test(norm) || IDEA_DETAIL_PAGE_RE.test(norm) || MARKET_WATCH_PAGE_RE.test(norm)) {
1346
1347
  return gdsAuth.requireBuilderPage(req, res, next);
1347
1348
  }
1348
1349
  next();
package/src/module-api.js CHANGED
@@ -81,7 +81,7 @@ const { sleep } = require('./sleep');
81
81
  // MAJOR (see allowBoxScope below): passes the request through untouched.
82
82
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
83
83
 
84
- const CORE_VERSION = '1.24.6'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
84
+ const CORE_VERSION = '1.24.8'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
85
85
 
86
86
  // A namespaced logger so a module's log lines are attributable + consistent.
87
87
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -2,7 +2,8 @@
2
2
  // (task 1004677 / BV2.TW19a, ADR 0371 D3, D5, D6).
3
3
  //
4
4
  // Pinned here (DB-free; the race lives in tests/autopickup_cap_db.mjs):
5
- // 1. the two parsers and the resolver: off by default, cap 20 by default, an
5
+ // 1. the parsers and the resolver: off by default, cap 200 by default (task
6
+ // 1005003; it was 20), per-rank caps read from one JSON row, an
6
7
  // unusable stored value reported and never thrown, a dead DB answers defaults;
7
8
  // 2. the refusal ladder: not a tweak, switch off, round limit, monthly cap, and the
8
9
  // lock is taken BEFORE either count;
@@ -35,7 +36,12 @@ async function withPool(query, fn) {
35
36
  pool.query = query;
36
37
  try { return await fn(); } finally { pool.query = saved; }
37
38
  }
38
- const settingsRows = (map) => async (_sql, [key]) => ({ rows: key in map ? [{ key, value: map[key] }] : [] });
39
+ // Answers both the single-key read (getSetting) and the one-query batch read
40
+ // (key = ANY($1)) that resolveTweakAutopickup makes.
41
+ const settingsRows = (map) => async (_sql, [key]) => {
42
+ const keys = Array.isArray(key) ? key : [key];
43
+ return { rows: keys.filter((k) => k in map).map((k) => ({ key: k, value: map[k] })) };
44
+ };
39
45
 
40
46
  // ---- 1. the setting ---------------------------------------------------------
41
47
 
@@ -50,11 +56,11 @@ test('the parsers accept exactly off|on and a whole positive cap', () => {
50
56
  assert.match(parseAutopickupCap('100000').reason, /out of range/);
51
57
  });
52
58
 
53
- test('an untouched project is off with a cap of 20', async () => {
59
+ test('an untouched project is off with a cap of 200 and no rank caps', async () => {
54
60
  assert.equal(DEFAULT_AUTOPICKUP, 'off');
55
- assert.equal(DEFAULT_AUTOPICKUP_CAP, 20);
61
+ assert.equal(DEFAULT_AUTOPICKUP_CAP, 200);
56
62
  const r = await withPool(settingsRows({}), () => resolveTweakAutopickup());
57
- assert.deepEqual(r, { mode: 'off', cap: 20, source: { mode: 'default', cap: 'default' }, invalid: [] });
63
+ assert.deepEqual(r, { mode: 'off', cap: 200, rank_caps: {}, source: { mode: 'default', cap: 'default' }, invalid: [] });
58
64
  assert.match(describeTweakAutopickup(r), /waits until a person/);
59
65
  });
60
66
 
@@ -63,7 +69,7 @@ test('stored values are read; an unusable one is reported and falls back', async
63
69
  () => resolveTweakAutopickup());
64
70
  assert.equal(r.mode, 'on');
65
71
  assert.equal(r.source.mode, 'project_settings');
66
- assert.equal(r.cap, 20);
72
+ assert.equal(r.cap, 200);
67
73
  assert.equal(r.invalid.length, 1);
68
74
  assert.equal(r.invalid[0].raw, 'lots');
69
75
  assert.match(describeTweakAutopickup({ mode: 'on', cap: 7 }), /up to 7 a month/);
@@ -72,7 +78,7 @@ test('stored values are read; an unusable one is reported and falls back', async
72
78
  test('a database that cannot be read answers the defaults', async () => {
73
79
  const r = await withPool(async () => { throw new Error('db down'); }, () => resolveTweakAutopickup());
74
80
  assert.equal(r.mode, 'off');
75
- assert.equal(r.cap, 20);
81
+ assert.equal(r.cap, 200);
76
82
  });
77
83
 
78
84
  // ---- 2. the refusal ladder --------------------------------------------------
@@ -128,7 +134,7 @@ test('the monthly cap refuses at the cap and admits below it', async () => {
128
134
  test('claimTask runs the gate before the insert, and names the column only on a marked claim', () => {
129
135
  const src = fs.readFileSync(path.join(ROOT, 'modules', 'lifecycle', 'db-claims.js'), 'utf8');
130
136
  const body = src.slice(src.indexOf('async function claimTask('), src.indexOf('async function claimTasksBatch'));
131
- const gate = body.indexOf('autoPickupRefusal(client, task, autoPickupSettings)');
137
+ const gate = body.indexOf('autoPickupRefusal(client, task, autoPickupSettings, builderRank)');
132
138
  const insert = body.indexOf('INSERT INTO claims');
133
139
  assert.ok(gate > 0 && insert > gate, 'the gate runs inside the transaction, before the insert');
134
140
  assert.match(body.slice(gate, insert), /if \(refusal\) throw refusal;/, 'a refusal stops the claim');
@@ -155,15 +161,19 @@ const fakeRes = () => ({
155
161
  json(p) { this.payload = p; return this; },
156
162
  });
157
163
 
158
- function buildRouter({ used = 4 } = {}) {
164
+ function buildRouter({ used = 4, rankCaps = {} } = {}) {
159
165
  const ps = api.projectSettings;
160
166
  const saved = { resolve: ps.resolveTweakAutopickup, set: ps.set };
161
167
  const writes = [];
162
- const state = { mode: 'off', cap: 20 };
168
+ const state = { mode: 'off', cap: 20, rank_caps: { ...rankCaps } };
163
169
  ps.resolveTweakAutopickup = async () => ({ ...state, source: { mode: 'default', cap: 'default' }, invalid: [] });
164
170
  ps.set = async (key, value, by) => {
165
171
  writes.push({ key, value, by });
166
- if (key === 'tweak.autopickup') state.mode = value; else state.cap = value;
172
+ if (key === 'tweak.autopickup') state.mode = value;
173
+ else if (key.startsWith('tweak.autopickup_rank_cap.')) {
174
+ const rank = key.slice('tweak.autopickup_rank_cap.'.length);
175
+ if (value === '') delete state.rank_caps[rank]; else state.rank_caps[rank] = value;
176
+ } else state.cap = value;
167
177
  };
168
178
  const autopickup = require('../modules/lifecycle/autopickup.js');
169
179
  const savedCount = autopickup.countThisMonth;
@@ -234,3 +244,185 @@ test('core_275 is additive: one nullable column, recorded in schema_migrations',
234
244
  assert.doesNotMatch(sql, /NOT NULL|DROP /);
235
245
  assert.match(sql, /INSERT INTO schema_migrations \(version\) VALUES \('core_275_claims_auto_pickup'\)/);
236
246
  });
247
+
248
+ // ---- 5. the cap per project and per builder rank (task 1005003) ------------
249
+
250
+ const { rankCapKey } = require('../src/bongos/project-settings.js');
251
+ const { effectiveCap } = require('../modules/lifecycle/autopickup.js');
252
+ const seams = require(path.join(ROOT, 'src', 'module-seams.js'));
253
+ const { attachFail } = require(path.join(ROOT, 'src', 'bongos', 'middleware', 'error-envelope.js'));
254
+
255
+ test('a project override and a rank override are both read, in ONE query', async () => {
256
+ assert.equal(rankCapKey('xenos'), 'tweak.autopickup_rank_cap.xenos');
257
+ let queries = 0;
258
+ const rows = settingsRows({
259
+ [SETTING_KEYS.TWEAK_AUTOPICKUP]: 'on',
260
+ [SETTING_KEYS.TWEAK_AUTOPICKUP_CAP]: '40',
261
+ 'tweak.autopickup_rank_cap.xenos': '5',
262
+ 'tweak.autopickup_rank_cap.archon': '80',
263
+ 'tweak.autopickup_rank_cap.metic': '', // cleared: follows the project cap
264
+ 'tweak.autopickup_rank_cap.olympian': '9', // off the live ladder: never read
265
+ });
266
+ const r = await withPool(async (sql, params) => { queries += 1; return rows(sql, params); }, () => resolveTweakAutopickup());
267
+ assert.equal(queries, 1, 'mode, cap and every rank cap come back in one round trip');
268
+ assert.equal(r.cap, 40);
269
+ assert.deepEqual(r.rank_caps, { xenos: 5, archon: 80 });
270
+ assert.deepEqual(r.invalid, []);
271
+ assert.equal(describeTweakAutopickup(r),
272
+ 'On: a running Autobongos runner applies submitted pages on its own, up to 40 a month. Some builder ranks have their own limit: xenos 5, archon 80. The artist still approves every one.');
273
+
274
+ const bad = await withPool(settingsRows({ 'tweak.autopickup_rank_cap.xenos': '-1' }), () => resolveTweakAutopickup());
275
+ assert.deepEqual(bad.rank_caps, {}, 'an unusable rank row falls back to the project cap');
276
+ assert.equal(bad.invalid.length, 1);
277
+ assert.equal(bad.invalid[0].where, "project_settings['tweak.autopickup_rank_cap.xenos']");
278
+ });
279
+
280
+ test('the effective cap is the rank\'s own cap, else the project cap', () => {
281
+ const s = { mode: 'on', cap: 200, rank_caps: { xenos: 5 } };
282
+ assert.deepEqual(effectiveCap(s, 'xenos'), { cap: 5, from: 'rank' });
283
+ assert.deepEqual(effectiveCap(s, 'metic'), { cap: 200, from: 'project' });
284
+ assert.deepEqual(effectiveCap(s, null), { cap: 200, from: 'project' });
285
+ assert.deepEqual(effectiveCap({ cap: 200 }, 'xenos'), { cap: 200, from: 'project' }, 'no rank caps at all');
286
+ });
287
+
288
+ test('the default cap of 200 admits round 199 and refuses at 200', async () => {
289
+ const s = { mode: 'on', cap: DEFAULT_AUTOPICKUP_CAP, rank_caps: {} };
290
+ assert.equal(await autoPickupRefusal(fakeClient({ month: 199 }), tweak, s, 'metic'), null);
291
+ const r = await autoPickupRefusal(fakeClient({ month: 200 }), tweak, s, 'metic');
292
+ assert.equal(r.code, 'AUTO_PICKUP_CAP_REACHED');
293
+ assert.equal(r.cap, 200);
294
+ assert.equal(r.cap_from, 'project');
295
+ assert.equal(r.message, 'Auto-pickup has used this month\'s 200 rounds, so submitted pages wait for a person until next month.');
296
+ });
297
+
298
+ test('a rank override replaces the project cap for that rank only, both ways', async () => {
299
+ const s = { mode: 'on', cap: 50, rank_caps: { xenos: 10, archon: 80 } };
300
+ // lower than the project cap: refuses a xenos runner where a metic one is admitted
301
+ const lower = await autoPickupRefusal(fakeClient({ month: 10 }), tweak, s, 'xenos');
302
+ assert.equal(lower.code, 'AUTO_PICKUP_CAP_REACHED');
303
+ assert.equal(lower.cap, 10);
304
+ assert.equal(lower.cap_from, 'rank');
305
+ assert.match(lower.message, /reached 10, the limit this project sets for a xenos builder's runner, so submitted pages wait for a person/);
306
+ assert.equal(await autoPickupRefusal(fakeClient({ month: 10 }), tweak, s, 'metic'), null);
307
+ // higher than the project cap: admits an archon runner past the project's number
308
+ assert.equal(await autoPickupRefusal(fakeClient({ month: 60 }), tweak, s, 'archon'), null);
309
+ assert.equal((await autoPickupRefusal(fakeClient({ month: 60 }), tweak, s, 'metic')).code, 'AUTO_PICKUP_CAP_REACHED');
310
+ });
311
+
312
+ test('claimTask passes the claimer\'s rank, read inside the transaction, to the gate', () => {
313
+ const src = fs.readFileSync(path.join(ROOT, 'modules', 'lifecycle', 'db-claims.js'), 'utf8');
314
+ const body = src.slice(src.indexOf('async function claimTask('), src.indexOf('async function claimTasksBatch'));
315
+ const rankRead = body.indexOf('const builderRank = builderRows[0] ? builderRows[0].rank : null;');
316
+ const gate = body.indexOf('autoPickupRefusal(client, task, autoPickupSettings, builderRank)');
317
+ assert.ok(rankRead > 0 && gate > rankRead);
318
+ });
319
+
320
+ test('the read reports the caller\'s own cap and what is left of it', async () => {
321
+ const { route, restore } = buildRouter({ used: 4, rankCaps: { xenos: 6 } });
322
+ try {
323
+ const get = handler(route('get', '/project-settings/tweak-autopickup'));
324
+ const x = fakeRes();
325
+ await get({ builder: { id: 1, rank: 'xenos' } }, x);
326
+ assert.deepEqual([x.payload.my_cap, x.payload.my_cap_from, x.payload.remaining_this_month], [6, 'rank', 2]);
327
+ assert.deepEqual(x.payload.rank_caps, { xenos: 6 });
328
+ const m = fakeRes();
329
+ await get({ builder: { id: 2, rank: 'metic' } }, m);
330
+ assert.deepEqual([m.payload.my_cap, m.payload.my_cap_from, m.payload.remaining_this_month], [20, 'project', 16]);
331
+ } finally { restore(); }
332
+ });
333
+
334
+ test('rank_caps is a partial update: a number sets, null clears, an unnamed rank is kept', async () => {
335
+ const { route, writes, restore } = buildRouter({ rankCaps: { xenos: 5, thetes: 9 } });
336
+ try {
337
+ const patch = handler(route('patch', '/project-settings/tweak-autopickup'));
338
+ const res = fakeRes();
339
+ await patch({ body: { rank_caps: { metic: 30, xenos: null } }, builder: { id: 7, rank: 'metic' } }, res);
340
+ assert.equal(res.code, null);
341
+ // One single-row upsert per named rank — never a read-modify-write of a shared
342
+ // object, so it cannot overwrite a rank someone else is editing at the same time.
343
+ assert.deepEqual(writes, [
344
+ { key: 'tweak.autopickup_rank_cap.metic', value: 30, by: 7 },
345
+ { key: 'tweak.autopickup_rank_cap.xenos', value: '', by: 7 },
346
+ ]);
347
+ assert.deepEqual(res.payload.rank_caps, { thetes: 9, metic: 30 });
348
+ assert.equal(res.payload.my_cap, 30);
349
+ } finally { restore(); }
350
+ });
351
+
352
+ test('two people saving two different ranks at once both keep their edit', async () => {
353
+ const { route, restore } = buildRouter();
354
+ try {
355
+ const patch = handler(route('patch', '/project-settings/tweak-autopickup'));
356
+ const a = fakeRes();
357
+ const b = fakeRes();
358
+ await Promise.all([
359
+ patch({ body: { rank_caps: { xenos: 5 } }, builder: { id: 7 } }, a),
360
+ patch({ body: { rank_caps: { archon: 80 } }, builder: { id: 8 } }, b),
361
+ ]);
362
+ const after = fakeRes();
363
+ await handler(route('get', '/project-settings/tweak-autopickup'))({ builder: { id: 9, rank: 'metic' } }, after);
364
+ assert.deepEqual(after.payload.rank_caps, { xenos: 5, archon: 80 });
365
+ } finally { restore(); }
366
+ });
367
+
368
+ test('an unknown rank or a bad rank number writes nothing, the project cap included', async () => {
369
+ const { route, writes, restore } = buildRouter();
370
+ try {
371
+ const patch = handler(route('patch', '/project-settings/tweak-autopickup'));
372
+ const unknown = fakeRes();
373
+ await patch({ body: { cap: 100, rank_caps: { olympian: 5 } }, builder: { id: 7 } }, unknown);
374
+ assert.equal(unknown.code, 'bad_tweak_autopickup');
375
+ assert.match(unknown.payload.message, /unknown rank 'olympian'/);
376
+ const zero = fakeRes();
377
+ await patch({ body: { cap: 100, rank_caps: { xenos: 0 } }, builder: { id: 7 } }, zero);
378
+ assert.match(zero.payload.message, /rank_caps\.xenos: must be positive/);
379
+ assert.equal(writes.length, 0);
380
+ } finally { restore(); }
381
+ });
382
+
383
+ // The server's permission check on the write, run for real: the route's own
384
+ // requirePermission layer, against a stub government port (the
385
+ // tests/government_require_permission.mjs double). Whoever holds
386
+ // project.settings.manage — the project owner, or anyone granted it — passes; a
387
+ // builder without it is refused with 403 before the handler runs.
388
+ async function runWriteGate(heldKeys) {
389
+ const { route, restore } = buildRouter();
390
+ try {
391
+ const stack = route('patch', '/project-settings/tweak-autopickup').stack;
392
+ const gate = stack[1].handle;
393
+ assert.equal(gate.name, 'permissionGate', 'the second layer is the kernel permission gate');
394
+ seams._reset();
395
+ seams.registerProvider('government', { resolveBuilderPermissions: async () => heldKeys });
396
+ const res = { _status: null, _json: null, status(c) { this._status = c; return this; }, json(b) { this._json = b; return this; } };
397
+ attachFail({}, res, () => {});
398
+ let passed = false;
399
+ await gate({ builder: { id: 7, rank: 'metic' }, body: { rank_caps: { xenos: 5 } } }, res, () => { passed = true; });
400
+ return { passed, res };
401
+ } finally { seams._reset(); restore(); }
402
+ }
403
+
404
+ test('a builder holding project.settings.manage may set the caps', async () => {
405
+ const { passed, res } = await runWriteGate(['project.settings.manage']);
406
+ assert.equal(passed, true);
407
+ assert.equal(res._status, null);
408
+ });
409
+
410
+ test('a builder without project.settings.manage is refused', async () => {
411
+ const { passed, res } = await runWriteGate(['idea.triage']);
412
+ assert.equal(passed, false);
413
+ assert.equal(res._status, 403);
414
+ assert.equal(res._json.error.code, 'permission_forbidden');
415
+ assert.deepEqual(res._json.error.details.required, ['project.settings.manage']);
416
+ });
417
+
418
+ test('the settings page row offers the project cap and one box per live rank', () => {
419
+ const { tweakCapRow } = require('../modules/lifecycle/routes/project-settings.js');
420
+ const row = tweakCapRow({ mode: 'off', cap: 200, rank_caps: { xenos: 5 }, source: { cap: 'default' }, invalid: [] });
421
+ assert.equal(row.key, 'tweak_round_cap');
422
+ assert.equal(row.editable, true);
423
+ assert.equal(row.value.cap, 200);
424
+ assert.deepEqual(row.value.ranks, api.LIVE_RANK_LADDER.map((rank) => ({ rank, cap: rank === 'xenos' ? 5 : null })));
425
+ const page = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'project-settings.js'), 'utf8');
426
+ assert.match(page, /row\.key === 'tweak_round_cap'/);
427
+ assert.match(page, /sendJson\('PATCH', '\/project-settings\/tweak-autopickup', \{ cap: Number\(\$\('ps-tweak-cap'\)\.value\), rank_caps: rankCaps \}\)/);
428
+ });
@@ -222,6 +222,7 @@ for (const rel of [
222
222
  'modules/security/routes/reports.js',
223
223
  'modules/onboarding/routes/access-requests.js',
224
224
  'modules/government/routes/government.js',
225
+ 'modules/market-watch/routes/rules.js',
225
226
  ]) app.use(require(path.join(ROOT, rel))());
226
227
  const server = http.createServer(app);
227
228
  await new Promise((r) => server.listen(0, '127.0.0.1', r));
@@ -243,6 +244,8 @@ const REQUESTS = {
243
244
  'PATCH /project-settings/ship-mode': ['PATCH', '/project-settings/ship-mode', { mode: 'sometimes' }],
244
245
  'PATCH /project-settings/tweak-autopickup': ['PATCH', '/project-settings/tweak-autopickup', { mode: 'sometimes' }],
245
246
  'PATCH /work-categories/:id': ['PATCH', `/work-categories/${BAD}`, {}],
247
+ // task 1004941: the owner deciding a Market Watch proposal
248
+ 'POST /market-watch/proposals/:id/decide': ['POST', `/market-watch/proposals/${BAD}/decide`, { decision: 'approve' }],
246
249
  'POST /security/reports/:id/confirm': ['POST', `/security/reports/${BAD}/confirm`, {}],
247
250
  'POST /security/reports/:id/dispute': ['POST', `/security/reports/${BAD}/dispute`, {}],
248
251
  'POST /security/reports/:id/fix': ['POST', `/security/reports/${BAD}/fix`, {}],
@@ -475,7 +475,10 @@ test('a matter\'s act is checked when it is opened: People only, a custom rank o
475
475
  openBoardItem: async () => { throw new Error('must not be reached'); },
476
476
  }, async () => {
477
477
  const act = (a) => ({ type: 'assign_rank', builder_id: 7, rank_key: 'tester', ...a });
478
- assert.equal((await board.openMatter({ area: 'rules', title: 't', action: act(), proposedBy: '42' })).reason, 'action_not_supported_here');
478
+ // task 1004941: Rules now carries one act of its own (a Market Watch proposal), so a
479
+ // People act there is the wrong act; a building with no acts still refuses any act
480
+ assert.equal((await board.openMatter({ area: 'rules', title: 't', action: act(), proposedBy: '42' })).reason, 'bad_action');
481
+ assert.equal((await board.openMatter({ area: 'planning', title: 't', action: act(), proposedBy: '42' })).reason, 'action_not_supported_here');
479
482
  assert.equal((await board.openMatter({ area: 'people', title: 't', action: act({ rank_key: 'metic' }), proposedBy: '42' })).reason, 'seeded_rank');
480
483
  assert.equal((await board.openMatter({ area: 'people', title: 't', action: act({ rank_key: 'ghost' }), proposedBy: '42' })).reason, 'unknown_rank');
481
484
  assert.equal((await board.openMatter({ area: 'people', title: 't', action: act({ type: 'promote' }), proposedBy: '42' })).reason, 'bad_action');
@@ -39,6 +39,7 @@ const sittingRow = (area, decided_by, rule) => ({ area, decided_by, rule, who: n
39
39
  autoActs.contributeAutomationMatterActs();
40
40
  lifeActs.contributeLifecycleMatterActs();
41
41
  buildersRoute();
42
+ require(path.join(ROOT, 'modules', 'market-watch', 'matter-acts.js')).contributeMarketWatchMatterActs();
42
43
 
43
44
  // ── the shape of each act ───────────────────────────────────────────────────
44
45
 
@@ -50,8 +51,11 @@ test('each building\'s act is checked strictly when the matter is opened: shape,
50
51
  assert.deepEqual(v('review', { type: 'satisfy_criterion', criterion_id: 9 }).action, { type: 'satisfy_criterion', criterion_id: 9 });
51
52
  assert.deepEqual(v('treasury', { type: 'set_budget', builder_id: 7, monthly_usd: 50 }).action, { type: 'set_budget', builder_id: 7, monthly_usd: 50 });
52
53
  assert.equal(v('treasury', { type: 'set_budget', builder_id: 7, monthly_usd: null }).action.monthly_usd, null, 'null clears the limit');
54
+ // task 1004941: Rules carries one act, a Market Watch proposal approved by vote
55
+ assert.deepEqual(v('rules', { type: 'market_watch_proposal', proposal_id: '5' }).action, { type: 'market_watch_proposal', proposal_id: 5 });
56
+ assert.equal(v('rules', { type: 'market_watch_proposal', proposal_id: 0 }).reason, 'bad_action');
53
57
  // refusals, each named
54
- assert.equal(v('rules', { type: 'switch_on' }).reason, 'action_not_supported_here');
58
+ assert.equal(v('rules', { type: 'switch_on' }).reason, 'bad_action', 'an act from another building');
55
59
  assert.equal(v('planning', { type: 'switch_on' }).reason, 'action_not_supported_here');
56
60
  assert.equal(v('automation', { type: 'satisfy_criterion', criterion_id: 9 }).reason, 'bad_action', 'an act from another building');
57
61
  assert.equal(v('automation', { type: 'switch_on', goal_id: 3 }).reason, 'unknown_field');
@@ -49,6 +49,9 @@ const ORACLE = {
49
49
  // task 1004473: the Commands page — its two reads (installed skills, CLI verbs)
50
50
  // are requireBuilder, so sign-in is the whole gate.
51
51
  '/builders/commands': 'requireBuilderPage',
52
+ // task 1004941: the Market Watch rules page — every member reads it (its read is
53
+ // requireBuilder); its writes check the owner or the government server-side.
54
+ '/builders/market-watch-rules': 'requireBuilderPage',
52
55
  // task 1004685 (the living-city prototype): My agents — the viewer's own agent
53
56
  // settings. Every read behind it is the viewer's own (GET /me, /me/* prefs,
54
57
  // /specialities) and requireBuilder, so sign-in is the whole gate.
@@ -41,6 +41,7 @@ test('the manifest validates, ships disabled, declares a payer, and contributes
41
41
  for (const p of ['hub-seed', 'weekly-run', 'weekly-ask']) assert.ok(c.pollers.includes(p), `poller ${p}`);
42
42
  assert.ok(c.routes.includes('ask'), 'the weekly ask routes');
43
43
  assert.ok(c.uiSections.includes('weekly-ask'), 'the weekly ask hall slice (hallWidgetScripts needs a uiSection)');
44
+ assert.ok(c.routes.includes('rules'), 'the rules page routes (task 1004941)');
44
45
  });
45
46
 
46
47
  test('the loader discovers it from the live tree — loaded, not an error, not skipped', () => {