@bongos/core 1.21.4 → 1.21.5

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 (67) hide show
  1. package/.bongos-core.json +118 -63
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +2 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0362-the-governor-docket-is-collected-from-contributed-sources.md +34 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +35 -1
  10. package/docs/api-reference.md +3 -2
  11. package/docs/copy-inventory.md +389 -518
  12. package/docs/copy-registry.json +880 -2143
  13. package/docs/module-api-changelog.md +2 -0
  14. package/docs/page-inventory.json +17 -25
  15. package/docs/page-readings.json +1008 -1246
  16. package/modules/autonomy/governor-docket.js +101 -0
  17. package/modules/autonomy/routes/autonomy.js +5 -0
  18. package/modules/discord/board-broadcast.js +3 -1
  19. package/modules/discord/craft-broadcast.js +107 -0
  20. package/modules/discord/routes/discord.js +10 -0
  21. package/modules/discord/ship-broadcast.js +30 -2
  22. package/modules/economy/reward.js +3 -0
  23. package/modules/government/board.js +20 -5
  24. package/modules/government/docket-vocab.js +82 -0
  25. package/modules/government/docket.js +458 -0
  26. package/modules/government/routes/government.js +34 -0
  27. package/modules/hall-ui/public/genesis-home.js +9 -10
  28. package/modules/ideas/governor-docket.js +93 -0
  29. package/modules/ideas/routes/blockers.js +5 -0
  30. package/modules/lifecycle/governor-docket.js +192 -0
  31. package/modules/lifecycle/routes/gate-approvals.js +78 -33
  32. package/modules/lifecycle/routes/lifecycle.js +3 -0
  33. package/modules/onboarding/routes/access-requests.js +68 -27
  34. package/modules/public-landing/public/assets/cosmos.css +6 -50
  35. package/modules/public-landing/public/projects.html +1170 -1659
  36. package/modules/public-landing/public/projects.probes.json +18 -18
  37. package/modules/public-landing/public/projects.states.json +23 -31
  38. package/modules/security/routes/reports.js +39 -0
  39. package/package-lock.json +2 -2
  40. package/package.json +1 -1
  41. package/release-notes.json +14 -0
  42. package/src/bongos/module-rechecks.js +77 -0
  43. package/src/bongos/route-rank-check.js +3 -0
  44. package/src/bongos/routes/core-update.js +41 -0
  45. package/src/bongos/routes/modules.js +17 -0
  46. package/src/module-api.js +1 -1
  47. package/tests/discord_craft_broadcast.mjs +162 -0
  48. package/tests/genesis_home.mjs +1 -1
  49. package/tests/government_routes.mjs +3 -1
  50. package/tests/governor_docket.mjs +552 -0
  51. package/tests/onboard.mjs +10 -4
  52. package/tests/platform_boot.mjs +1 -1
  53. package/tests/projects_hub.mjs +196 -89
  54. package/tests/projects_hub_app_status.mjs +3 -1
  55. package/tests/projects_hub_app_step.mjs +72 -48
  56. package/tests/projects_hub_dns_ready.mjs +10 -2
  57. package/tests/projects_hub_look.mjs +32 -22
  58. package/tests/projects_hub_module_picker.mjs +112 -62
  59. package/tests/projects_hub_pre_uat.mjs +48 -41
  60. package/tests/public_landing_projects.mjs +12 -6
  61. package/tests/wizard_demo.mjs +37 -11
  62. package/tests/wizard_draft_resume.mjs +34 -22
  63. package/tests/wizard_front_door.mjs +173 -103
  64. package/tests/wizard_intent_resume.mjs +28 -25
  65. package/tests/wizard_physics.mjs +94 -76
  66. package/tests/wizard_physics_more.mjs +409 -277
  67. package/tests/wizard_six_screens.mjs +126 -0
