@bongos/core 1.21.34 → 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 (47) hide show
  1. package/.bongos-core.json +65 -50
  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 +4 -0
  6. package/docs/page-inventory.json +10 -1
  7. package/docs/page-readings.json +356 -251
  8. package/modules/autonomy/governor-docket.js +68 -1
  9. package/modules/autonomy/matter-acts.js +41 -0
  10. package/modules/autonomy/routes/autonomy.js +2 -0
  11. package/modules/government/board.js +4 -0
  12. package/modules/government/db.js +14 -0
  13. package/modules/government/decision-board.js +90 -11
  14. package/modules/government/docket-floors.js +20 -0
  15. package/modules/government/docket-vocab.js +19 -0
  16. package/modules/government/docket.js +114 -2
  17. package/modules/government/routes/government.js +15 -1
  18. package/modules/hall-ui/public/street-card.js +157 -1
  19. package/modules/hall-ui/public/street-desk.js +205 -11
  20. package/modules/hall-ui/public/street-inside.js +11 -8
  21. package/modules/hall-ui/public/street.css +16 -0
  22. package/modules/hall-ui/public/street.js +97 -9
  23. package/modules/hall-ui/public/street.states.json +355 -6
  24. package/modules/ideas/governor-docket.js +91 -0
  25. package/modules/lifecycle/governor-docket.js +93 -2
  26. package/modules/lifecycle/matter-acts.js +63 -0
  27. package/modules/lifecycle/routes/done-when.js +26 -12
  28. package/modules/lifecycle/routes/gate-approvals.js +74 -59
  29. package/modules/lifecycle/routes/lifecycle.js +2 -0
  30. package/modules/lifecycle/routes/project-settings.js +24 -0
  31. package/modules/onboarding/routes/access-requests.js +7 -0
  32. package/modules/provisioning/core-upgrade.js +1 -1
  33. package/modules/provisioning/routes/core-upgrade.js +36 -1
  34. package/modules/security/routes/reports.js +23 -0
  35. package/package-lock.json +2 -2
  36. package/package.json +1 -1
  37. package/release-notes.json +16 -0
  38. package/src/bongos/route-rank-check.js +2 -0
  39. package/src/bongos/routes/backup.js +26 -0
  40. package/src/bongos/routes/builders.js +96 -1
  41. package/src/bongos/routes/modules.js +18 -0
  42. package/src/module-api.js +1 -1
  43. package/tests/governor_docket.mjs +28 -1
  44. package/tests/governor_matter_acts.mjs +241 -0
  45. package/tests/hall_matter_card.mjs +11 -2
  46. package/tests/hall_street_desk.mjs +473 -4
  47. package/tests/hall_street_inside.mjs +3 -3
@@ -52,9 +52,63 @@ function fenceDocketMatters(project, mayManage) {
52
52
  tally: null,
53
53
  href: '/watch',
54
54
  ref: { type: 'autonomy_fence', id: 'project' },
55
+ // The floor desk's acts (task 1004559), only for the switch's own atom.
56
+ ...(mayManage ? { acts: project.enabled === false ? [{ key: 'switch_on' }] : [{ key: 'allow', pick: 'open_goals', except: [] }, { key: 'switch_off', options: OFF_REASONS }] } : {}),
55
57
  }];
56
58
  }
57
59
 
60
+ // Why the switch goes off, as one tap (POST /autonomy/fence takes a reason).
61
+ const OFF_REASONS = Object.freeze([
62
+ { key: 'pause', label: 'pause for now' },
63
+ { key: 'cost', label: 'spending too much' },
64
+ { key: 'quality', label: 'builds look wrong' },
65
+ ]);
66
+ // Why one runner is paused, as one tap (POST /autonomy/builders/:id/pause).
67
+ const PAUSE_REASONS = Object.freeze([
68
+ { key: 'pause', label: 'pause for now' },
69
+ { key: 'cost', label: 'spending too much' },
70
+ { key: 'stuck', label: 'it is stuck' },
71
+ ]);
72
+
73
+ // The Automation desks (task 1004559): rows the holder of autonomy.fence.manage
74
+ // acts on in place. Pure; exported for tests.
75
+ // The switch when it is on and has goals (otherwise the fence matter carries it).
76
+ function switchDeskRows(project) {
77
+ const goals = Array.isArray(project && project.goals) ? project.goals : [];
78
+ if (!project || project.enabled === false || !goals.length) return [];
79
+ return [{ id: 'switch', name: 'The switch', fact: `on · ${goals.length} ${goals.length === 1 ? 'goal' : 'goals'}`, facts: ['unattended building is on'], opened_at: project.updated_at || null, ref: { type: 'autonomy_fence', id: 'project' }, acts: [{ key: 'switch_off', options: OFF_REASONS }] }];
80
+ }
81
+ // One row per allowed goal (first in line, or not allowed any more), and one to
82
+ // allow another of the open goals the screen already reads.
83
+ function goalDeskRows(project) {
84
+ const goals = Array.isArray(project && project.goals) ? project.goals : [];
85
+ const first = project && project.priority_goal_id != null ? Number(project.priority_goal_id) : null;
86
+ const rows = goals.map((g) => {
87
+ const isFirst = first === Number(g.goal_id);
88
+ return {
89
+ id: g.goal_id, name: `goal ${g.goal_id}`, fact: isFirst ? 'first in line' : g.added_at ? `allowed ${dayWord(g.added_at)}` : 'allowed',
90
+ facts: isFirst ? ['the runner works it first'] : [], opened_at: g.added_at || null,
91
+ ref: { type: 'autonomy_goal', id: g.goal_id },
92
+ acts: isFirst ? [{ key: 'disallow' }] : [{ key: 'first' }, { key: 'disallow' }],
93
+ };
94
+ });
95
+ rows.push({ id: 'add', tool: true, name: 'Allow a goal', fact: goals.length ? `${goals.length} allowed now` : 'none allowed yet', ref: { type: 'autonomy_goal', id: 'add' }, acts: [{ key: 'allow', pick: 'open_goals', except: goals.map((g) => g.goal_id) }] });
96
+ return rows;
97
+ }
98
+ // One row per builder with a runner (or a pause on record): pause or resume it.
99
+ function runnerDeskRows(scopes, nowMs, describe) {
100
+ return (scopes || []).filter((b) => (b.runners || []).length || b.owner_paused).map((b) => {
101
+ const live = (b.runners || []).map((r) => ({ r, h: describe(r, { nowMs }) })).find((x) => x.h && x.h.ok && x.h.state !== 'never_seen');
102
+ const fact = b.owner_paused ? 'paused' : live && live.r.working_task_id ? `on task ${live.r.working_task_id}` : live ? 'idle' : 'not seen lately';
103
+ return {
104
+ id: b.builder_id, name: b.display_name || b.github_login || `builder ${b.builder_id}`, fact,
105
+ facts: b.owner_paused && b.owner_paused_reason ? [b.owner_paused_reason] : [],
106
+ ref: { type: 'autonomy_runner', id: b.builder_id },
107
+ acts: b.owner_paused ? [{ key: 'resume' }] : [{ key: 'pause', options: PAUSE_REASONS }],
108
+ };
109
+ });
110
+ }
111
+
58
112
  // Money left under the unattended-spend ceiling. Pure. Hot when under a fifth left.
