@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
@@ -25,12 +25,45 @@
25
25
  const express = require('express');
26
26
  const api = require('../../../src/module-api');
27
27
  const provisioning = require('../provisioning');
28
- const { makeCoreUpgradeHandler, makeCoreUpgradePreviewHandler, makeControlPlaneHandler, makeUpdateChannelHandler } = require('../core-upgrade');
28
+ const { makeCoreUpgradeHandler, makeCoreUpgradePreviewHandler, makeControlPlaneHandler, makeUpdateChannelHandler, loadPreview } = require('../core-upgrade');
29
29
  const { requireOwnProjectOrPin } = require('../core-upgrade-authz');
30
30
 
31
31
  const log = api.logger('provisioning');
32
32
 
33
+ // The Release desk's core row (task 1004559): the platform's own move, in two
34
+ // steps on one row. Until a dry run has PASSED for the very version on offer the
35
+ // row offers only 'check' (POST …/core-upgrade with dry_run); 'take' (the real
36
+ // move) is handed only after that pass, so no desk can move the pin without the
37
+ // check. The chip carries the version, which the route requires as its own
38
+ // confirmation. A run in progress offers nothing (the row then steps aside for
39
+ // the plain core-update matter). Pure; exported for tests.
40
+ function coreDeskRows(preview) {
41
+ const p = preview || {};
42
+ const to = p.recommended_version;
43
+ if (!p.behind || !to || p.instance_id == null) return [];
44
+ if (p.open_intent && (p.open_intent.state === 'pending' || p.open_intent.state === 'running')) return [];
45
+ const pf = p.preflight && p.preflight.target === to ? p.preflight : null;
46
+ const version = [{ key: String(to), label: String(to) }];
47
+ const base = { id: p.instance_id, name: `Core ${to}`, over: 'core_update', opened_at: p.observed_at || null, ref: { type: 'core_pin', id: p.instance_id } };
48
+ if (pf && pf.ok) return [{ ...base, fact: 'checked · nothing blocks', facts: [`running ${p.served_version || 'unknown'}`], acts: [{ key: 'take', options: version }, { key: 'check', options: version }] }];
49
+ if (pf) return [{ ...base, fact: 'the check refused it', facts: [String(pf.error || 'no reason given'), `running ${p.served_version || 'unknown'}`], acts: [{ key: 'check', options: version }] }];
50
+ return [{ ...base, fact: 'not checked yet', facts: [`running ${p.served_version || 'unknown'}`], acts: [{ key: 'check', options: version }] }];
51
+ }
52
+ function contributeCoreDesk() {
53
+ api.contribute('governor.docket', {
54
+ desk: 'release.core',
55
+ // the control-plane read's own atom (GET /provisioning/control-plane)
56
+ gate: { permission: 'core.pin.move' },
57
+ read: async () => {
58
+ const { rows } = await api.pool.query("SELECT * FROM provisioning_instances WHERE hosting_shape = 'control-plane' LIMIT 1");
59
+ if (!rows.length) return [];
60
+ return coreDeskRows(await loadPreview(api.pool, provisioning, rows[0]));
61
+ },
62
+ });
63
+ }
64
+
33
65
  module.exports = function buildCoreUpgradeRouter() {
66
+ contributeCoreDesk();
34
67
  const router = express.Router();
35
68
 
36
69
  // POST /provisioning/instances/:id/core-upgrade — move the core pin.
@@ -119,3 +152,5 @@ module.exports = function buildCoreUpgradeRouter() {
119
152
 
120
153
  return router;
121
154
  };
155
+
156
+ module.exports.coreDeskRows = coreDeskRows;
@@ -56,6 +56,23 @@ function redteamDocketMatter(r) {
56
56
  tally: null,
57
57
  href: '/watch',
58
58
  ref: { type: 'vulnerability_report', id: r.id },
59
+ // The floor desk's acts (task 1004559): the docket only lists this for a
60
+ // holder of the adjudicate atom, which both routes check.
61
+ acts: [{ key: 'confirm' }, { key: 'dispute' }],
62
+ };
63
+ }
64
+
65
+ // A confirmed report still waiting for its fix, as a Disputes desk row (task
66
+ // 1004559): one act, POST /security/reports/:id/fix. Pure; exported for tests.
67
+ function confirmedDeskRow(r) {
68
+ return {
69
+ id: r.id,
70
+ name: r.title,
71
+ fact: r.severity ? `${r.severity} · confirmed` : 'confirmed',
72
+ facts: ['confirmed, not fixed yet'],
73
+ opened_at: r.confirmed_at || r.reported_at || null,
74
+ ref: { type: 'vulnerability_report', id: r.id },
75
+ acts: [{ key: 'fix' }],
59
76
  };
60
77
  }
