@bongos/core 1.21.35 → 1.21.36

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 (36) hide show
  1. package/.bongos-core.json +54 -39
  2. package/clients/bongos-client/index.d.ts +3 -3
  3. package/docs/api/openapi.json +25 -35
  4. package/docs/api-reference.md +1 -1
  5. package/docs/module-api-changelog.md +2 -0
  6. package/docs/page-inventory.json +4 -1
  7. package/docs/page-readings.json +350 -304
  8. package/modules/autonomy/matter-acts.js +41 -0
  9. package/modules/autonomy/routes/autonomy.js +2 -0
  10. package/modules/government/board.js +4 -0
  11. package/modules/government/db.js +14 -0
  12. package/modules/government/decision-board.js +90 -11
  13. package/modules/government/docket-vocab.js +5 -0
  14. package/modules/government/docket.js +11 -0
  15. package/modules/government/routes/government.js +2 -1
  16. package/modules/hall-ui/public/street-card.js +62 -2
  17. package/modules/hall-ui/public/street-desk.js +101 -8
  18. package/modules/hall-ui/public/street-inside.js +6 -3
  19. package/modules/hall-ui/public/street.css +15 -0
  20. package/modules/hall-ui/public/street.js +73 -4
  21. package/modules/hall-ui/public/street.states.json +124 -0
  22. package/modules/ideas/governor-docket.js +91 -0
  23. package/modules/lifecycle/governor-docket.js +69 -0
  24. package/modules/lifecycle/matter-acts.js +63 -0
  25. package/modules/lifecycle/routes/done-when.js +26 -12
  26. package/modules/lifecycle/routes/gate-approvals.js +74 -59
  27. package/modules/lifecycle/routes/lifecycle.js +2 -0
  28. package/package-lock.json +2 -2
  29. package/package.json +1 -1
  30. package/release-notes.json +10 -0
  31. package/src/bongos/route-rank-check.js +2 -0
  32. package/src/bongos/routes/builders.js +56 -1
  33. package/src/module-api.js +1 -1
  34. package/tests/governor_matter_acts.mjs +241 -0
  35. package/tests/hall_street_desk.mjs +244 -4
  36. package/tests/hall_street_inside.mjs +3 -3
@@ -25,6 +25,7 @@ const goalEdits = require('../goal-edits.js');
25
25
  const uatDb = require('../criterion-uat-db.js');
26
26
  const { isReservedCriterionSlug } = require('../criterion-ref.js');
27
27
  const { validateOrRespond, parseId, asyncHandler, LIMITS } = api;
28
+ const log = api.logger('lifecycle');
28
29
 
29
30
  // authorizeCriterionCreate — the ORDERED, FAIL-CLOSED decision for a non-Archon
30
31
  // POST /versions/:id/done-when (ADR 0154). PURE + exported so every rejection is
@@ -80,6 +81,24 @@ function authorizeCriterionCreate({ goal, version, actorId, actorRank, actorMemb
80
81
  return { ok: true };
81
82
  }
82
83
 
84
+ // Close a criterion WITHOUT a UAT, on a stated reason: the override row and the
85
+ // flip in ONE transaction (an override row with no flip behind it would claim a
86
+ // close that never happened), then the goal achieves if that was its last
87
+ // criterion (a criterion with no goal completes none). Shared by POST
88
+ // /done-when/:id/satisfy and a passed Review matter (task 1004561,
89
+ // ../matter-acts.js) so the two can never differ.
90
+ async function satisfyByOverride({ criterionId, signerId, reason, taskId = null }) {
91
+ const updated = await api.withTx(async (client) => {
92
+ await uatDb.recordOverride(client, { criterionId, signerId, reason });
93
+ return doneWhen.markCriterion(criterionId, taskId, { client });
94
+ }, { pool: api.pool });
95
+ let goalResult = { achieved: false, goal: null };
96
+ if (updated?.goal_id != null) {
97
+ goalResult = await db.achieveGoalIfComplete({ goalId: updated.goal_id });
98
+ }
99
+ return { updated, goalResult };
100
+ }
101
+
83
102
  module.exports = function buildDoneWhenRouter() {
84
103
  const router = express.Router();
85
104
 
@@ -248,18 +267,7 @@ module.exports = function buildDoneWhenRouter() {
248
267
  });
249
268
  }
250
269
  const taskId = req.body?.satisfied_by_task_id ?? null;
251
- // ONE transaction: an override row with no flip behind it would claim a
252
- // close that never happened, so the two commit together or not at all.
253
- const updated = await api.withTx(async (client) => {
254
- await uatDb.recordOverride(client, { criterionId: id, signerId: req.builder.id, reason });
255
- return doneWhen.markCriterion(id, taskId, { client });
256
- }, { pool: api.pool });
257
- // Auto-achieve: confirming a goal's last criterion flips it open→achieved.
258
- // A criterion with no goal (goal_id NULL) completes no goal.
259
- let goalResult = { achieved: false, goal: null };
260
- if (updated?.goal_id != null) {
261
- goalResult = await db.achieveGoalIfComplete({ goalId: updated.goal_id });
262
- }
270
+ const { updated, goalResult } = await satisfyByOverride({ criterionId: id, signerId: req.builder.id, reason, taskId });
263
271
  // BV1.R08 (task 1003595): when the goal did NOT flip because it still holds