@@ -0,0 +1,101 @@
1
+ 'use strict';
2
+
3
+ // modules/autonomy/governor-docket.js — the autonomy module's part of the
4
+ // governor's docket (task 1004523, goal 1000125; collector:
5
+ // modules/government/docket.js, decision: ADR 0362).
6
+ //
7
+ // WHY. Two things the Governor City shows live here. The Autobongos FENCE stands
8
+ // at Automation, and it is a state rather than a queue, so it becomes a matter
9
+ // only when it needs a governor: the switch is off, or it is on with no goal
10
+ // allowed (nothing can build either way). And the "money" reading: the one
11
+ // budget this project actually records is the unattended-spend ceiling the
12
+ // autonomy gate brakes on (scripts/gds/autonomy-gate.js `ceilingUsd`), against
13
+ // the month's unattended spend (economy's `autonomousApiSpendMtd`, through the
14
+ // `reward` port). So "money left" is honestly "what unattended building may still
15
+ // spend this month" — the project has no wider budget to count down from, and the
16
+ // line says so rather than inventing one.
17
+ //
18
+ // Contributed to the `governor.docket` point from the autonomy route factory (a
19
+ // module never requires a sibling, ADR 0083). Pure mappers exported for tests.
20
+
21
+ const api = require('../../src/module-api');
22
+ const db = require('./db');
23
+ const autonomyGate = require('../../scripts/gds/autonomy-gate');
24
+
25
+ const POINT = 'governor.docket';
26
+
27
+ // The fence as matters for one viewer. Pure. Only a holder of
28
+ // `autonomy.fence.manage` (the switch's own atom) is told it needs them.
29
+ function fenceDocketMatters(project, mayManage) {
30
+ const goals = Array.isArray(project.goals) ? project.goals : [];
31
+ let title = null;
32
+ let where = null;
33
+ if (project.enabled === false) {
34
+ title = 'Unattended building is switched off';
35
+ where = project.paused_reason ? `automation · the switch · ${project.paused_reason}` : 'automation · the switch';
36
+ } else if (!goals.length) {
37
+ title = 'Unattended building is on, but no goal is allowed';
38
+ where = 'automation · allowed goals';
39
+ }
40
+ if (!title) return [];
41
+ return [{
42
+ id: 'fence',
43
+ rule: 'decide',
44
+ state: mayManage ? 'needs' : 'settled',
45
+ title,
46
+ short: 'the switch',
47
+ where,
48
+ opened_at: project.updated_at || null,
49
+ deadline: null,
50
+ proposer: project.updated_by == null ? null : { id: project.updated_by },
51
+ tally: null,
52
+ href: '/watch',
53
+ ref: { type: 'autonomy_fence', id: 'project' },
54
+ }];
55
+ }
56
+
57
+ // Money left under the unattended-spend ceiling. Pure. Hot when under a fifth left.
58
+ function moneyReading({ ceilingUsd, spentUsd }) {
59
+ const ceiling = Number(ceilingUsd) || 0;
60
+ const spent = Math.max(0, Number(spentUsd) || 0);
61
+ const left = Math.max(0, ceiling - spent);
62
+ const usd = (n) => `$${n.toFixed(2).replace(/\.00$/, '')}`;
63
+ return {
64
+ value: { left_usd: left, spent_usd: spent, ceiling_usd: ceiling, scope: 'unattended building, this month' },
65
+ figure: `${usd(left)} left`,
66
+ line: `unattended building this month · ${usd(spent)} of ${usd(ceiling)} spent`,
67
+ hot: ceiling > 0 && left / ceiling < 0.2,
68
+ };
69
+ }
70
+
71
+ function contributeAutonomyToDocket() {
72
+ // GET /autonomy/fence is any builder's read; the read itself fails on a missing
73
+ // fence row rather than fabricating "off" (that route's rule).
74
+ api.contribute(POINT, {
75
+ source: 'autobongos_fence',
76
+ building: 'automation',
77
+ gate: { anyBuilder: true },
78
+ read: async (ctx) => {
79
+ const project = await db.readFence();
80
+ if (!project) throw new Error('fence_uninitialised');
81
+ return fenceDocketMatters(project, ctx.has('autonomy.fence.manage'));
82
+ },
83
+ });
84
+ // The ceiling and the month's unattended spend are what GET /autonomy/precheck
85
+ // answers, so the reading carries that route's atom.
86
+ api.contribute(POINT, {
87
+ reading: 'money',
88
+ gate: { permission: 'autonomy.precheck' },
89
+ read: async () => {
90
+ const reward = api.resolveOptional('reward');
91
+ if (!reward || typeof reward.autonomousApiSpendMtd !== 'function') throw new Error('economy_off');
92
+ return moneyReading({ ceilingUsd: autonomyGate.ceilingUsd(), spentUsd: await reward.autonomousApiSpendMtd() });
93
+ },
94
+ });
95
+ }
96
+
97
+ module.exports = {
98
+ contributeAutonomyToDocket,
99
+ fenceDocketMatters,
100
+ moneyReading,
101
+ };
@@ -101,7 +101,12 @@ function commitOrNull(v) {
101
101
  return typeof v === 'string' && /^[0-9a-f]{40}$/.test(v) ? v : null;
102
102
  }