61
78
 
@@ -69,6 +86,11 @@ function contributeReportsToDocket() {
69
86
  return lists.flat().map(redteamDocketMatter);
70
87
  },
71
88
  });
89
+ api.contribute('governor.docket', {
90
+ desk: 'disputes.confirmed',
91
+ gate: { permission: 'security.report.adjudicate' },
92
+ read: async () => (await security.listVulnerabilityReports({ scope: 'all', status: 'confirmed', limit: 40 })).map(confirmedDeskRow),
93
+ });
72
94
  // The Disputes record (task 1004527): every report that was ruled on, dated by
73
95
  // its ruling (confirmed or fixed). Same atom as the list route it reads.
74
96
  api.contribute('governor.docket', {
@@ -318,3 +340,4 @@ module.exports = function buildSecurityReportsRouter() {
318
340
 
319
341
  module.exports.redteamDocketMatter = redteamDocketMatter;
320
342
  module.exports.rulingHistory = rulingHistory;
343
+ module.exports.confirmedDeskRow = confirmedDeskRow;
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.21.34",
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.34",
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.34",
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",
@@ -8964,5 +8964,21 @@
8964
8964
  "id": "1004558",
8965
8965
  "text": "Inside every building in the governor city, the floor you pick is now a desk you can act from, built from the new floor desks design. It shows the floor's name and how many things wait on you, up to three short numbers, and on"
8966
8966
  }
8967
+ ],
8968
+ "1.21.35": [
8969
+ {
8970
+ "id": "1004559",
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
+ }
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
+ }
8967
8983
  ]
8968
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',
@@ -24,6 +24,7 @@ const auth = require('../auth');
24
24
  const { branding } = require('../../branding');
25
25
  const { resolveEnv } = require('../../instance-config');
26
26
  const { asyncHandler } = require('./_helpers');
27
+ const seams = require('../../module-seams');
27
28
  // task 1003208: structured logging — was console.*. A core file logs through
28
29
  // the kernel logger directly; only MODULES go via api.logger (the doorway).
29
30
  const log = require('../logger').logger.child({ src: 'backup' });
@@ -76,6 +77,20 @@ function isStaleDump(stamp, nowMs = Date.now(), staleAfterHours = STALE_AFTER_HO
76
77
  return age > staleAfterHours;
77
78
  }
78
79
 