264
272
  // unfinished work, say so. Confirming the last criterion and getting a bare
265
273
  // `goal_achieved: false` back reads as a bug to the agent that just did it —
@@ -466,12 +474,17 @@ module.exports = function buildDoneWhenRouter() {
466
474
 
467
475
  // Auth: flip a criterion back to unsatisfied (rare; for corrections).
468
476
  // rank: archon — un-flip a criterion; same gate as satisfy.
477
+ // An optional `reason` (the Review desk's one-tap chip, task 1004561) is
478
+ // logged beside who undid it; the flip itself is unchanged.
469
479
  router.post('/done-when/:criterionId/unsatisfy', auth.requireBuilder, auth.requirePermission('criterion.unsatisfy'), asyncHandler('POST /done-when/:criterionId/unsatisfy', async (req, res) => {
480
+ if (validateOrRespond(req, res, { reason: { type: 'string', maxLength: 200 } })) return;
470
481
  const id = parseId(req, res, { param: 'criterionId', code: 'bad_criterion_id' });
471
482
  if (id === null) return;
472
483
  const existing = await doneWhen.getCriterion(id);
473
484
  if (!existing) return res.fail('criterion_not_found', 404);
474
485
  const updated = await doneWhen.unmarkCriterion(id);
486
+ const why = typeof req.body?.reason === 'string' && req.body.reason.trim() ? `: ${req.body.reason.trim()}` : '';
487
+ log.info(`[gds] criterion ${id} sign-off UNDONE by builder ${req.builder.id}${why}`);
475
488
  res.json({ criterion: updated });
476
489
  }, { errorCode: 'unsatisfy_failed' }));
477
490
 
@@ -482,3 +495,4 @@ module.exports = function buildDoneWhenRouter() {
482
495
  // tests/criterion_authoring_gate.mjs (DB-free), the authorizeGoalTaskCreate
483
496
  // precedent in routes/goals.js.
484
497
  module.exports.authorizeCriterionCreate = authorizeCriterionCreate;
498
+ module.exports.satisfyByOverride = satisfyByOverride;
@@ -141,6 +141,75 @@ function contributeGateApprovalsToDocket() {
141
141
  });
142
142
  }
143
143
 
