@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.
- package/.bongos-core.json +54 -39
- package/clients/bongos-client/index.d.ts +3 -3
- package/docs/api/openapi.json +25 -35
- package/docs/api-reference.md +1 -1
- package/docs/module-api-changelog.md +2 -0
- package/docs/page-inventory.json +4 -1
- package/docs/page-readings.json +350 -304
- package/modules/autonomy/matter-acts.js +41 -0
- package/modules/autonomy/routes/autonomy.js +2 -0
- package/modules/government/board.js +4 -0
- package/modules/government/db.js +14 -0
- package/modules/government/decision-board.js +90 -11
- package/modules/government/docket-vocab.js +5 -0
- package/modules/government/docket.js +11 -0
- package/modules/government/routes/government.js +2 -1
- package/modules/hall-ui/public/street-card.js +62 -2
- package/modules/hall-ui/public/street-desk.js +101 -8
- package/modules/hall-ui/public/street-inside.js +6 -3
- package/modules/hall-ui/public/street.css +15 -0
- package/modules/hall-ui/public/street.js +73 -4
- package/modules/hall-ui/public/street.states.json +124 -0
- package/modules/ideas/governor-docket.js +91 -0
- package/modules/lifecycle/governor-docket.js +69 -0
- package/modules/lifecycle/matter-acts.js +63 -0
- package/modules/lifecycle/routes/done-when.js +26 -12
- package/modules/lifecycle/routes/gate-approvals.js +74 -59
- package/modules/lifecycle/routes/lifecycle.js +2 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +10 -0
- package/src/bongos/route-rank-check.js +2 -0
- package/src/bongos/routes/builders.js +56 -1
- package/src/module-api.js +1 -1
- package/tests/governor_matter_acts.mjs +241 -0
- package/tests/hall_street_desk.mjs +244 -4
- 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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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",
|
package/release-notes.json
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
+
});
|