80
+ // The newest dump's age for the Treasury desk (task 1004559), read WITHOUT
81
+ // blocking: the docket is read on every street visit, so this is one async
82
+ // directory listing and the age from the filename (no stat), never latestDump()'s
83
+ // synchronous reads. Same filename rule and staleness rule as latestDump().
84
+ async function newestDumpAge(nowMs = Date.now()) {
85
+ let files;
86
+ try { files = await fs.promises.readdir(BACKUP_DIR); } catch { return null; }
87
+ const dumps = files.filter((f) => f.startsWith(`${DB_NAME}-`) && f.endsWith('.sql.gz')).sort();
88
+ if (!dumps.length) return null;
89
+ const ts = dumps[dumps.length - 1].replace(`${DB_NAME}-`, '').replace('.sql.gz', '');
90
+ const age = dumpAgeHours(ts, nowMs);
91
+ return { age_hours: age === null ? null : Math.round(age * 10) / 10, stale: isStaleDump(ts, nowMs) };
92
+ }
93
+
79
94
  function latestDump(nowMs = Date.now()) {
80
95
  let files;
81
96
  try {
@@ -107,7 +122,16 @@ function latestDump(nowMs = Date.now()) {
107
122
  };
108
123
  }
109
124
 
125
+ // The Treasury desk's backup row (task 1004559), under the backup routes' own
126
+ // atom: one tap runs POST /backup/trigger. Pure; exported for tests.
127
+ function backupDeskRows(dump, running) {
128
+ const age = dump && Number.isFinite(Number(dump.age_hours)) ? Number(dump.age_hours) : null;
129
+ const fact = running ? 'backing up now' : age == null ? 'no backup yet' : age < 1 ? 'last one under an hour' : age < 48 ? `last ${Math.round(age)} hours ago` : `last ${Math.round(age / 24)} days ago`;
130
+ return [{ id: 'now', name: 'Back up now', fact, facts: dump && dump.stale ? ['the last one is stale'] : [], tool: true, ref: { type: 'backup', id: 'now' }, acts: running ? [] : [{ key: 'backup' }] }];
131
+ }
132
+
110
133
  module.exports = function buildBackupRouter() {
134
+ seams.contribute('governor.docket', { desk: 'treasury.backup', gate: { permission: 'backup.manage' }, read: async () => backupDeskRows(await newestDumpAge(), triggerRunning) });
111
135
  const router = express.Router();
112
136
 
113
137
  // rank: metic+archon — any working builder may check or trigger a backup. Gate:
@@ -168,3 +192,5 @@ module.exports.parseDumpTimestamp = parseDumpTimestamp;
168
192
  module.exports.dumpAgeHours = dumpAgeHours;
169
193
  module.exports.isStaleDump = isStaleDump;
170
194
  module.exports.STALE_AFTER_HOURS = STALE_AFTER_HOURS;
195
+
196
+ module.exports.backupDeskRows = backupDeskRows;
@@ -168,7 +168,99 @@ function fireDiscordBroadcast(text) {
168
168
  }
169
169
  }
170
170
 
171
+ // The People desk's builder rows (task 1004559): one row per active builder the
172
+ // viewer may act on, with the acts the viewer's own atoms allow: set tier
173
+ // (builder.rank.set, PATCH /builders/:id/rank) and deactivate (builder.status.set,
174
+ // PATCH /builders/:id/status). Never the viewer's own row: both routes refuse it.
175
+ // Pure; exported for tests.
176
+ const STOP_REASONS = Object.freeze([
177
+ { key: 'left', label: 'left the project' },
178
+ { key: 'quiet', label: 'gone quiet' },
179
+ { key: 'asked', label: 'asked to stop' },
180
+ ]);
181
+ function builderDeskRows(builders, { viewerId, mayRank, mayStop, ranks }) {
182
+ if (!mayRank && !mayStop) return [];
183
+ return (builders || []).filter((b) => b && String(b.id) !== String(viewerId)).map((b) => {
184
+ const acts = [];
185
+ // one rung up at most (the route refuses a multi-rung promotion), any rung down
186
+ const at = (ranks || []).indexOf(b.rank);
187
+ // a rank off the live ladder offers no tier chip: there is no rung to move by one
188
+ if (mayRank && at >= 0) acts.push({ key: 'tier', options: (ranks || []).filter((r, i) => r !== b.rank && i <= at + 1).map((r) => ({ key: r, label: r })) });
189
+ if (mayStop) acts.push({ key: 'deactivate', options: STOP_REASONS });
190
+ return { id: b.id, name: b.display_name || b.github_login || `builder ${b.id}`, fact: b.rank || 'builder', facts: b.github_login ? [`@${b.github_login}`] : [], ref: { type: 'builder', id: b.id }, acts };
191
+ });
192
+ }
193
+ // Contributed from the router factory: any builder passes the gate, and the read
194
+ // scopes itself to the viewer's atoms (an empty desk for a viewer holding neither).
195
+ function contributeBuilderDesk() {
196
+ seams.contribute('governor.docket', {
197
+ desk: 'people.builders',
198
+ gate: { anyBuilder: true },
199
+ read: async (ctx) => {
200
+ const mayRank = ctx.has('builder.rank.set');
201
+ const mayStop = ctx.has('builder.status.set');
202
+ if (!mayRank && !mayStop) return [];
203
+ return builderDeskRows(await db.listActiveBuilders(), { viewerId: ctx.builderId, mayRank, mayStop, ranks: db.LIVE_RANK_LADDER });
204
+ },
205
+ });
206
+ }
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
+
171
261
  module.exports = function buildBuildersRouter() {
262
+ contributeBuilderDesk();
263
+ contributeTreasury();
172
264
  const router = express.Router();
173
265
 
174
266
  // rank: archon — sole chokepoint that mutates builders.rank; ADR 0016 §enforcement.
@@ -323,7 +415,7 @@ module.exports = function buildBuildersRouter() {
323
415
  // setBuilderMonthlyBudget carved to modules/economy/ (BV1.R77 / ADR 0093
324
416
  // §1-2): resolve the `reward` kernel port (economy is default-on). The
325
417
  // archon rank gate on this route is unchanged.
326
- const updated = await seams.resolveOptional('reward').setBuilderMonthlyBudget(targetId, budget);
418
+ const updated = await setMonthlyBudget(targetId, budget);
327
419
  if (!updated) return res.fail('builder_not_found', 404);
328
420
  res.json({ builder: updated });
329
421
  } catch (err) {
@@ -707,3 +799,6 @@ module.exports = function buildBuildersRouter() {
707
799
 
708
800
  return router;
709
801
  };
802
+
803
+ module.exports.builderDeskRows = builderDeskRows;
804
+ module.exports.budgetDeskRows = budgetDeskRows;
@@ -180,8 +180,24 @@ function contributeModuleRechecksToDocket() {
180
180
  });
181
181
  }
182
182
 
183
+ // The Rules desk's module rows (task 1004559), under the toggle's own atom
184
+ // (POST /modules/:key/enable, module.enable): each module on disk, turned on in
185
+ // one tap, or off with a one-tap 'yes' so a stray tap cannot pull one. Pure;
186
+ // exported for tests.
187
+ function moduleDeskRows(mods) {
188
+ return (mods || []).filter((m) => m && m.key).map((m) => ({
189
+ id: m.key, name: m.title || m.key, fact: m.enabled ? 'on' : 'off', facts: m.description ? [m.description] : [], tool: true,
190
+ ref: { type: 'module', id: m.key },
191
+ acts: [m.enabled ? { key: 'module_off', options: [{ key: 'yes', label: 'yes, turn off' }] } : { key: 'module_on' }],
192
+ }));
193
+ }
194
+ function contributeModuleDesk() {
195
+ seams.contribute('governor.docket', { desk: 'rules.modules', gate: { permission: 'module.enable' }, read: async () => moduleDeskRows(listModules()) });
196
+ }
197
+
183
198
  module.exports = function buildModulesRouter() {
184
199
  contributeModuleRechecksToDocket();
200
+ contributeModuleDesk();
185
201
  const router = express.Router();
186
202
  // Checked how-tos by "key@version:tarball_sha256". Per router, so a test's fresh
187
203
  // router starts empty.
@@ -702,3 +718,5 @@ module.exports = function buildModulesRouter() {
702
718
  // own disk scan, no DB), so it's safe to require directly outside an Express
703
719
  // request cycle, the same way tests/atlas.mjs requires gen-atlas.js's builders.
704
720
  module.exports.listModules = listModules;
721
+
722
+ module.exports.moduleDeskRows = moduleDeskRows;
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.34'; // 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');
@@ -285,7 +285,7 @@ test('the response shape: counts, line, street-ordered buildings, sorted matters
285
285
  c.readings.set('flow', { reading: 'flow', gate: { anyBuilder: true }, read: async () => ({ value: 3, figure: '3 stuck', line: 'longest wait 4 days, waiting for review', hot: true }) });
286
286
  const d = await docket.buildDocket({ viewer: VIEWER, permissions: ['board.vote.cast', 'security.report.adjudicate'], contributions: c, now: NOW });
287
287
 
288
- assert.deepEqual(Object.keys(d).sort(), ['buildings', 'counts', 'floors', 'generated_at', 'history', 'history_sources', 'line', 'matters', 'readings', 'sources', 'viewer', 'vocabulary'].sort());
288
+ assert.deepEqual(Object.keys(d).sort(), ['buildings', 'counts', 'desks', 'floors', 'generated_at', 'history', 'history_sources', 'line', 'matters', 'readings', 'sources', 'viewer', 'vocabulary'].sort());
289
289
  assert.deepEqual(d.viewer, { id: '7' });
290
290
  // board 11 (needs) + board 12 (settled, closed this week) + blocker + report; board 13 is too old
291
291
  assert.deepEqual(d.counts, { needs: 2, moving: 1, settled: 1 });
@@ -734,3 +734,30 @@ test('GET /government/docket: 403 without board.vote.cast, the documented shape
734
734
  assert.equal(body.sources.find((s) => s.source === 'access_requests').status, 'not_yours');
735
735
  assert.ok(!JSON.stringify(body).includes('Admit Kai'), 'a hidden source leaks nothing');
736
736
  });
737
+
738
+ // ── the floor desks' acts and rows (task 1004559) ────────────────────────────
739
+
740
+ test('acts: a matter carries only well-formed acts, at most two, with at most five one-tap options', () => {
741
+ const m = docket.normalizeMatter({ id: 1, title: 't', acts: [{ key: 'confirm', options: [1, 2, 3, 4, 5, 6].map((n) => ({ key: n, label: `option ${n}` })) }, { key: 'Bad Key' }, { key: 'fix' }, { key: 'third' }] }, { source: 's', building: 'review' });
742
+ assert.deepEqual(m.acts.map((a) => a.key), ['confirm', 'fix']);
743
+ assert.equal(m.acts[0].options.length, 5);
744
+ assert.equal('acts' in docket.normalizeMatter({ id: 1, title: 't' }, { source: 's', building: 'review' }), false, 'no acts, no field');
745
+ assert.deepEqual(docket.normalizeActs([{ key: 'allow', pick: 'open_goals', except: [1, 2] }, { key: 'pause', pick: 'everything' }]), [{ key: 'allow', pick: 'open_goals', except: ['1', '2'] }, { key: 'pause' }]);
746
+ assert.equal(docket.normalizeDeskRow({ id: 1, name: 'n', acts: [] }, { desk: 'people.builders', building: 'people' }), null, 'a desk row with nothing to do is dropped');
747
+ });
748
+
749
+ test('desks: gated by the atom their write route checks; a gated-out desk is not_yours with no rows; a broken one is unavailable', async () => {
750
+ const c = none();
751
+ c.desks = new Map([
752
+ ['people.builders', { desk: 'people.builders', gate: { permission: 'builder.rank.set' }, read: async () => [{ id: 7, name: 'Ana', fact: 'thetes', acts: [{ key: 'tier', options: [{ key: 'metic', label: 'metic' }] }] }, { id: 8, name: 'no acts', acts: [] }] }],
753
+ ['automation.goals', { desk: 'automation.goals', gate: { anyBuilder: true }, read: async () => { throw new Error('boom'); } }],
754
+ ]);
755
+ const without = await docket.buildDocket({ viewer: VIEWER, permissions: [], contributions: c, now: NOW });
756
+ const by = (d, k) => d.desks.find((x) => x.key === k);
757
+ assert.deepEqual(by(without, 'people.builders'), { key: 'people.builders', building: 'people', status: 'not_yours', rows: [] });
758
+ assert.equal(by(without, 'automation.goals').status, 'unavailable');
759
+ assert.equal(by(without, 'automation.runners').status, 'off');
760
+ const withAtom = await docket.buildDocket({ viewer: VIEWER, permissions: ['builder.rank.set'], contributions: c, now: NOW });
761
+ assert.deepEqual(by(withAtom, 'people.builders').rows.map((r) => [r.id, r.name, r.acts[0].key]), [['people.builders:7', 'Ana', 'tier']]);
762
+ assert.equal(withAtom.matters.length, 0, 'desk rows never become matters (no pins, no counts)');
763
+ });