144
+ // The approval itself, shared by the route above and a passed Release matter
145
+ // (task 1004561, ../matter-acts.js) so the two can never differ. Returns
146
+ // { ok: true, body } or { ok: false, code, status, message }; throws on an
147
+ // unexpected failure (the caller says it). `login` is who approved, as the
148
+ // status description names it.
149
+ async function approveGatePr({ number, login }) {
150
+ if (!githubPush.isConfigured()) {
151
+ return { ok: false, code: 'publish_unconfigured', status: 503, message: 'No server GitHub credential configured — cannot approve.' };
152
+ }
153
+ // Resolve the live OPEN PR + its current head sha (the approval is sha-pinned,
154
+ // so a later commit's head carries no approval — red-team #2b protection).
155
+ const prs = await githubPush.listOpenPullRequests({});
156
+ const pr = prs.find((p) => p.number === number);
157
+ if (!pr) return { ok: false, code: 'pr_not_open', status: 404, message: `No open PR #${number}.` };
158
+ if (!pr.head_sha) return { ok: false, code: 'no_head_sha', status: 409, message: 'Could not resolve the PR head commit.' };
159
+
160
+ const gate = findCheck(pr, 'gate-review');
161
+ if (gate && isSuccess(gate)) {
162
+ return { ok: true, body: { ok: true, pr: number, already_clear: true, message: 'gate-review is already green — nothing to approve.' } };
163
+ }
164
+
165
+ // 1. Set the trustless owner-approval status on the head sha.
166
+ const set = await githubPush.setGateOwnerApproved({ sha: pr.head_sha, login, targetUrl: pr.url });
167
+ if (!set) {
168
+ // 500, not 502 — Cloudflare masks an origin 502 with its branded page (see the route's catch).
169
+ return { ok: false, code: 'status_set_failed', status: 500, message: 'Could not set the gate-owner-approved status.' };
170
+ }
171
+
172
+ // 2. Re-trigger gate-review (close+reopen → `pull_request: reopened`) so the
173
+ // required check re-evaluates the new approval; re-arm auto-merge.
174
+ // A PAGE TWEAK HELD FOR ITS ARTIST is reopened but NOT re-armed (task
175
+ // 1004323): clearing the gate check is the owner's act on the PR, and
176
+ // approving the page is the artist's (ADR 0341 D7). Asked fail-closed: a
177
+ // read that throws answers held, and an ordinary confirmed task left
178
+ // un-armed still lands through the publish reconciler's sweep.
179
+ const prTask = taskIdFromPr(pr);
180
+ const held = prTask != null && await pageTweakReads.heldForArtistFailClosed(prTask);
181
+ let recheck = { reopened: false, auto_merge: false };
182
+ try {
183
+ recheck = await githubPush.recheckPullRequest(held ? { number, arm: false, heldReason: HELD_NOT_ARMED.reason } : { number });
184
+ } catch (e) {
185
+ // The status IS set; if the re-trigger failed, the next push or a manual
186
+ // re-run still clears it, and the publish reconciler sweep merges once green.
187
+ console.error('[gds] gate-approval recheck failed', e);
188
+ return {
189
+ ok: true,
190
+ body: {
191
+ ok: true, pr: number, head_sha: pr.head_sha, status_set: true, recheck: 'failed',
192
+ message: 'Approval recorded. Re-run of the gate check did not fire — it will clear on the next push or a manual re-run.',
193
+ },
194
+ };
195
+ }
196
+
197
+ return {
198
+ ok: true,
199
+ body: {
200
+ ok: true,
201
+ pr: number,
202
+ head_sha: pr.head_sha,
203
+ status_set: true,
204
+ recheck,
205
+ held_for_artist: held,
206
+ message: held
207
+ ? 'Approval recorded. Re-running the gate check. This is a page tweak waiting for its artist, so it lands only once the artist approves it in the studio.'
208
+ : 'Approval recorded. Re-running the gate check — the PR merges once it goes green.',
209
+ },
210
+ };
211
+ }
212
+
144
213
  module.exports = function buildGateApprovalsRouter() {
145
214
  contributeGateApprovalsToDocket();
146
215
  const router = express.Router();
@@ -189,66 +258,10 @@ module.exports = function buildGateApprovalsRouter() {
189
258
  router.post('/gate-approvals/:pr/approve', auth.requireBuilder, auth.requirePermission('gate_approval.decide'), async (req, res) => {
190
259
  const number = parseId(req, res, { param: 'pr', code: 'bad_pr', positiveInt: true });
191
260
  if (number == null) return;
192
- if (!githubPush.isConfigured()) {
193
- return res.fail('publish_unconfigured', { status: 503, message: 'No server GitHub credential configured — cannot approve.' });
194
- }
195
261
  try {
196
- // Resolve the live OPEN PR + its current head sha (the approval is sha-pinned,
197
- // so a later commit's head carries no approval — red-team #2b protection).
198
- const prs = await githubPush.listOpenPullRequests({});
199
- const pr = prs.find((p) => p.number === number);
200
- if (!pr) return res.fail('pr_not_open', { status: 404, message: `No open PR #${number}.` });
201
- if (!pr.head_sha) return res.fail('no_head_sha', { status: 409, message: 'Could not resolve the PR head commit.' });
202
-
203
- const gate = findCheck(pr, 'gate-review');
204
- if (gate && isSuccess(gate)) {
205
- return res.json({ ok: true, pr: number, already_clear: true, message: 'gate-review is already green — nothing to approve.' });
206
- }
207
-
208
- // 1. Set the trustless owner-approval status on the head sha.
209
- const set = await githubPush.setGateOwnerApproved({
210
- sha: pr.head_sha,
211
- login: req.builder.github_login,
212
- targetUrl: pr.url,
213
- });
214
- if (!set) {
215
- // 500, not 502 — Cloudflare masks an origin 502 with its branded page (see catch).
216
- return res.fail('status_set_failed', { status: 500, message: 'Could not set the gate-owner-approved status.' });
217
- }
218
-
219
- // 2. Re-trigger gate-review (close+reopen → `pull_request: reopened`) so the
220
- // required check re-evaluates the new approval; re-arm auto-merge.
221
- // A PAGE TWEAK HELD FOR ITS ARTIST is reopened but NOT re-armed (task
222
- // 1004323): clearing the gate check is the owner's act on the PR, and
223
- // approving the page is the artist's (ADR 0341 D7). Asked fail-closed: a
224
- // read that throws answers held, and an ordinary confirmed task left
225
- // un-armed still lands through the publish reconciler's sweep.
226
- const prTask = taskIdFromPr(pr);
227
- const held = prTask != null && await pageTweakReads.heldForArtistFailClosed(prTask);
228
- let recheck = { reopened: false, auto_merge: false };
229
- try {
230
- recheck = await githubPush.recheckPullRequest(held ? { number, arm: false, heldReason: HELD_NOT_ARMED.reason } : { number });
231
- } catch (e) {
232
- // The status IS set; if the re-trigger failed, the next push or a manual
233
- // re-run still clears it, and the publish reconciler sweep merges once green.
234
- console.error('[gds] gate-approval recheck failed', e);
235
- return res.json({
236
- ok: true, pr: number, head_sha: pr.head_sha, status_set: true, recheck: 'failed',
237
- message: 'Approval recorded. Re-run of the gate check did not fire — it will clear on the next push or a manual re-run.',
238
- });
239
- }
240
-
241
- res.json({
242
- ok: true,
243
- pr: number,
244
- head_sha: pr.head_sha,
245
- status_set: true,
246
- recheck,
247
- held_for_artist: held,
248
- message: held
249
- ? 'Approval recorded. Re-running the gate check. This is a page tweak waiting for its artist, so it lands only once the artist approves it in the studio.'
250
- : 'Approval recorded. Re-running the gate check — the PR merges once it goes green.',
251
- });
262
+ const out = await approveGatePr({ number, login: req.builder.github_login });
263
+ if (!out.ok) return res.fail(out.code, { status: out.status, message: out.message });
264
+ res.json(out.body);
252
265
  } catch (err) {
253
266
  console.error('[gds] POST /gate-approvals/:pr/approve', err);
254
267
  // 500, not 502: Cloudflare replaces an origin 502/504 with its own branded
@@ -261,3 +274,5 @@ module.exports = function buildGateApprovalsRouter() {
261
274
 
262
275
  return router;
263
276
  };
277
+
278
+ module.exports.approveGatePr = approveGatePr;
@@ -139,6 +139,8 @@ function registerSeams() {
139
139
  module.exports = function buildLifecycleRouter() {
140
140
  registerSeams();
141
141
  governorDocket.contributeLifecycleToDocket();
142
+ // what a passed Review or Release matter carries out (task 1004561)
143
+ require('../matter-acts').contributeLifecycleMatterActs();
142
144
  // No HTTP surface — the lifecycle capability is a pure kernel port. The empty
143
145
  // router is only the loader's contributes.routes mount vehicle; returning a
144
146
  // valid express.Router() keeps mountModuleRoutes' `router.use(sub)` happy
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.35",
3
+ "version": "1.21.36",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.21.35",
9
+ "version": "1.21.36",
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.21.35",
3
+ "version": "1.21.36",
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",
@@ -8970,5 +8970,15 @@
8970
8970
  "id": "1004559",
8971
8971
  "text": "Governing now happens inside the buildings. On a floor's desk you can set a builder's tier, deactivate them, invite someone by GitHub name, give or take back a custom rank; confirm, dispute or mark fixed a security report and"
8972
8972
  }
8973
+ ],
8974
+ "1.21.36": [
8975
+ {
8976
+ "id": "1004561",
8977
+ "text": "Three new governing actions, all in place inside the buildings. In Treasury you can now set each builder's monthly spending limit with one tap. In Review you can undo a sign-off with a one-tap reason. And when a council decide"
8978
+ },
8979
+ {
8980
+ "id": "1004560",
8981
+ "text": "The Planning building now has three working desks on its first floors. Priorities lists the open goals in order with a square for each promise; arrows move a goal up or down, and a save bar (with undo) sets the new order, or,"
8982
+ }
8973
8983
  ]
8974
8984
  }
@@ -194,6 +194,8 @@ const EXPECTED_RANKS = {
194
194
  // builder authority — rank + lifecycle status mutation
195
195
  'PATCH /builders/:id/rank': 'perm:builder.rank.set',
196
196
  'PATCH /builders/:id/status': 'perm:builder.status.set',
197
+ // task 1004561: the Treasury desk sets a builder's monthly limit through it
198
+ 'PATCH /builders/:id/budget': 'perm:builder.budget.set',
197
199
  // session revocation (kill another builder's / all sessions)
198
200
  'POST /auth/revoke': 'perm:auth.session.revoke',
199
201
  'POST /auth/revoke-all': 'perm:auth.session.revoke',
@@ -205,8 +205,62 @@ function contributeBuilderDesk() {
205
205
  });
206
206
  }
207
207
 
208
+ // The Treasury desk's budget rows (task 1004561): one row per active builder,
209
+ // their monthly spending limit as the fact, and the limits a viewer holding
210
+ // builder.budget.set (PATCH /builders/:id/budget's own atom) may set in one tap.
211
+ // The chip for the limit a builder already has is left off. Pure; exported.
212
+ const BUDGET_CHIPS = Object.freeze([25, 50, 100, 250]);
213
+ function budgetWords(usd) {
214
+ return usd == null ? 'no limit' : `$${Number(usd).toLocaleString('en-US', { maximumFractionDigits: 2 })} a month`;
215
+ }
216
+ function budgetDeskRows(builders) {
217
+ return (builders || []).filter(Boolean).map((b) => {
218
+ const now = b.monthly_budget_usd == null ? null : Number(b.monthly_budget_usd);
219
+ const options = BUDGET_CHIPS.filter((n) => n !== now).map((n) => ({ key: String(n), label: `$${n} a month` }));
220
+ if (now !== null) options.push({ key: 'none', label: 'no limit' });
221
+ return {
222
+ id: b.id, name: b.display_name || b.github_login || `builder ${b.id}`, fact: budgetWords(now),
223
+ facts: b.github_login ? [`@${b.github_login}`] : [], ref: { type: 'builder_budget', id: b.id },
224
+ acts: [{ key: 'budget', options: options.slice(0, 5) }],
225
+ };
226
+ });
227
+ }
228
+ // The one write both the route and a passed Treasury matter make.
229
+ async function setMonthlyBudget(builderId, usd) {
230
+ return seams.resolveOptional('reward').setBuilderMonthlyBudget(builderId, usd);
231
+ }
232
+ function contributeTreasury() {
233
+ seams.contribute('governor.docket', {
234
+ desk: 'treasury.budgets',
235
+ gate: { permission: 'builder.budget.set' },
236
+ // one read: the active roster and each limit together
237
+ read: async () => {
238
+ const { rows } = await pool.query(
239
+ `SELECT id, github_login, display_name, monthly_budget_usd
240
+ FROM builders
241
+ WHERE status = 'active'
242
+ ORDER BY COALESCE(display_name, github_login)`,
243
+ );
244
+ return budgetDeskRows(rows);
245
+ },
246
+ });
247
+ // What a passed Treasury matter carries out, when the decision table puts
248
+ // Treasury to a sitting: the same write as PATCH /builders/:id/budget.
249
+ seams.contribute('governor.matterAct', {
250
+ area: 'treasury',
251
+ type: 'set_budget',
252
+ act: async (action, ctx) => {
253
+ const updated = await setMonthlyBudget(action.builder_id, action.monthly_usd);
254
+ if (!updated) return { done: false, reason: 'builder_not_found' };
255
+ log.info(`[gds] builder ${action.builder_id} monthly budget set to ${budgetWords(action.monthly_usd)} by a passed Treasury matter (matter ${ctx && ctx.matterId})`);
256
+ return { done: true };
257
+ },
258
+ });
259
+ }
260
+
208
261
  module.exports = function buildBuildersRouter() {
209
262
  contributeBuilderDesk();
263
+ contributeTreasury();
210
264
  const router = express.Router();
211
265
 
212
266
  // rank: archon — sole chokepoint that mutates builders.rank; ADR 0016 §enforcement.
@@ -361,7 +415,7 @@ module.exports = function buildBuildersRouter() {
361
415
  // setBuilderMonthlyBudget carved to modules/economy/ (BV1.R77 / ADR 0093
362
416
  // §1-2): resolve the `reward` kernel port (economy is default-on). The
363
417
  // archon rank gate on this route is unchanged.
364
- const updated = await seams.resolveOptional('reward').setBuilderMonthlyBudget(targetId, budget);
418
+ const updated = await setMonthlyBudget(targetId, budget);
365
419
  if (!updated) return res.fail('builder_not_found', 404);
366
420
  res.json({ builder: updated });
367
421
  } catch (err) {
@@ -747,3 +801,4 @@ module.exports = function buildBuildersRouter() {
747
801
  };
748
802
 
749
803
  module.exports.builderDeskRows = builderDeskRows;
804
+ module.exports.budgetDeskRows = budgetDeskRows;
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.21.35'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.21.36'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -0,0 +1,241 @@
1
+ // tests/governor_matter_acts.mjs — a PASSED matter at Automation, Release,
2
+ // Review or Treasury carries out its act (task 1004561, goal 1000125), the way
3
+ // People already did: after the close commits, through the owning module's act,
4
+ // which calls the same function as that building's direct route. Also the two
5
+ // new desks' server halves (the Treasury budget rows, the Review undo rows) and
6
+ // the budget route's permission pin. DB-free: every write is a stub.
7
+ import { strict as assert } from 'node:assert';
8
+ import { test } from 'node:test';
9
+ import path from 'node:path';
10
+ import { createRequire } from 'node:module';
11
+ import { fileURLToPath } from 'node:url';
12
+
13
+ const require = createRequire(import.meta.url);
14
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
15
+ const GOV = path.join(ROOT, 'modules', 'government');
16
+ const api = require(path.join(ROOT, 'src', 'module-api.js'));
17
+ const seams = require(path.join(ROOT, 'src', 'module-seams.js'));
18
+ const board = require(path.join(GOV, 'board.js'));
19
+ const decisionBoard = require(path.join(GOV, 'decision-board.js'));
20
+ const db = require(path.join(GOV, 'db.js'));
21
+ const autoActs = require(path.join(ROOT, 'modules', 'autonomy', 'matter-acts.js'));
22
+ const autoDb = require(path.join(ROOT, 'modules', 'autonomy', 'db.js'));
23
+ const lifeActs = require(path.join(ROOT, 'modules', 'lifecycle', 'matter-acts.js'));
24
+ const lifeDb = require(path.join(ROOT, 'modules', 'lifecycle', 'db.js'));
25
+ const doneWhen = require(path.join(ROOT, 'modules', 'lifecycle', 'done-when.js'));
26
+ const doneWhenRoutes = require(path.join(ROOT, 'modules', 'lifecycle', 'routes', 'done-when.js'));
27
+ const gateRoutes = require(path.join(ROOT, 'modules', 'lifecycle', 'routes', 'gate-approvals.js'));
28
+ const lifeDocket = require(path.join(ROOT, 'modules', 'lifecycle', 'governor-docket.js'));
29
+ const buildersRoute = require(path.join(ROOT, 'src', 'bongos', 'routes', 'builders.js'));
30
+ const rrc = require(path.join(ROOT, 'src', 'bongos', 'route-rank-check.js'));
31
+
32
+ async function stub(obj, stubs, fn) {
33
+ const originals = {};
34
+ for (const [k, v] of Object.entries(stubs)) { originals[k] = obj[k]; obj[k] = v; }
35
+ try { return await fn(); } finally { for (const [k, v] of Object.entries(originals)) obj[k] = v; }
36
+ }
37
+ const sittingRow = (area, decided_by, rule) => ({ area, decided_by, rule, who: null, set_by_item_id: '30' });
38
+
39
+ // Register every owning module's acts once, as their routers do at boot.
40
+ autoActs.contributeAutomationMatterActs();
41
+ lifeActs.contributeLifecycleMatterActs();
42
+ buildersRoute();
43
+
44
+ // ── the shape of each act ───────────────────────────────────────────────────
45
+
46
+ test('each building\'s act is checked strictly when the matter is opened: shape, fields, nothing guessed', () => {
47
+ const v = decisionBoard.validateMatterAction;
48
+ assert.deepEqual(v('automation', { type: 'switch_on' }), { ok: true, action: { type: 'switch_on' } });
49
+ assert.deepEqual(v('automation', { type: 'allow_goal', goal_id: '12' }).action, { type: 'allow_goal', goal_id: 12 });
50
+ assert.deepEqual(v('release', { type: 'approve_gate', pr: 1500 }).action, { type: 'approve_gate', pr: 1500 });
51
+ assert.deepEqual(v('release', { type: 'approve_override', request_id: 4 }).action, { type: 'approve_override', request_id: 4 });
52
+ assert.deepEqual(v('review', { type: 'satisfy_criterion', criterion_id: 9 }).action, { type: 'satisfy_criterion', criterion_id: 9 });
53
+ assert.deepEqual(v('treasury', { type: 'set_budget', builder_id: 7, monthly_usd: 50 }).action, { type: 'set_budget', builder_id: 7, monthly_usd: 50 });
54
+ assert.equal(v('treasury', { type: 'set_budget', builder_id: 7, monthly_usd: null }).action.monthly_usd, null, 'null clears the limit');
55
+ // refusals, each named
56
+ assert.equal(v('rules', { type: 'switch_on' }).reason, 'action_not_supported_here');
57
+ assert.equal(v('planning', { type: 'switch_on' }).reason, 'action_not_supported_here');
58
+ assert.equal(v('automation', { type: 'satisfy_criterion', criterion_id: 9 }).reason, 'bad_action', 'an act from another building');
59
+ assert.equal(v('automation', { type: 'switch_on', goal_id: 3 }).reason, 'unknown_field');
60
+ assert.equal(v('automation', { type: 'allow_goal' }).field, 'action.goal_id');
61
+ assert.equal(v('automation', { type: 'allow_goal', goal_id: 0 }).reason, 'bad_action');
62
+ assert.equal(v('automation', { type: 'allow_goal', goal_id: true }).reason, 'bad_action');
63
+ assert.equal(v('treasury', { type: 'set_budget', builder_id: 7, monthly_usd: -1 }).reason, 'bad_action');
64
+ assert.equal(v('treasury', { type: 'set_budget', builder_id: 7, monthly_usd: '50' }).reason, 'bad_action');
65
+ assert.equal(v('treasury', { type: 'set_budget', builder_id: 7, monthly_usd: 1e9 }).reason, 'bad_action');
66
+ assert.equal(v('review', []).reason, 'bad_action');
67
+ // People is unchanged
68
+ assert.deepEqual(v('people', { type: 'assign_rank', builder_id: '7', rank_key: 'tester' }).action, { type: 'assign_rank', builder_id: 7, rank_key: 'tester' });
69
+ });
70
+
71
+ test('every act in the table is carried out by its owning module, and only those', () => {
72
+ for (const [area, acts] of Object.entries(decisionBoard.MATTER_ACTIONS)) {
73
+ if (area === 'people') continue; // People acts inside the government module itself
74
+ for (const type of Object.keys(acts)) assert.ok(decisionBoard.matterActFor(area, type), `${area}.${type} has an act`);
75
+ }
76
+ assert.equal(decisionBoard.matterActFor('automation', 'satisfy_criterion'), null);
77
+ });
78
+
79
+ test('opening a matter whose act no module carries out is refused before anything is filed', async () => {
80
+ const never = async () => { throw new Error('must not be reached'); };
81
+ await stub(db, { listDecisionRules: async () => [sittingRow('automation', 'rank:council', 'majority')], createMatter: never, openBoardItem: never }, async () => {
82
+ await stub(api, { contributions: () => [] }, async () => {
83
+ const out = await board.openMatter({ area: 'automation', title: 'Switch it on', action: { type: 'switch_on' }, proposedBy: '42' });
84
+ assert.equal(out.reason, 'action_not_wired');
85
+ });
86
+ });
87
+ let made = null;
88
+ await stub(db, {
89
+ listDecisionRules: async () => [sittingRow('treasury', 'rank:council', 'majority')],
90
+ createMatter: async (a) => { made = a; return { id: '8', ...a }; },
91
+ openBoardItem: async (a) => ({ id: '40', subject_type: a.subjectType, subject_id: a.subjectId }),
92
+ }, async () => {
93
+ const out = await board.openMatter({ area: 'treasury', title: 'Ana: $50 a month', action: { type: 'set_budget', builder_id: 7, monthly_usd: 50 }, proposedBy: '42' });
94
+ assert.equal(out.ok, true, JSON.stringify(out));
95
+ });
96
+ assert.deepEqual(made.action, { type: 'set_budget', builder_id: 7, monthly_usd: 50 });
97
+ });
98
+
99
+ // ── a passed matter acts; a returned one never does ─────────────────────────
100
+
101
+ async function closeMatter({ area, action, outcome = 'passed', extraDb = {} }) {
102
+ const subjectType = `${area}_matter`;
103
+ const votes = outcome === 'passed' ? [{ voter_id: '9', direction: 'yes' }] : [{ voter_id: '9', direction: 'no', reason_md: 'no' }];
104
+ const events = [];
105
+ return stub(api, { emitAsync: async (name, payload) => { events.push([name, payload]); } }, () => stub(db, {
106
+ getVotesForItem: async () => votes,
107
+ closeBoardItem: async ({ outcome: o, afterClose }) => {
108
+ assert.equal(afterClose, null, 'a matter books nothing inside the close');
109
+ return { id: '40', subject_type: subjectType, subject_id: '8', opened_by: '42', closed_at: 'T', outcome: o };
110
+ },
111
+ getMatter: async () => ({ id: '8', area, title: 'A matter', action, proposed_by: '42' }),
112
+ insertAuthorRatifyKarma: async () => { throw new Error('a matter pays nobody'); },
113
+ getBoardItemAuthor: async () => '42',
114
+ ...extraDb,
115
+ }, async () => {
116
+ const item = { id: '40', subject_type: subjectType, subject_id: '8', opened_by: '42', closed_at: null, constitution: { membership: 'rank:council', pass_rule: 'first_ratifier', rule: 'first' } };
117
+ return { out: await board.closeItem(item), events };
118
+ }));
119
+ }
120
+
121
+ test('a passed Automation matter switches the fence and allows a goal, as the proposer, through the route\'s own writes', async () => {
122
+ const writes = [];
123
+ await stub(autoDb, {
124
+ setFenceEnabled: async (a) => { writes.push(['fence', a]); return { enabled: a.enabled }; },
125
+ allowGoal: async (a) => { writes.push(['allow', a]); return { goal_id: a.goalId }; },
126
+ }, async () => {
127
+ const on = await closeMatter({ area: 'automation', action: { type: 'switch_on' } });
128
+ assert.equal(on.out.outcome, 'passed');
129
+ assert.deepEqual(on.out.acted, { area: 'automation', matter_id: '8', action: 'switch_on', done: true });
130
+ const passed = on.events.find(([n]) => n === 'board.matter.passed');
131
+ assert.equal(passed[1].acted, true);
132
+ await closeMatter({ area: 'automation', action: { type: 'switch_off' } });
133
+ await closeMatter({ area: 'automation', action: { type: 'allow_goal', goal_id: 12 } });
134
+ });
135
+ assert.deepEqual(writes.map(([k, a]) => [k, a.enabled ?? a.goalId, a.builderId]), [['fence', true, '42'], ['fence', false, '42'], ['allow', 12, '42']]);
136
+ assert.match(writes[0][1].reason, /passed Automation matter \(matter 8\)/);
137
+ });
138
+
139
+ test('a RETURNED matter carries out nothing', async () => {
140
+ let wrote = false;
141
+ await stub(autoDb, { setFenceEnabled: async () => { wrote = true; return {}; } }, async () => {
142
+ const back = await closeMatter({ area: 'automation', action: { type: 'switch_on' }, outcome: 'returned' });
143
+ assert.equal(back.out.outcome, 'returned');
144
+ assert.equal(back.out.acted, undefined);
145
+ });
146
+ assert.equal(wrote, false);
147
+ });
148
+
149
+ test('a passed Review matter signs the criterion off by override, once', async () => {
150
+ const signed = [];
151
+ await stub(doneWhenRoutes, { satisfyByOverride: async (a) => { signed.push(a); return { updated: {}, goalResult: {} }; } }, () =>
152
+ stub(doneWhen, { getCriterion: async (id) => ({ id, satisfied: id === 10 }) }, async () => {
153
+ const out = (await closeMatter({ area: 'review', action: { type: 'satisfy_criterion', criterion_id: 9 } })).out;
154
+ assert.equal(out.acted.done, true);
155
+ const again = (await closeMatter({ area: 'review', action: { type: 'satisfy_criterion', criterion_id: 10 } })).out;
156
+ assert.deepEqual([again.outcome, again.acted.done, again.acted.reason], ['passed', false, 'already_satisfied'], 'a refusal leaves the decision standing');
157
+ }));
158
+ assert.equal(signed.length, 1);
159
+ assert.equal(signed[0].criterionId, 9);
160
+ assert.equal(signed[0].signerId, '42');
161
+ assert.match(signed[0].reason, /passed Review matter/);
162
+ });
163
+
164
+ test('a passed Release matter approves an override or clears a gate; a request already decided is said, not thrown', async () => {
165
+ const calls = [];
166
+ await stub(lifeDb, {
167
+ decideOverrideRequest: async (a) => {
168
+ calls.push(['override', a]);
169
+ if (a.requestId === 5) throw Object.assign(new Error('closed'), { code: 'REQUEST_NOT_OPEN' });
170
+ return {};
171
+ },
172
+ }, () => stub(gateRoutes, { approveGatePr: async (a) => { calls.push(['gate', a]); return a.number === 2 ? { ok: false, code: 'pr_not_open' } : { ok: true, body: {} }; } }, async () => {
173
+ assert.equal((await closeMatter({ area: 'release', action: { type: 'approve_override', request_id: 4 } })).out.acted.done, true);
174
+ assert.equal((await closeMatter({ area: 'release', action: { type: 'approve_override', request_id: 5 } })).out.acted.reason, 'request_not_open');
175
+ assert.equal((await closeMatter({ area: 'release', action: { type: 'approve_gate', pr: 1500 } })).out.acted.done, true);
176
+ assert.equal((await closeMatter({ area: 'release', action: { type: 'approve_gate', pr: 2 } })).out.acted.reason, 'pr_not_open');
177
+ }));
178
+ assert.deepEqual(calls[0], ['override', { requestId: 4, archonId: '42', decision: 'approve', note: 'A passed Release matter (matter 8) decided it.' }]);
179
+ assert.equal(calls[2][1].number, 1500);
180
+ });
181
+
182
+ test('a passed Treasury matter sets the limit through the same write as PATCH /builders/:id/budget', async () => {
183
+ const set = [];
184
+ const reward = seams.resolveOptional('reward');
185
+ const fake = { setBuilderMonthlyBudget: async (id, usd) => { set.push([id, usd]); return id === 404 ? null : { id, monthly_budget_usd: usd }; } };
186
+ await stub(seams, { resolveOptional: (name, fb) => (name === 'reward' ? fake : (reward && fb)) }, async () => {
187
+ assert.equal((await closeMatter({ area: 'treasury', action: { type: 'set_budget', builder_id: 7, monthly_usd: 50 } })).out.acted.done, true);
188
+ assert.equal((await closeMatter({ area: 'treasury', action: { type: 'set_budget', builder_id: 404, monthly_usd: null } })).out.acted.reason, 'builder_not_found');
189
+ });
190
+ assert.deepEqual(set, [[7, 50], [404, null]]);
191
+ });
192
+
193
+ test('an act that throws leaves the decision passed and says it did not run', async () => {
194
+ await stub(autoDb, { setFenceEnabled: async () => { throw new Error('db down'); } }, async () => {
195
+ const { out, events } = await closeMatter({ area: 'automation', action: { type: 'switch_on' } });
196
+ assert.equal(out.outcome, 'passed');
197
+ assert.deepEqual([out.acted.done, out.acted.reason], [false, 'act_failed']);
198
+ assert.equal(events.find(([n]) => n === 'board.matter.passed')[1].acted, false);
199
+ });
200
+ });
201
+
202
+ test('a stored act that no longer validates is not carried out', async () => {
203
+ let wrote = false;
204
+ await stub(autoDb, { allowGoal: async () => { wrote = true; } }, async () => {
205
+ const { out } = await closeMatter({ area: 'automation', action: { type: 'allow_goal', goal_id: -3 } });
206
+ assert.deepEqual([out.acted.done, out.acted.reason], [false, 'bad_action']);
207
+ });
208
+ assert.equal(wrote, false);
209
+ });
210
+
211
+ // ── the two desks' server halves ────────────────────────────────────────────
212
+
213
+ test('Treasury desk: a row per builder, their limit as the fact, the other limits one tap each', () => {
214
+ const rows = buildersRoute.budgetDeskRows([{ id: 7, display_name: 'Ana', github_login: 'ana', monthly_budget_usd: '100.00' }, { id: 8, github_login: 'kai', monthly_budget_usd: null }]);
215
+ assert.equal(rows[0].fact, '$100 a month');
216
+ assert.deepEqual(rows[0].acts[0].options.map((o) => o.key), ['25', '50', '250', 'none']);
217
+ assert.ok(rows[0].acts[0].options.every((o) => o.label.length <= 16), 'every chip fits the line rule');
218
+ assert.equal(rows[1].fact, 'no limit');
219
+ assert.deepEqual(rows[1].acts[0].options.map((o) => o.key), ['25', '50', '100', '250']);
220
+ assert.deepEqual(rows[0].ref, { type: 'builder_budget', id: 7 });
221
+ });
222
+
223
+ test('Review desk: only signed-off promises, newest first, each undone with a one-tap reason', () => {
224
+ const rows = lifeDocket.signedDeskRows([
225
+ { id: 1, criterion_md: 'Old', satisfied: true, checked_at: '2026-09-01T00:00:00Z' },
226
+ { id: 2, criterion_md: 'New', satisfied: true, checked_at: '2026-10-02T00:00:00Z' },
227
+ { id: 3, criterion_md: 'Open', satisfied: false, checked_at: null },
228
+ ]);
229
+ assert.deepEqual(rows.map((r) => r.name), ['New', 'Old']);
230
+ assert.equal(rows[0].acts[0].key, 'unsign');
231
+ assert.ok(rows[0].acts[0].options.every((o) => o.label.length <= 16));
232
+ });
233
+
234
+ test('the desks are read under their routes\' own atoms, and the budget route is pinned', () => {
235
+ const desks = api.contributions('governor.docket').filter((c) => c && (c.desk === 'treasury.budgets' || c.desk === 'review.signed'));
236
+ const gates = Object.fromEntries(desks.map((c) => [c.desk, c.gate && c.gate.permission]));
237
+ assert.equal(gates['treasury.budgets'], 'builder.budget.set');
238
+ assert.equal(rrc.EXPECTED_RANKS['PATCH /builders/:id/budget'], 'perm:builder.budget.set');
239
+ const life = rrc.MODULE_EXPECTED_RANKS.lifecycle || Object.values(rrc.MODULE_EXPECTED_RANKS).find((m) => m['POST /done-when/:criterionId/unsatisfy']);
240
+ assert.equal(life['POST /done-when/:criterionId/unsatisfy'], 'perm:criterion.unsatisfy');
241
+ });