103
103
 
104
+ // The governor's docket (task 1004523): the fence + the money reading,
105
+ // contributed from this factory (see ../governor-docket.js).
106
+ const governorDocket = require('../governor-docket');
107
+
104
108
  module.exports = function buildAutonomyRouter() {
109
+ governorDocket.contributeAutonomyToDocket();
105
110
  const router = express.Router();
106
111
 
107
112
  router.get('/autonomy/precheck', auth.requireBuilder, auth.requirePermission('autonomy.precheck'), async (req, res) => {
@@ -244,4 +244,6 @@ async function postBoardOutcome(payload, deps = {}) {
244
244
  return result;
245
245
  }
246
246
 
247
- module.exports = { postBoardAnnouncement, postBoardOutcome, composeOutcomeLine };
247
+ // plainName is shared with craft-broadcast.js (task 1004439): one sanitizer for
248
+ // every builder name this module puts on a channel.
249
+ module.exports = { postBoardAnnouncement, postBoardOutcome, composeOutcomeLine, plainName };
@@ -0,0 +1,107 @@
1
+ // modules/discord/craft-broadcast.js — the feed hears an artist's and an
2
+ // ideator's moments, not only shipped tasks (task 1004439 / WA6.RP09, goal
3
+ // 1000095, criterion wa6-roles-read-true-in-public).
4
+ //
5
+ // WHY. #ship-feed posts one line per shipped task, and nothing when the board
6
+ // ratifies an idea or a Full Idea passes its completeness bar. An ideator's best
7
+ // moments happened in silence while an engineer's every ship was announced. (A
8
+ // page approved in Tweak Mode DOES ship as a task; ship-broadcast.js names its
9
+ // artist on that line, task 1004432's author of record.)
10
+ //
11
+ // THE LINES, owner audience, no jargon:
12
+ // board.item.passed (a full_idea) 🏛️ The board ratified an idea by <name>. <link>
13
+ // idea.graded.full (at the bar) 📜 <name>'s Full Idea passed its completeness check. <link>
14
+ //
15
+ // PRIVACY, the existing feed's rules: #ship-feed is the BUILDERS' channel (not
16
+ // #ship-news), so naming a builder by their display name is what it already does
17
+ // for the Board Room's members. The idea's TITLE is never posted — an idea may sit
18
+ // in a private goal, and the hall link is gated where the idea is; the line says
19
+ // THAT it happened and who, and the link carries the rest to whoever may see it.
20
+ // A line never starts with `#<id>`, so the applause bot does not read it as a ship.
21
+ //
22
+ // BEST-EFFORT, like every broadcast in this module: it never throws, so a Discord
23
+ // hiccup can never fail a ratification or a grade — both are durable rows already
24
+ // written when their events fire.
25
+
26
+ 'use strict';
27
+
28
+ const api = require('../../src/module-api');
29
+ const webhook = require('./webhook');
30
+ const { plainName } = require('./board-broadcast');
31
+
32
+ const CHANNEL = 'ship_feed';
33
+
34
+ function ideaLink(ideaId) {
35
+ const origin = ((api.branding && api.branding().domains) || {}).buildersOrigin || '';
36
+ return `${origin ? origin.replace(/\/$/, '') : ''}/#/idea/${encodeURIComponent(ideaId)}`;
37
+ }
38
+
39
+ // PURE. The two lines, from what the caller already resolved.
40
+ function composeRatifiedLine({ ideaId, author }) {
41
+ const by = author ? ` by ${plainName(author)}` : '';
42
+ return `\u{1F3DB}\u{FE0F} The board ratified an idea${by}. ${ideaLink(ideaId)}`;
43
+ }
44
+ function composeFullIdeaLine({ ideaId, author }) {
45
+ const who = author ? `${plainName(author)}'s Full Idea` : 'A Full Idea';
46
+ return `\u{1F4DC} ${who} passed its completeness check. ${ideaLink(ideaId)}`;
47
+ }
48
+
49
+ // The display name for a builder id, or null: a missing or unreadable builder
50
+ // leaves the line unnamed rather than failing it.
51
+ async function nameOf(builderId, deps) {
52
+ if (builderId == null) return null;
53
+ try {
54
+ const getBuilder = deps.getBuilder || ((id) => api.getBuilderById(id));
55
+ const b = await getBuilder(builderId);
56
+ return b ? (b.display_name || b.github_login || null) : null;
57
+ } catch (_) {
58
+ return null;
59
+ }
60
+ }
61
+
62
+ async function post(line, deps, result, what) {
63
+ const log = deps.log || console.log;
64
+ const hasChannel = deps.hasChannel || webhook.hasWebhook;
65
+ try {
66
+ if (!hasChannel(CHANNEL)) { result.skipped = 'channel_not_configured'; return result; }
67
+ const send = deps.post || webhook.postWebhook;
68
+ await send(CHANNEL, line);
69
+ result.posted = true;
70
+ } catch (e) {
71
+ result.skipped = 'post_failed';
72
+ log(`[discord] ${what}: #ship-feed post failed (non-blocking): ${e && e.message ? e.message : e}`);
73
+ }
74
+ return result;
75
+ }
76
+
77
+ // board.item.passed — the pass contract's seven fields. Only a Full Idea is an
78
+ // ideator's moment: an amendment or a genesis stage is the project's decision,
79
+ // and posts nothing here (the Board Room's own channel already carries it).
80
+ async function postIdeaRatified(payload, deps = {}) {
81
+ const result = { posted: false, skipped: null };
82
+ if (!payload || payload.subject_type !== 'full_idea' || payload.idea_id == null) {
83
+ result.skipped = 'not_an_idea';
84
+ return result;
85
+ }
86
+ const author = await nameOf(payload.author_id, deps);
87
+ return post(composeRatifiedLine({ ideaId: payload.idea_id, author }), deps, result, 'idea ratified');
88
+ }
89
+
90
+ // idea.graded.full — fired once per real spark→full transition. It is a pass
91
+ // only at or above economy's bar (the same bar the full-pass credit pays at,
92
+ // read through the reward port; without economy there is no bar to pass, so
93
+ // nothing is announced). The author is the developer when someone else developed
94
+ // the spark, as the credit split names them.
95
+ async function postFullIdeaPassed(graded, deps = {}) {
96
+ const result = { posted: false, skipped: null };
97
+ const ideaId = graded && (graded.idea_id != null ? graded.idea_id : graded.id);
98
+ if (ideaId == null) { result.skipped = 'no_idea'; return result; }
99
+ const reward = deps.reward || api.resolveOptional('reward');
100
+ const bar = reward && Number(reward.IDEA_FULL_PASS_BAR);
101
+ const score = graded.completeness_score == null ? NaN : Number(graded.completeness_score);
102
+ if (!Number.isFinite(bar) || !Number.isFinite(score) || score < bar) { result.skipped = 'below_bar'; return result; }
103
+ const author = await nameOf(graded.developed_by != null ? graded.developed_by : graded.captured_by, deps);
104
+ return post(composeFullIdeaLine({ ideaId, author }), deps, result, 'full idea passed');
105
+ }
106
+
107
+ module.exports = { postIdeaRatified, postFullIdeaPassed, composeRatifiedLine, composeFullIdeaLine };
@@ -21,6 +21,7 @@ const auth = require('../auth');
21
21
  const db = require('../db');
22
22
  const shipBroadcast = require('../ship-broadcast');
23
23
  const boardBroadcast = require('../board-broadcast');
24
+ const craftBroadcast = require('../craft-broadcast');
24
25
  const roleSync = require('../discord-role-sync');
25
26
  const webhook = require('../webhook');
26
27
 
@@ -50,6 +51,8 @@ let _strandListenerOff = null;
50
51
  let _roleSyncListenerOff = null;
51
52
  let _boardListenerOff = null;
52
53
  let _boardCloseListenerOff = null;
54
+ let _ideaRatifiedListenerOff = null;
55
+ let _fullIdeaListenerOff = null;
53
56
  function registerSeams() {
54
57
  if (!api.hasProvider('discord.isLinked')) {
55
58
  api.registerProvider('discord.isLinked', async (builderId) => !!(await db.getDiscordLink(builderId)));
@@ -98,6 +101,13 @@ function registerSeams() {
98
101
  // well as a pass; still outbound only, still nothing cast from Discord.
99
102
  if (_boardCloseListenerOff) _boardCloseListenerOff();
100
103
  _boardCloseListenerOff = api.on('board.item.closed', (payload) => boardBroadcast.postBoardOutcome(payload));
104
+ // task 1004439: the ideator's moments reach #ship-feed beside the ships — a
105
+ // ratified idea (the pass contract's event; a mirror reads it, nothing here
106
+ // pays) and a Full Idea at the bar. Outbound only, best-effort.
107
+ if (_ideaRatifiedListenerOff) _ideaRatifiedListenerOff();
108
+ _ideaRatifiedListenerOff = api.on('board.item.passed', (payload) => craftBroadcast.postIdeaRatified(payload));
109
+ if (_fullIdeaListenerOff) _fullIdeaListenerOff();
110
+ _fullIdeaListenerOff = api.on('idea.graded.full', (graded) => craftBroadcast.postFullIdeaPassed(graded));
101
111
  if (_roleSyncListenerOff) _roleSyncListenerOff();
102
112
  _roleSyncListenerOff = api.on('builder.role-sync-needed', ({ builderId }) =>
103
113
  roleSync.syncBuilderRole(builderId).catch((e) =>
@@ -30,17 +30,43 @@
30
30
  // For now #ship-news gets nothing so exactly one #ship-feed message fires per
31
31
  // ship. The broader "make the two channels genuinely different + filter by task
32
32
  // type" work is idea 458 — un-pause by restoring the ship_news entry below.
33
- function chooseBroadcastTargets({ securitySensitive, taskId, summary }) {
33
+ //
34
+ // A PAGE TWEAK (task 1004439): an approved Tweak Mode round ships as a task, and
35
+ // its line says what it is — a page approved, rewritten by its ARTIST (the round's
36
+ // author of record, task 1004432's shipped_by), never the session that applied it.
37
+ // `pageArtist` is the artist's name already resolved, or null when it could not be
38
+ // read; the line then says "page approved" and names nobody rather than the
39
+ // applier. Still `#<id>`-prefixed, so applause on it reaches the round.
40
+ function chooseBroadcastTargets({ securitySensitive, taskId, summary, pageTweak = false, pageArtist = null }) {
34
41
  if (!summary) return [];
35
42
  if (securitySensitive) {
36
43
  return [{ channel: 'security_ship_feed', line: `#${taskId} shipped (security) — ${summary}` }];
37
44
  }
45
+ if (pageTweak) {
46
+ const by = pageArtist ? ` — rewritten by ${plainName(pageArtist)}` : '';
47
+ return [{ channel: 'ship_feed', line: `#${taskId} page approved${by}: ${summary}` }];
48
+ }
38
49
  return [
39
50
  // { channel: 'ship_news', line: summary }, // PAUSED — task 1749 / idea 458
40
51
  { channel: 'ship_feed', line: `#${taskId} shipped — ${summary}` },
41
52
  ];
42
53
  }
43
54
 
55
+ // The page-tweak source string, and the artist's display name for a shipped
56
+ // round, or null — an unreadable builder leaves the line unnamed, never fails it.
57
+ const PAGE_TWEAK_SOURCE = 'page-tweak';
58
+ async function pageArtistName(task, deps) {
59
+ if (task.shipped_by == null) return null;
60
+ try {
61
+ const getBuilder = deps.getBuilder || ((id) => require('../../src/module-api').getBuilderById(id));
62
+ const b = await getBuilder(task.shipped_by);
63
+ return b ? (b.display_name || b.github_login || null) : null;
64
+ } catch (_) {
65
+ return null;
66
+ }
67
+ }
68
+ function plainName(name) { return require('./board-broadcast').plainName(name); }
69
+
44
70
  // Best-effort server-side broadcast for a task that just landed via server-
45
71
  // mediated publish. `task` is the shipTask() row: { id, value_summary,
46
72
  // security_sensitive, ... }. deps.postWebhook (the scripts/discord/lib poster) and
@@ -61,7 +87,9 @@ async function postShipBroadcast(task, deps = {}) {
61
87
 
62
88
  const postWebhook = deps.postWebhook || require('./webhook').postWebhook;
63
89
  const log = deps.log || console.log;
64
- const targets = chooseBroadcastTargets({ securitySensitive: sec, taskId: task.id, summary });
90
+ const pageTweak = task.source === PAGE_TWEAK_SOURCE;
91
+ const pageArtist = pageTweak && sec === false ? await pageArtistName(task, deps) : null;
92
+ const targets = chooseBroadcastTargets({ securitySensitive: sec, taskId: task.id, summary, pageTweak, pageArtist });
65
93
  for (const { channel, line } of targets) {
66
94
  try {
67
95
  await postWebhook(channel, line);
@@ -72,6 +72,9 @@ module.exports = {
72
72
  get KIND_MULTIPLIERS() { return credits.KIND_MULTIPLIERS; },
73
73
  get IDEA_BONUS_FRACTION() { return credits.IDEA_BONUS_FRACTION; },
74
74
  get REWARD_MARGIN_PCT() { return credits.REWARD_MARGIN_PCT; },
75
+ // task 1004439: the completeness a Full Idea must reach to pass, so the Discord
76
+ // feed announces a pass at exactly the bar the full-pass credit pays at.
77
+ get IDEA_FULL_PASS_BAR() { return credits.IDEA_FULL_PASS_BAR; },
75
78
 
76
79
  // --- READ surface the core me/public/builders/sessions routes resolve through
77
80
  // this same port (one economy port; the grading precedent registers the
@@ -1349,6 +1349,21 @@ async function boardPassForIdea(ideaId) {
1349
1349
  // - this builder is not the subject's author (the anti-self-vote line, §3)
1350
1350
  // Fail-closed on an unreadable constitution: parseMembershipPredicate yields
1351
1351
  // nobody, so a malformed snapshot summons no one rather than everyone.
1352
+ //
1353
+ // THE RULE ITSELF is `awaitsVoter` below, and it is the ONE definition: the
1354
+ // governor's docket (docket.js, task 1004523) asks it too, so the city's
1355
+ // "needs you" and this summons cannot disagree about who must act.
1356
+ // tests/governor_docket.mjs drives both over the same matrix.
1357
+
1358
+ // Pure: does this sitting wait on this builder's vote? Open and not past its
1359
+ // deadline, a member under the item's OWN snapshot, not already cast, and not
1360
+ // the subject's author (null author = nobody to hold back: a genesis stage).
1361
+ function awaitsVoter({ builderId, authorId = null, alreadyVoted = false, isMember = false, closed = false, expired = false } = {}) {
1362
+ if (builderId === null || builderId === undefined || builderId === '') return false;
1363
+ if (closed || expired || alreadyVoted || !isMember) return false;
1364
+ return !(authorId != null && String(authorId) === String(builderId));
1365
+ }
1366
+
1352
1367
  async function pendingVotesFor(builderId) {
1353
1368
  const empty = Object.freeze({ count: 0, first_item_id: null });
1354
1369
  if (builderId === null || builderId === undefined || builderId === '') return empty;
@@ -1367,16 +1382,15 @@ async function pendingVotesFor(builderId) {
1367
1382
  const membershipCache = new Map();
1368
1383
  const votable = [];
1369
1384
  for (const row of rows) {
1370
- if (row.already_voted) continue;
1371
- // The author does not vote on their own item (§3). Null author = nobody to
1372
- // disqualify, so the item stays votable.
1373
- if (row.author_id != null && String(row.author_id) === String(builderId)) continue;
1385
+ // Cheap refusals first, so membership is resolved only for a row that could
1386
+ // still be votable (the SQL already dropped closed and past-deadline items).
1387
+ if (!awaitsVoter({ builderId, authorId: row.author_id, alreadyVoted: !!row.already_voted, isMember: true })) continue;
1374
1388
  const constitution = row.constitution || {};
1375
1389
  const key = String(constitution.membership || '');
1376
1390
  if (!membershipCache.has(key)) {
1377
1391
  membershipCache.set(key, await isBoardMember(builderId, constitution).catch(() => false));
1378
1392
  }
1379
- if (!membershipCache.get(key)) continue;
1393
+ if (!awaitsVoter({ builderId, authorId: row.author_id, alreadyVoted: !!row.already_voted, isMember: membershipCache.get(key) })) continue;
1380
1394
  votable.push(row);
1381
1395
  }
1382
1396
  if (!votable.length) return empty;
@@ -1392,6 +1406,7 @@ module.exports = {
1392
1406
  openGenesisStageClose,
1393
1407
  genesisStageStatus,
1394
1408
  pendingVotesFor,
1409
+ awaitsVoter,
1395
1410
  hasMajority,
1396
1411
  majorityDecided,
1397
1412
  decidingVotes,
@@ -0,0 +1,82 @@
1
+ 'use strict';
2
+
3
+ // modules/government/docket-vocab.js — the Governor City's fixed words: the nine
4
+ // civic buildings in street order, the seven ways a matter is decided, the three
5
+ // states a matter can be in for one viewer, and the six readings (task 1004523,
6
+ // goal 1000125).
7
+ //
8
+ // WHY. The docket (docket.js) is the one server read every Governor City screen
9
+ // draws from — the home city's pins, the street, the building interiors, the
10
+ // floors and the matter cards (tasks 1004524-1004528). Those screens must agree
11
+ // on what a building key, a rule key or a state key MEANS, so the vocabulary is
12
+ // declared once, here, and shipped on the response (`vocabulary`) so a page builds
13
+ // its legend from the server instead of retyping it. The words and their order are
14
+ // the reference design's (docs/design/mocks/governor-city/README.md, "The street"
15
+ // and "Decision glyphs"); a change here is a change to the design of record.
16
+ //
17
+ // Pure data, no requires: browser-side copies are held equal by test, never by
18
+ // hand.
19
+
20
+ // The nine civic buildings, in the order work moves along the street, each under
21
+ // its street group label.
22
+ const BUILDINGS = Object.freeze([
23
+ Object.freeze({ key: 'charter', name: 'Charter', group: 'how we decide' }),
24
+ Object.freeze({ key: 'rules', name: 'Rules', group: 'how we decide' }),
25
+ Object.freeze({ key: 'disputes', name: 'Disputes', group: 'how we decide' }),
26
+ Object.freeze({ key: 'people', name: 'People', group: 'who and what' }),
27
+ Object.freeze({ key: 'planning', name: 'Planning', group: 'who and what' }),
28
+ Object.freeze({ key: 'automation', name: 'Automation', group: 'the work' }),
29
+ Object.freeze({ key: 'review', name: 'Review', group: 'checks and going live' }),
30
+ Object.freeze({ key: 'release', name: 'Release', group: 'checks and going live' }),
31
+ Object.freeze({ key: 'treasury', name: 'Treasury', group: 'money' }),
32
+ ]);
33
+ const BUILDING_KEYS = Object.freeze(BUILDINGS.map((b) => b.key));
34
+
35
+ // How a matter is decided — the seven decision glyphs.
36
+ const RULES = Object.freeze([
37
+ Object.freeze({ key: 'decide', words: 'one person decides' }),
38
+ Object.freeze({ key: 'first', words: 'first yes wins' }),
39
+ Object.freeze({ key: 'consent', words: 'passes unless someone objects' }),
40
+ Object.freeze({ key: 'majority', words: 'more than half must say yes' }),
41
+ Object.freeze({ key: 'unanimous', words: 'everyone has to agree' }),
42
+ Object.freeze({ key: 'signoff', words: 'signed off by someone who did not build it' }),
43
+ Object.freeze({ key: 'rule', words: 'settled by a rule' }),
44
+ ]);
45
+ const RULE_KEYS = Object.freeze(RULES.map((r) => r.key));
46
+
47
+ // The board's pass rules (config.js PASS_RULES) in the city's words. An unknown
48
+ // pass rule maps to null — the card then says the rule is not one it knows,
49
+ // rather than drawing the nearest glyph.
50
+ const PASS_RULE_TO_RULE = Object.freeze({
51
+ first_ratifier: 'first',
52
+ consent: 'consent',
53
+ majority: 'majority',
54
+ unanimous: 'unanimous',
55
+ });
56
+
57
+ // A matter's state for ONE viewer. Exactly one applies (README "Pins").
58
+ // needs — waits on the viewer's own act
59
+ // moving — open, and moving without the viewer (they acted, or it is a call
60
+ // they share that is not theirs to make right now)
61
+ // settled — decided, or not the viewer's to act on
62
+ const STATES = Object.freeze(['needs', 'moving', 'settled']);
63
+
64
+ // The six readings on the street's footer, each a door to the building it reads.
65
+ const READINGS = Object.freeze([
66
+ Object.freeze({ key: 'flow', label: 'flow', building: 'review' }),
67
+ Object.freeze({ key: 'promises', label: 'promises', building: 'planning' }),
68
+ Object.freeze({ key: 'money', label: 'money', building: 'treasury' }),
69
+ Object.freeze({ key: 'people', label: 'people', building: 'people' }),
70
+ Object.freeze({ key: 'fairness', label: 'fairness', building: 'disputes' }),
71
+ Object.freeze({ key: 'rules', label: 'rules at work', building: 'rules' }),
72
+ ]);
73
+
74
+ module.exports = {
75
+ BUILDINGS,
76
+ BUILDING_KEYS,
77
+ RULES,
78
+ RULE_KEYS,
79
+ PASS_RULE_TO_RULE,
80
+ STATES,
81
+ READINGS,
82
+ };