59
113
  function moneyReading({ ceilingUsd, spentUsd }) {
60
114
  const ceiling = Number(ceilingUsd) || 0;
@@ -147,6 +201,8 @@ function fenceHistory(project) {
147
201
  const shared = (once, key, fn) => (typeof once === 'function' ? once(key, fn) : fn());
148
202
  const spendFor = (reward, once) => shared(once, 'autonomy.spendMtd', () => reward.autonomousApiSpendMtd());
149
203
  const fenceFor = (once) => shared(once, 'autonomy.fence', () => db.readFence());
204
+ // The Running now floor and the runners desk read the same rows once per request.
205
+ const runnersFor = (once) => shared(once, 'autonomy.runners', () => db.readAllRunnerScopes());
150
206
 
151
207
  function contributeAutonomyFloors() {
152
208
  api.contribute(POINT, {
@@ -156,7 +212,7 @@ function contributeAutonomyFloors() {
156
212
  // GET /autonomy/runners/all is the owner's read of every runner.
157
213
  api.contribute(POINT, {
158
214
  floor: 'automation.running', gate: { permission: 'autonomy.fence.manage' },
159
- read: async (_ctx, { now } = {}) => runningFloor(await db.readAllRunnerScopes(), now || Date.now(), runnerHealth.describeRunner),
215
+ read: async (_ctx, { now, once } = {}) => runningFloor(await runnersFor(once), now || Date.now(), runnerHealth.describeRunner),
160
216
  });
161
217
  api.contribute(POINT, {
162
218
  floor: 'automation.spending_limit', gate: { permission: 'autonomy.precheck' },
@@ -176,8 +232,16 @@ function contributeAutonomyFloors() {
176
232
  });
177
233
  }
178
234
 
235
+ function contributeAutonomyDesks() {
236
+ const gate = { permission: 'autonomy.fence.manage' };
237
+ api.contribute(POINT, { desk: 'automation.switch', gate, read: async (_ctx, { once } = {}) => { const p = await fenceFor(once); if (!p) throw new Error('fence_uninitialised'); return switchDeskRows(p); } });
238
+ api.contribute(POINT, { desk: 'automation.goals', gate, read: async (_ctx, { once } = {}) => { const p = await fenceFor(once); if (!p) throw new Error('fence_uninitialised'); return goalDeskRows(p); } });
239
+ api.contribute(POINT, { desk: 'automation.runners', gate, read: async (_ctx, { now, once } = {}) => runnerDeskRows(await runnersFor(once), now || Date.now(), runnerHealth.describeRunner) });
240
+ }
241
+
179
242
  function contributeAutonomyToDocket() {
180
243
  contributeAutonomyFloors();
244
+ contributeAutonomyDesks();
181
245
  // GET /autonomy/fence is any builder's read; the read itself fails on a missing
182
246
  // fence row rather than fabricating "off" (that route's rule).
183
247
  api.contribute(POINT, {
@@ -206,6 +270,9 @@ function contributeAutonomyToDocket() {
206
270
  module.exports = {
207
271
  contributeAutonomyToDocket,
208
272
  fenceDocketMatters,
273
+ switchDeskRows,
274
+ goalDeskRows,
275
+ runnerDeskRows,
209
276
  moneyReading,
210
277
  allowedGoalsFloor,
211
278
  runningFloor,
@@ -0,0 +1,41 @@
1
+ 'use strict';
2
+
3
+ // modules/autonomy/matter-acts.js — what a PASSED Automation matter carries out
4
+ // (task 1004561, goal 1000125). When a project's decision table puts Automation
5
+ // to a sitting, the sitting's passed matter acts here, after the close commits:
6
+ // the switch on or off, or one goal allowed. Each act goes through the SAME db
7
+ // function its direct route calls (POST /autonomy/fence, POST
8
+ // /autonomy/fence/goals), so a matter can do nothing the route could not.
9
+ // The government module only checks the act's shape and calls in through the
10
+ // 'governor.matterAct' contribution point (a module never requires a sibling).
11
+
12
+ const api = require('../../src/module-api');
13
+ const db = require('./db');
14
+
15
+ const log = api.logger('autonomy');
16
+ const POINT = 'governor.matterAct';
17
+
18
+ const why = (ctx) => `a passed Automation matter (matter ${ctx && ctx.matterId})`;
19
+
20
+ async function switchFence(enabled, ctx) {
21
+ const row = await db.setFenceEnabled({ enabled, reason: why(ctx), builderId: ctx.decidedBy });
22
+ if (!row) return { done: false, reason: 'fence_uninitialised' };
23
+ log.info(`[gds] autonomy fence ${enabled ? 'ENABLED' : 'DISABLED'} by ${why(ctx)}, proposed by builder ${ctx.decidedBy}`);
24
+ return { done: true };
25
+ }
26
+
27
+ const ACTS = Object.freeze({
28
+ switch_on: (_action, ctx) => switchFence(true, ctx),
29
+ switch_off: (_action, ctx) => switchFence(false, ctx),
30
+ allow_goal: async (action, ctx) => {
31
+ await db.allowGoal({ goalId: action.goal_id, note: why(ctx), builderId: ctx.decidedBy });
32
+ log.info(`[gds] autonomy fence: goal ${action.goal_id} ALLOWED by ${why(ctx)}`);
33
+ return { done: true };
34
+ },
35
+ });
36
+
37
+ function contributeAutomationMatterActs() {
38
+ for (const [type, act] of Object.entries(ACTS)) api.contribute(POINT, { area: 'automation', type, act });
39
+ }
40
+
41
+ module.exports = { ACTS, contributeAutomationMatterActs };
@@ -108,6 +108,8 @@ const governorDocket = require('../governor-docket');
108
108
 
109
109
  module.exports = function buildAutonomyRouter() {
110
110
  governorDocket.contributeAutonomyToDocket();
111
+ // what a passed Automation matter carries out (task 1004561)
112
+ require('../matter-acts').contributeAutomationMatterActs();
111
113
  const router = express.Router();
112
114
 
113
115
  // The one Discord line for a runner idle on a setting (task 1004539). The port
@@ -1370,6 +1370,9 @@ async function boardPassForIdea(ideaId) {
1370
1370
  });
1371
1371
  }
1372
1372
 
1373
+ // boardPassForIdea for a list, in one read (task 1004560).
1374
+ const boardPassedIdeaIds = async (ids) => new Set(await db.listPassedSubjectIds('full_idea', ids));
1375
+
1373
1376
  // ── the summons: what is waiting on THIS member's vote (task 1003271) ───────
1374
1377
  //
1375
1378
  // THE PROBLEM THIS EXISTS TO FIX. Nine items sat open for two days with zero
@@ -1485,6 +1488,7 @@ module.exports = {
1485
1488
  maybeCloseAfterVote,
1486
1489
  latestRejectionForIdea,
1487
1490
  boardPassForIdea,
1491
+ boardPassedIdeaIds,
1488
1492
  validateProposedConstitution,
1489
1493
  proposeAmendment,
1490
1494
  boardRoomView,
@@ -712,6 +712,19 @@ async function getLatestPassedItem(subjectType, subjectId) {
712
712
  return rows[0] || null;
713
713
  }
714
714
 
715
+ // Which of these subjects the board passed, in ONE read (task 1004560: the
716
+ // Ideas-to-ratify desk asks for every open Full Idea at once).
717
+ async function listPassedSubjectIds(subjectType, subjectIds) {
718
+ const ids = (subjectIds || []).map(Number).filter((n) => Number.isInteger(n) && n > 0);
719
+ if (!ids.length) return [];
720
+ const { rows } = await pool.query(
721
+ `SELECT DISTINCT subject_id FROM government_board_items
722
+ WHERE subject_type = $1 AND subject_id = ANY($2::bigint[]) AND outcome = 'passed'`,
723
+ [subjectType, ids],
724
+ );
725
+ return rows.map((r) => String(r.subject_id));
726
+ }
727
+
715
728
  // The expiry sweep's read (R11): every OPEN item whose deadline has passed, on
716
729
  // the database clock, oldest deadline first. Served by the partial index
717
730
  // government_board_items_open_by_deadline (government_003 built it for exactly
@@ -872,6 +885,7 @@ module.exports = {
872
885
  getExpiredOpenBoardItems,
873
886
  getLatestReturnedItemWithObjection,
874
887
  getLatestPassedItem,
888
+ listPassedSubjectIds,
875
889
  createAmendment,
876
890
  getAmendment,
877
891
  listDecisionRules,
@@ -50,7 +50,9 @@ async function applyPassedDecisionRules(client, closedRow, amendment) {
50
50
  }
51
51
 
52
52
  // A passed matter: act where the building can act by itself, and build the
53
- // announcement. Never throws — the decision already stands.
53
+ // announcement. Never throws — the decision already stands. Runs after the
54
+ // close commits (People since task 1004529; Automation, Release, Review and
55
+ // Treasury since task 1004561, through their owning modules' acts).
54
56
  async function actOnPassedMatter(closed) {
55
57
  const area = decisionRules.areaForSubjectType(closed.subject_type);
56
58
  let matter = null;
@@ -71,11 +73,30 @@ async function actOnPassedMatter(closed) {
71
73
  await db.unassignRank({ builderId: action.builder_id, rankKey: action.rank_key });
72
74
  result = { ...result, action: action.type, done: true, builder_id: String(action.builder_id), rank_key: action.rank_key };
73
75
  }
76
+ } else if (area !== 'people' && action && MATTER_ACTIONS[area]) {
77
+ // The owning module carries it out, through its direct route's own
78
+ // function (task 1004561). Re-validated first: the stored row was checked
79
+ // when opened and is immutable since, so a refusal means it was altered.
80
+ const v = validateMatterAction(area, action);
81
+ const doer = v.ok ? matterActFor(area, v.action.type) : null;
82
+ if (!v.ok) {
83
+ result = { ...result, action: action.type || null, done: false, reason: 'bad_action' };
84
+ } else if (!doer) {
85
+ result = { ...result, action: v.action.type, done: false, reason: 'not_wired' };
86
+ } else {
87
+ const out = (await doer.act(v.action, { decidedBy: matter.proposed_by, matterId: String(matter.id) })) || {};
88
+ result = { ...result, action: v.action.type, done: out.done === true, ...(out.reason ? { reason: String(out.reason) } : {}) };
89
+ }
74
90
  }
75
91
  } catch (err) {
76
92
  log.error(`[government] matter ${closed.subject_id} passed at ${area} but its act did NOT run (re-drivable from the passed item): ${err && err.message}`);
77
93
  result = { ...result, done: false, reason: 'act_failed' };
78
94
  }
95
+ // A refusal (already decided elsewhere, the module off) leaves the decision
96
+ // standing; it is said loudly here and carried as `acted: false`.
97
+ if (result.action && !result.done && result.reason !== 'act_failed') {
98
+ log.warn(`[government] matter ${closed.subject_id} passed at ${area} but its act was refused: ${result.reason || 'not done'}`);
99
+ }
79
100
  return {
80
101
  result,
81
102
  payload: {
@@ -153,22 +174,74 @@ async function decisionRulesView() {
153
174
  // the whole point — People decided by a council's consent sits as exactly that.
154
175
  const MATTER_TITLE_MAX = 200;
155
176
  const MATTER_DETAIL_MAX = 5000;
156
- const MATTER_ACTION_TYPES = Object.freeze(['assign_rank', 'unassign_rank']);
177
+ // What a passed matter may carry out, per building (task 1004561). People acts
178
+ // here, in this module; every other act is carried out by the module that owns
179
+ // the building's direct route, through the same function that route calls,
180
+ // contributed to 'governor.matterAct' as { area, type, act }. This table is only
181
+ // the SHAPE of each act (field → kind), checked when the matter is opened and
182
+ // again before it acts. 'id' is a positive integer, 'rank' a custom rank key,
183
+ // 'usd' a non-negative amount or null (no limit).
184
+ const MATTER_ACTIONS = Object.freeze({
185
+ people: Object.freeze({
186
+ assign_rank: Object.freeze({ builder_id: 'id', rank_key: 'rank' }),
187
+ unassign_rank: Object.freeze({ builder_id: 'id', rank_key: 'rank' }),
188
+ }),
189
+ automation: Object.freeze({
190
+ switch_on: Object.freeze({}),
191
+ switch_off: Object.freeze({}),
192
+ allow_goal: Object.freeze({ goal_id: 'id' }),
193
+ }),
194
+ release: Object.freeze({
195
+ approve_override: Object.freeze({ request_id: 'id' }),
196
+ approve_gate: Object.freeze({ pr: 'id' }),
197
+ }),
198
+ review: Object.freeze({
199
+ satisfy_criterion: Object.freeze({ criterion_id: 'id' }),
200
+ }),
201
+ treasury: Object.freeze({
202
+ set_budget: Object.freeze({ builder_id: 'id', monthly_usd: 'usd' }),
203
+ }),
204
+ });
205
+ const MATTER_ACT_POINT = 'governor.matterAct';
206
+ const MAX_BUDGET_USD = 100000;
207
+
208
+ function fieldValue(kind, raw) {
209
+ if (kind === 'id') {
210
+ const n = Number(raw);
211
+ return Number.isSafeInteger(n) && n > 0 && raw !== null && raw !== '' && typeof raw !== 'boolean' ? { ok: true, v: n } : { ok: false };
212
+ }
213
+ if (kind === 'rank') return typeof raw === 'string' && /^[a-z][a-z0-9-]*$/.test(raw) && raw.length <= 64 ? { ok: true, v: raw } : { ok: false };
214
+ if (kind === 'usd') {
215
+ if (raw === null) return { ok: true, v: null };
216
+ return typeof raw === 'number' && Number.isFinite(raw) && raw >= 0 && raw <= MAX_BUDGET_USD ? { ok: true, v: raw } : { ok: false };
217
+ }
218
+ return { ok: false };
219
+ }
157
220
 
158
221
  function validateMatterAction(area, action) {
159
222
  if (action == null) return { ok: true, action: null };
160
- if (area !== 'people') return { ok: false, reason: 'action_not_supported_here', expected: 'only People can act by itself today (assign_rank / unassign_rank)' };
223
+ const acts = MATTER_ACTIONS[area];
224
+ if (!acts) return { ok: false, reason: 'action_not_supported_here', expected: `a matter acts by itself only at ${Object.keys(MATTER_ACTIONS).join(', ')}` };
161
225
  if (typeof action !== 'object' || Array.isArray(action)) return { ok: false, reason: 'bad_action' };
226
+ const shape = acts[action.type];
227
+ if (!shape) return { ok: false, reason: 'bad_action', expected: Object.keys(acts) };
162
228
  for (const k of Object.keys(action)) {
163
- if (!['type', 'builder_id', 'rank_key'].includes(k)) return { ok: false, reason: 'unknown_field', field: `action.${k}` };
229
+ if (k !== 'type' && !Object.prototype.hasOwnProperty.call(shape, k)) return { ok: false, reason: 'unknown_field', field: `action.${k}` };
164
230
  }
165
- if (!MATTER_ACTION_TYPES.includes(action.type)) return { ok: false, reason: 'bad_action', expected: MATTER_ACTION_TYPES };
166
- const builderId = Number(action.builder_id);
167
- if (!Number.isInteger(builderId) || builderId <= 0) return { ok: false, reason: 'bad_action', field: 'action.builder_id' };
168
- if (typeof action.rank_key !== 'string' || !/^[a-z][a-z0-9-]*$/.test(action.rank_key) || action.rank_key.length > 64) {
169
- return { ok: false, reason: 'bad_action', field: 'action.rank_key' };
231
+ const out = { type: action.type };
232
+ for (const [k, kind] of Object.entries(shape)) {
233
+ if (!Object.prototype.hasOwnProperty.call(action, k)) return { ok: false, reason: 'bad_action', field: `action.${k}` };
234
+ const f = fieldValue(kind, action[k]);
235
+ if (!f.ok) return { ok: false, reason: 'bad_action', field: `action.${k}` };
236
+ out[k] = f.v;
170
237
  }
171
- return { ok: true, action: { type: action.type, builder_id: builderId, rank_key: action.rank_key } };
238
+ return { ok: true, action: out };
239
+ }
240
+
241
+ // The owning module's act for (area, type), or null when that module is off.
242
+ function matterActFor(area, type) {
243
+ return api.contributions(MATTER_ACT_POINT)
244
+ .find((c) => c && c.area === area && c.type === type && typeof c.act === 'function') || null;
172
245
  }
173
246
 
174
247
  async function openMatter({ area, title, detailMd = null, action = null, proposedBy } = {}) {
@@ -190,7 +263,10 @@ async function openMatter({ area, title, detailMd = null, action = null, propose
190
263
  };
191
264
  }
192
265
  if (!resolved.sitting) return { ok: false, reason: 'no_one_holds_this', area, what_this_means: `No one holds ${resolved.name} yet, so nothing there can be decided.` };
193
- if (a.action) {
266
+ if (a.action && area !== 'people' && !matterActFor(area, a.action.type)) {
267
+ return { ok: false, reason: 'action_not_wired', area, what_this_means: `Nothing at ${resolved.name} can carry that out right now, so it would pass and do nothing. Turn on the module that owns ${resolved.name}, or file the matter without an act.` };
268
+ }
269
+ if (a.action && area === 'people') {
194
270
  const rank = await db.getRankByKey(a.action.rank_key);
195
271
  if (!rank) return { ok: false, reason: 'unknown_rank', rank_key: a.action.rank_key };
196
272
  if (rank.is_system) return { ok: false, reason: 'seeded_rank', rank_key: a.action.rank_key, expected: 'a custom rank — a seeded rank follows the builder\'s rank' };
@@ -295,6 +371,9 @@ module.exports = {
295
371
  proposeDecisionRules,
296
372
  decisionRulesView,
297
373
  validateMatterAction,
374
+ matterActFor,
375
+ MATTER_ACTIONS,
376
+ MATTER_ACT_POINT,
298
377
  openMatter,
299
378
  checkActAlone,
300
379
  };
@@ -215,7 +215,27 @@ function governmentHistory(gov) {
215
215
  return out;
216
216
  }
217
217
 
218
+ // The People desk's rank rows (task 1004559): one row per custom rank, to give it
219
+ // (POST /government/assignments) or take it back (DELETE
220
+ // /government/assignments/:builderId/:rankKey), each a chip per person (at most
221
+ // five). Seeded ranks are tiers, set on the builder's own row. Pure.
222
+ function rankDeskRows(ranks, assignments) {
223
+ const people = new Map();
224
+ for (const a of assignments || []) if (!people.has(String(a.builder_id))) people.set(String(a.builder_id), a.display_name || a.github_login || `builder ${a.builder_id}`);
225
+ return (ranks || []).filter((r) => r && !r.is_system).map((r) => {
226
+ const holders = (assignments || []).filter((a) => a.rank_key === r.rank_key);
227
+ const held = new Set(holders.map((a) => String(a.builder_id)));
228
+ const give = [...people].filter(([id]) => !held.has(id)).slice(0, 5).map(([id, name]) => ({ key: id, label: name }));
229
+ const take = holders.slice(0, 5).map((a) => ({ key: String(a.builder_id), label: people.get(String(a.builder_id)) }));
230
+ const acts = [];
231
+ if (give.length) acts.push({ key: 'give', options: give });
232
+ if (take.length) acts.push({ key: 'take', options: take });
233
+ return { id: r.rank_key, name: r.label || 'unnamed rank', fact: `${holders.length} ${holders.length === 1 ? 'holds it' : 'hold it'}`, facts: r.description ? [r.description] : [], ref: { type: 'custom_rank', id: r.rank_key }, acts };
234
+ });
235
+ }
236
+
218
237
  module.exports = {
238
+ rankDeskRows,
219
239
  BUILDERS,
220
240
  ideasFloor,
221
241
  governmentHistory,
@@ -90,6 +90,24 @@ const FLOORS = Object.freeze([
90
90
  ['treasury.rewards', 'treasury'], ['treasury.grading', 'treasury'], ['treasury.hosting', 'treasury'],
91
91
  ].map(([key, building]) => Object.freeze({ key, building })));
92
92
 
93
+ // The floor desks' own rows (task 1004559): things on a floor a viewer may act on
94
+ // in place that are not matters waiting on anyone: a builder's tier, an allowed
95
+ // goal, a runner, a confirmed report waiting for its fix. Each is read by the
96
+ // module that owns its write route, under that route's atom; a desk whose module
97
+ // is off reports `off` and the floor shows its quiet line.
98
+ const DESKS = Object.freeze([
99
+ ['people.builders', 'people'], ['people.ranks', 'people'],
100
+ ['disputes.confirmed', 'disputes'],
101
+ ['automation.switch', 'automation'], ['automation.goals', 'automation'], ['automation.runners', 'automation'],
102
+ ['people.invite', 'people'], ['release.core', 'release'],
103
+ ['rules.settings', 'rules'], ['rules.modules', 'rules'], ['treasury.backup', 'treasury'],
104
+ // the Planning desks (task 1004560): the goals in their worked order, and the
105
+ // ideas a ratifier may take from here
106
+ ['planning.order', 'planning'], ['planning.ratify', 'planning'],
107
+ // task 1004561: a builder's monthly spending limit, and a sign-off undone
108
+ ['treasury.budgets', 'treasury'], ['review.signed', 'review'],
109
+ ].map(([key, building]) => Object.freeze({ key, building })));
110
+
93
111
  module.exports = {
94
112
  BUILDINGS,
95
113
  BUILDING_KEYS,
@@ -99,4 +117,5 @@ module.exports = {
99
117
  STATES,
100
118
  READINGS,
101
119
  FLOORS,
120
+ DESKS,
102
121
  };
@@ -35,6 +35,15 @@
35
35
  // { history: 'releases', building: 'release', gate: …, // the record drawers
36
36
  // read: async (ctx) => [{ id, title, at, outcome }, …] }
37
37
  //
38
+ // { desk: 'people.builders', gate: …, // one of docket-vocab DESKS
39
+ // read: async (ctx) => [{ id, name, fact, facts, acts, ref, tool? }, …] }
40
+ //
41
+ // A DESK (task 1004559) is the rows a floor desk acts on in place that are not
42
+ // matters: a builder's tier, an allowed goal, a runner. A matter or a desk row
43
+ // may carry `acts`, the acts THIS viewer may take, decided by the owning module
44
+ // from ctx.has() with its write route's own atom (normalizeActs below). Desk
45
+ // rows never reach the street's pins or counts.
46
+ //
38
47
  // A FLOOR (task 1004527) is a state rather than a queue — who sits, what the
39
48
  // month cost, what went live — read by the module that owns it, under the same
40
49
  // atom as its own route. HISTORY is what closed: rulings, amendments, releases,
@@ -117,6 +126,7 @@ const COMING_FLOORS = Object.freeze([
117
126
  ]);
118
127
  const MAX_FLOOR_LINES = 6;
119
128
  const MAX_HISTORY_PER_SOURCE = 60;
129
+ const MAX_DESK_ROWS = 40;
120
130
 
121
131
 
122
132
  // A source read that has not answered by now is reported unavailable rather than
@@ -172,9 +182,88 @@ function normalizeMatter(raw, { source, building }) {
172
182
  tally: tally(raw.tally),
173
183
  href: raw.href ? String(raw.href) : null,
174
184
  ref: raw.ref && typeof raw.ref === 'object' ? { type: String(raw.ref.type), id: String(raw.ref.id) } : null,
185
+ ...actsField(raw.acts),
175
186
  };
176
187
  }
177
188
 
189
+ // WHAT A VIEWER MAY DO, FROM THE SERVER (task 1004559). A matter or a desk row may
190
+ // carry `acts`: the acts THIS viewer may take on it, decided by the owning module
191
+ // from ctx.has() with the same atom its own write route checks. The floor desk
192
+ // draws one button per act (at most two) and one-tap chips for an act's options;
193
+ // the route still decides (the docket only hides what would be refused). An act
194
+ // is { key, options?: [{ key, label }], pick?: 'open_goals', except?: [id] }:
195
+ // `pick` asks the screen to offer the open goals it already reads, minus
196
+ // `except`. Pure. Anything malformed is dropped, never guessed.
197
+ const MAX_ACTS = 2;
198
+ const MAX_OPTIONS = 5;
199
+ const ACT_KEY_RE = /^[a-z][a-z_]{1,23}$/;
200
+ const PICKS = Object.freeze(['open_goals']);
201
+ // The one thing a desk may type: a GitHub name to invite (it is a name, not a
202
+ // reason; reasons stay chips).
203
+ const FIELDS = Object.freeze(['github_login']);
204
+ function normalizeActs(raw) {
205
+ if (!Array.isArray(raw)) return [];
206
+ return raw.filter((a) => a && typeof a === 'object' && ACT_KEY_RE.test(String(a.key))).slice(0, MAX_ACTS).map((a) => {
207
+ const out = { key: String(a.key) };
208
+ const options = (Array.isArray(a.options) ? a.options : [])
209
+ .filter((o) => o && o.key != null && o.label != null && String(o.label).trim())
210
+ .slice(0, MAX_OPTIONS)
211
+ .map((o) => ({ key: String(o.key).slice(0, 64), label: String(o.label).slice(0, 16) }));
212
+ if (options.length) out.options = options;
213
+ if (PICKS.includes(a.pick)) {
214
+ out.pick = a.pick;
215
+ out.except = (Array.isArray(a.except) ? a.except : []).map(String).slice(0, 200);
216
+ }
217
+ if (FIELDS.includes(a.field)) out.field = a.field;
218
+ return out;
219
+ });
220
+ }
221
+ function actsField(raw) {
222
+ const acts = normalizeActs(raw);
223
+ return acts.length ? { acts } : {};
224
+ }
225
+
226
+ // One desk row as a contributor handed it (task 1004559). A desk row is a thing on
227
+ // a floor a viewer may act on that is not a matter waiting on anyone: a builder
228
+ // whose tier can be set, an allowed goal, a runner, a confirmed report waiting
229
+ // for its fix. It never reaches the street's pins or counts. Pure; a row with no
230
+ // name or no act is dropped (a desk row exists to be acted on).
231
+ function normalizeDeskRow(raw, { desk, building }) {
232
+ if (!raw || typeof raw !== 'object' || raw.id == null || !raw.name) return null;
233
+ const acts = normalizeActs(raw.acts);
234
+ if (!acts.length) return null;
235
+ return {
236
+ id: `${desk}:${raw.id}`,
237
+ desk,
238
+ building,
239
+ name: String(raw.name).slice(0, 80),
240
+ fact: raw.fact == null ? '' : String(raw.fact).slice(0, 80),
241
+ facts: (Array.isArray(raw.facts) ? raw.facts : []).filter((x) => x != null && String(x).trim()).slice(0, 3).map((x) => String(x).slice(0, 80)),
242
+ needs: raw.needs === true,
243
+ // a row that is only a way to add something (allow another goal) is not a
244
+ // thing on the floor, so the floor does not count it
245
+ tool: raw.tool === true,
246
+ // a same-site page an 'open' act goes to; and the matter source this row
247
+ // stands in for on its floor (the core update's two-step replaces its link)
248
+ href: typeof raw.href === 'string' && raw.href.charAt(0) === '/' && raw.href.charAt(1) !== '/' ? raw.href : null,
249
+ over: typeof raw.over === 'string' && /^[a-z_]+$/.test(raw.over) ? raw.over : null,
250
+ opened_at: iso(raw.opened_at),
251
+ ref: raw.ref && typeof raw.ref === 'object' ? { type: String(raw.ref.type), id: String(raw.ref.id) } : null,
252
+ acts,
253
+ ...deskExtras(raw),
254
+ };
255
+ }
256
+ // Two optional shapes a desk row may carry (task 1004560): `pips`, promises kept
257
+ // of total drawn as squares (at most 12), and `set`, the ordered set the row sits
258
+ // in (a version id) so the desk can reorder its rows and save the order whole.
259
+ function deskExtras(raw) {
260
+ const out = {};
261
+ const p = raw.pips;
262
+ if (p && Number.isInteger(p.of) && p.of > 0 && p.of <= 12 && Number.isInteger(p.on) && p.on >= 0 && p.on <= p.of) out.pips = { on: p.on, of: p.of };
263
+ if (typeof raw.set === 'string' && /^[A-Za-z0-9._-]{1,64}$/.test(raw.set)) out.set = raw.set;
264
+ return out;
265
+ }
266
+
178
267
  // One floor as a contributor handed it → { count, line, lines, hot }. Pure.
179
268
  // Anything that is not a plain object is not a floor (→ null, read as
180
269
  // unavailable); a count is kept only as the short figure it was given.
@@ -397,14 +486,16 @@ function registeredContributions() {
397
486
  const readings = new Map();
398
487
  const floors = new Map();
399
488
  const histories = new Map();
489
+ const desks = new Map();
400
490
  for (const c of api.contributions(CONTRIBUTION_POINT)) {
401
491
  if (!c || typeof c !== 'object' || typeof c.read !== 'function' || !hasValidGate(c.gate)) continue;
402
492
  if (typeof c.source === 'string' && c.source) sources.set(c.source, c);
403
493
  else if (typeof c.reading === 'string' && c.reading) readings.set(c.reading, c);
404
494
  else if (typeof c.floor === 'string' && c.floor) floors.set(c.floor, c);
405
495
  else if (typeof c.history === 'string' && c.history && vocab.BUILDING_KEYS.includes(c.building)) histories.set(c.history, c);
496
+ else if (typeof c.desk === 'string' && vocab.DESKS.some((d) => d.key === c.desk)) desks.set(c.desk, c);
406
497
  }
407
- return { sources, readings, floors, histories };
498
+ return { sources, readings, floors, histories, desks };
408
499
  }
409
500
 
410
501
  // ── the collector ────────────────────────────────────────────────────────────
@@ -554,7 +645,25 @@ async function buildDocket({ viewer, permissions, contributions = registeredCont
554
645
  historySources.push({ source: key, building: c.building, status: 'unavailable' });
555
646
  }
556
647
  }));
557
- const [floors] = await Promise.all([floorsP, historyP]);
648
+ // The desks (task 1004559): rows a viewer may act on in place, each read by its
649
+ // owning module under the atom its own write route checks. A gated-out desk is
650
+ // not_yours with no rows, so the floor shows its quiet line.
651
+ const deskImpls = new Map(contributions.desks || []);
652
+ const desksP = Promise.all(vocab.DESKS.map(async (decl) => {
653
+ const base = { key: decl.key, building: decl.building, status: null, rows: [] };
654
+ const impl = deskImpls.get(decl.key);
655
+ if (!impl) return { ...base, status: 'off' };
656
+ if (!gateAllows(impl.gate, ctx)) return { ...base, status: 'not_yours' };
657
+ try {
658
+ const raw = await withTimeout(Promise.resolve(impl.read(ctx, { now, once })), timeoutMs);
659
+ const rows = (Array.isArray(raw) ? raw : []).slice(0, MAX_DESK_ROWS).map((r) => normalizeDeskRow(r, { desk: decl.key, building: decl.building })).filter(Boolean);
660
+ return { ...base, status: 'live', rows };
661
+ } catch (err) {
662
+ log.warn({ desk: decl.key, err: err && err.message }, 'governor docket desk failed');
663
+ return { ...base, status: 'unavailable' };
664
+ }
665
+ }));
666
+ const [floors, , desks] = await Promise.all([floorsP, historyP, desksP]);
558
667
  history.sort((a, b) => (a.at < b.at ? 1 : a.at > b.at ? -1 : a.id < b.id ? -1 : 1));
559
668
  historySources.sort((a, b) => (a.source < b.source ? -1 : 1));
560
669
 
@@ -590,6 +699,7 @@ async function buildDocket({ viewer, permissions, contributions = registeredCont
590
699
  floors,
591
700
  history,
592
701
  history_sources: historySources,
702
+ desks,
593
703
  vocabulary: {
594
704
  buildings: vocab.BUILDINGS, rules: vocab.RULES, states: vocab.STATES,
595
705
  // The table's rule set — the matter glyphs plus vacant (no one holds it).
@@ -665,6 +775,8 @@ module.exports = {
665
775
  SOURCE_TIMEOUT_MS,
666
776
  normalizeFloor,
667
777
  normalizeHistory,
778
+ normalizeActs,
779
+ normalizeDeskRow,
668
780
  loadGovernment,
669
781
  SETTLED_WINDOW_MS,
670
782
  normalizeMatter,
@@ -110,6 +110,7 @@ const governmentPort = {
110
110
  // is no counterpart that records an outcome, and there must never be one: an
111
111
  // outcome is written by a close, never by the module reading it.
112
112
  boardPassForIdea: board.boardPassForIdea,
113
+ boardPassedIdeaIds: board.boardPassedIdeaIds,
113
114
  // task 1004429 (BV2.PS04): a finished genesis stage is put to the board — the
114
115
  // genesis home (task 1004423) calls this when a stage's tasks are done. The
115
116
  // finished-tasks gate lives inside board.js; the item is an ordinary one under
@@ -182,7 +183,20 @@ const { validateOrRespond, LIMITS, parseId } = api;
182
183
  const RANK_KEY_RE = /^[a-z][a-z0-9-]*$/;
183
184
  const RANK_KEY_MAX = 64;
184
185
 
186
+ // The People desk's rank rows, under the assignments routes' own atom (task 1004559).
187
+ function contributeRankDesk() {
188
+ api.contribute('governor.docket', {
189
+ desk: 'people.ranks',
190
+ gate: { permission: 'government.manage' },
191
+ read: async () => {
192
+ const [ranks, assignments] = await Promise.all([db.listRanksWithGrants(), db.listAssignments()]);
193
+ return require('../docket-floors').rankDeskRows(ranks, assignments);
194
+ },
195
+ });
196
+ }
197
+
185
198
  module.exports = function buildGovernmentRouter() {
199
+ contributeRankDesk();
186
200
  registerSeams();
187
201
  const router = express.Router();
188
202
 
@@ -776,7 +790,7 @@ module.exports = function buildGovernmentRouter() {
776
790
  proposedBy: req.builder.id,
777
791
  });
778
792
  if (!out.ok) {
779
- if (out.reason === 'decided_by_gate' || out.reason === 'no_one_holds_this') {
793
+ if (out.reason === 'decided_by_gate' || out.reason === 'no_one_holds_this' || out.reason === 'action_not_wired') {
780
794
  return res.fail(out.reason, 409, { area: out.area, ...(out.gate ? { gate: out.gate } : {}), what_this_means: out.what_this_means });
781
795
  }
782
796
  return res.fail('invalid_matter', 400, {