@bongos/core 1.21.4 → 1.21.6

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 (126) hide show
  1. package/.bongos-core.json +233 -108
  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 +3 -1
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0361-merges-publish-as-candidates-release-decides-what-others-are-offered.md +1 -0
  8. package/docs/adr/0362-the-governor-docket-is-collected-from-contributed-sources.md +34 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +37 -2
  11. package/docs/api-reference.md +4 -3
  12. package/docs/copy-inventory.md +541 -658
  13. package/docs/copy-registry.json +1205 -2340
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/onboarding/diagrams/03-drachmae-karma.mmd +1 -1
  16. package/docs/onboarding/diagrams/assertions.json +1 -1
  17. package/docs/page-inventory.json +47 -28
  18. package/docs/page-readings.json +1128 -1339
  19. package/modules/autonomy/config-idle.js +192 -0
  20. package/modules/autonomy/governor-docket.js +101 -0
  21. package/modules/autonomy/routes/autonomy.js +27 -0
  22. package/modules/autonomy/runner-health.js +22 -1
  23. package/modules/discord/board-broadcast.js +3 -1
  24. package/modules/discord/craft-broadcast.js +107 -0
  25. package/modules/discord/routes/discord.js +10 -0
  26. package/modules/discord/ship-broadcast.js +30 -2
  27. package/modules/economy/reward.js +3 -0
  28. package/modules/government/board.js +20 -5
  29. package/modules/government/docket-vocab.js +82 -0
  30. package/modules/government/docket.js +458 -0
  31. package/modules/government/routes/government.js +34 -0
  32. package/modules/hall-ui/public/city-draw.js +438 -0
  33. package/modules/hall-ui/public/city.css +110 -0
  34. package/modules/hall-ui/public/city.html +71 -0
  35. package/modules/hall-ui/public/city.js +177 -0
  36. package/modules/hall-ui/public/city.states.json +30 -0
  37. package/modules/hall-ui/public/gate.js +11 -4
  38. package/modules/hall-ui/public/genesis-home.js +9 -10
  39. package/modules/hall-ui/public/profile.css +4 -0
  40. package/modules/hall-ui/public/profile.js +25 -1
  41. package/modules/hall-ui/public/settings-autobongos.js +19 -1
  42. package/modules/hall-ui/public/settings.css +26 -0
  43. package/modules/hall-ui/public/settings.html +8 -0
  44. package/modules/hall-ui/public/settings.js +43 -3
  45. package/modules/ideas/governor-docket.js +93 -0
  46. package/modules/ideas/routes/blockers.js +5 -0
  47. package/modules/lifecycle/governor-docket.js +192 -0
  48. package/modules/lifecycle/routes/gate-approvals.js +78 -33
  49. package/modules/lifecycle/routes/lifecycle.js +3 -0
  50. package/modules/onboarding/routes/access-requests.js +68 -27
  51. package/modules/platform-identity/craft-rollup.js +72 -0
  52. package/modules/platform-identity/migrations/platform_identity_029_activity_crafts.sql +23 -0
  53. package/modules/platform-identity/platform-identity.js +12 -6
  54. package/modules/platform-identity/routes/sso.js +4 -0
  55. package/modules/platform-identity/tests/platform-identity.mjs +1 -1
  56. package/modules/provisioning/core-upgrade.js +34 -7
  57. package/modules/provisioning/module.json +2 -1
  58. package/modules/provisioning/seams.js +2 -0
  59. package/modules/public-landing/public/assets/cosmos.css +6 -50
  60. package/modules/public-landing/public/projects.html +1170 -1659
  61. package/modules/public-landing/public/projects.probes.json +18 -18
  62. package/modules/public-landing/public/projects.states.json +24 -31
  63. package/modules/security/routes/reports.js +39 -0
  64. package/package-lock.json +2 -2
  65. package/package.json +1 -1
  66. package/release-notes.json +72 -0
  67. package/scripts/gds/autobongos-grade-cap.js +187 -0
  68. package/scripts/gds/autobongos-loop.js +4 -0
  69. package/scripts/gds/autobongos-run.js +154 -13
  70. package/scripts/gds/autobongos-verify.js +211 -14
  71. package/scripts/gds/provision-core-upgrade.js +14 -2
  72. package/scripts/gds/provision-repo.js +77 -13
  73. package/scripts/gds/ship-flow.js +5 -0
  74. package/scripts/gds/ship-preflight-steps.js +2 -1
  75. package/scripts/gds/ship.js +10 -0
  76. package/scripts/gds/update-sweep.js +24 -8
  77. package/scripts/gds/upgrade-outcome.js +1 -0
  78. package/src/bongos/auth-admission.js +95 -13
  79. package/src/bongos/db-kernel.js +1 -1
  80. package/src/bongos/module-rechecks.js +77 -0
  81. package/src/bongos/route-rank-check.js +3 -0
  82. package/src/bongos/routes/core-update.js +68 -2
  83. package/src/bongos/routes/modules.js +17 -0
  84. package/src/bongos/serve-internal.js +14 -0
  85. package/src/bongos/software-update.js +3 -2
  86. package/src/module-api.js +1 -1
  87. package/tests/activity_rollup_order_db.mjs +3 -1
  88. package/tests/activity_snapshots_db.mjs +2 -0
  89. package/tests/autobongos_grade_cap.mjs +125 -0
  90. package/tests/autobongos_loop.mjs +230 -1
  91. package/tests/autobongos_verify.mjs +239 -3
  92. package/tests/autonomy_config_idle.mjs +272 -0
  93. package/tests/core_update_banner.mjs +29 -0
  94. package/tests/core_upgrade_door.mjs +31 -1
  95. package/tests/core_upgrade_runner.mjs +20 -0
  96. package/tests/discord_craft_broadcast.mjs +162 -0
  97. package/tests/genesis_home.mjs +1 -1
  98. package/tests/government_routes.mjs +3 -1
  99. package/tests/governor_docket.mjs +552 -0
  100. package/tests/hall_audit.mjs +10 -1
  101. package/tests/hall_city.mjs +369 -0
  102. package/tests/hall_page_gate_map.mjs +5 -0
  103. package/tests/hub_craft_rollup.mjs +350 -0
  104. package/tests/onboard.mjs +10 -4
  105. package/tests/platform_boot.mjs +1 -1
  106. package/tests/profile_rollup_consent.mjs +2 -0
  107. package/tests/profile_route.mjs +16 -2
  108. package/tests/projects_hub.mjs +196 -89
  109. package/tests/projects_hub_app_status.mjs +3 -1
  110. package/tests/projects_hub_app_step.mjs +72 -48
  111. package/tests/projects_hub_dns_ready.mjs +10 -2
  112. package/tests/projects_hub_look.mjs +32 -22
  113. package/tests/projects_hub_module_picker.mjs +112 -62
  114. package/tests/projects_hub_pre_uat.mjs +48 -41
  115. package/tests/provision.mjs +78 -0
  116. package/tests/public_landing_projects.mjs +12 -6
  117. package/tests/settings_main_role.mjs +190 -0
  118. package/tests/software_update.mjs +56 -1
  119. package/tests/update_subscription_engine.mjs +55 -3
  120. package/tests/wizard_demo.mjs +37 -11
  121. package/tests/wizard_draft_resume.mjs +34 -22
  122. package/tests/wizard_front_door.mjs +173 -103
  123. package/tests/wizard_intent_resume.mjs +28 -25
  124. package/tests/wizard_physics.mjs +94 -76
  125. package/tests/wizard_physics_more.mjs +428 -277
  126. package/tests/wizard_six_screens.mjs +126 -0
@@ -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
+ };
@@ -0,0 +1,458 @@
1
+ 'use strict';
2
+
3
+ // modules/government/docket.js — the governor's docket: ONE read of every matter
4
+ // waiting on, or moving for, a governor, plus the six readings (task 1004523,
5
+ // goal 1000125, the Governor City).
6
+ //
7
+ // WHY. The Governor City (docs/design/mocks/governor-city/, Street.dc.html's
8
+ // matters() / status() / signsOf()) draws every part of a project's government
9
+ // as a building, with a pin over each open matter. Before this file the matters
10
+ // lived in a dozen places — the board, blockers, access requests, override
11
+ // requests, removal proposals, red-team reports, criteria awaiting UAT, the core
12
+ // update, CI gate approvals, the Autobongos fence, goal join requests — and the
13
+ // only cross-source summary was a browser card fetching five of them one by one.
14
+ // The screen tasks (1004524 home city, 1004525 street, 1004526 interiors, 1004527
15
+ // floors, 1004528 matter cards) all read THIS instead, so they agree on what is
16
+ // open, where it sits and whose move it is.
17
+ //
18
+ // HOW A SOURCE GETS HERE: THE `governor.docket` CONTRIBUTION POINT. A module may
19
+ // not require a sibling (ADR 0083), and the sources live in five modules and the
20
+ // core. So each owner contributes its own source, built on its OWN existing db
21
+ // read, with `api.contribute('governor.docket', { … })` from its route factory —
22
+ // and this file collects them. The contract (validated here, fail-closed):
23
+ //
24
+ // { source: 'blockers', // unique key (EXPECTED_SOURCES)
25
+ // building: 'planning', // one of docket-vocab BUILDING_KEYS
26
+ // gate: { permission: 'blocker.review' } // hidden unless the viewer holds it
27
+ // | { anyBuilder: true }, // the read scopes itself to the viewer
28
+ // read: async (ctx) => [matter, …] }
29
+ //
30
+ // { reading: 'flow', gate: …, read: async (ctx) => { figure, line, value, hot } }
31
+ //
32
+ // `ctx` is { builderId, rank, has(permission) } — `has` answers from the
33
+ // viewer's permission set resolved ONCE for this request, server-side and
34
+ // uncached (ADR 0016). A contribution with no recognisable gate is DROPPED, never
35
+ // run: an unscoped source would show every viewer everything.
36
+ //
37
+ // RANK-SCOPED SERVER-SIDE. The gate a source declares is the SAME atom its own
38
+ // list route requires, so the docket can never show a viewer a matter its owning
39
+ // route would refuse them. A gated-out source is reported as `not_yours` with no
40
+ // rows — the screen can say a building has matters you cannot see without
41
+ // leaking them.
42
+ //
43
+ // NOTHING IS INVENTED. A source the design names that has no data yet (grade
44
+ // appeals, votes on new standing rules, the treasury's spending matters) is
45
+ // reported `coming`; a source whose module is off is `off`; a read that throws or
46
+ // times out is `unavailable`. None of them ever contributes a sample row.
47
+
48
+ const api = require('../../src/module-api');
49
+ const vocab = require('./docket-vocab');
50
+ const board = require('./board');
51
+ const db = require('./db');
52
+ const { resolveBoardMembers } = require('./board-membership');
53
+ const resolver = require('./resolver');
54
+
55
+ const log = api.logger('government');
56
+
57
+ const CONTRIBUTION_POINT = 'governor.docket';
58
+
59
+ // Every source the reference design names, with the building it stands in. The
60
+ // docket reports a status for each, so a screen never has to guess whether an
61
+ // empty building is empty or unbuilt.
62
+ const EXPECTED_SOURCES = Object.freeze([
63
+ Object.freeze({ source: 'board', building: 'charter', label: 'board sittings' }),
64
+ Object.freeze({ source: 'blockers', building: 'planning', label: 'blockers' }),
65
+ Object.freeze({ source: 'access_requests', building: 'people', label: 'access requests' }),
66
+ Object.freeze({ source: 'goal_join_requests', building: 'people', label: 'goal join requests' }),
67
+ Object.freeze({ source: 'override_requests', building: 'release', label: 'override requests' }),
68
+ Object.freeze({ source: 'removal_proposals', building: 'disputes', label: 'removal proposals' }),
69
+ Object.freeze({ source: 'redteam_reports', building: 'disputes', label: 'red-team reports' }),
70
+ Object.freeze({ source: 'criteria_uat', building: 'review', label: 'criteria awaiting UAT' }),
71
+ Object.freeze({ source: 'core_update', building: 'release', label: 'core update' }),
72
+ Object.freeze({ source: 'gate_approvals', building: 'release', label: 'CI gate approvals' }),
73
+ Object.freeze({ source: 'autobongos_fence', building: 'automation', label: 'the Autobongos fence' }),
74
+ Object.freeze({ source: 'module_rechecks', building: 'release', label: 'module re-checks' }),
75
+ ]);
76
+
77
+ // Named by the design, not yet recorded anywhere. Each says why, in words.
78
+ const COMING_SOURCES = Object.freeze([
79
+ Object.freeze({ source: 'grade_appeals', building: 'disputes', label: 'grade appeals',
80
+ reason: 'There is no way to appeal a grade yet.' }),
81
+ Object.freeze({ source: 'standing_rules', building: 'rules', label: 'standing rules',
82
+ reason: 'A new standing rule has nowhere to be proposed yet. The one rule in force, idea routing, is counted in the rules-at-work reading.' }),
83
+ Object.freeze({ source: 'treasury_spending', building: 'treasury', label: 'spending matters',
84
+ reason: 'The project has no budget or spending votes recorded yet.' }),
85
+ ]);
86
+
87
+
88
+ // A source read that has not answered by now is reported unavailable rather than
89
+ // holding the whole city hostage (one source calls GitHub, one the npm registry).
90
+ const SOURCE_TIMEOUT_MS = 4000;
91
+
92
+ // ── pure helpers ──────────────────────────────────────────────────────────────
93
+
94
+ function iso(v) {
95
+ if (v == null || v === '') return null;
96
+ const d = v instanceof Date ? v : new Date(v);
97
+ return Number.isNaN(d.getTime()) ? null : d.toISOString();
98
+ }
99
+
100
+ function person(p) {
101
+ if (!p || typeof p !== 'object') return null;
102
+ const id = p.id == null ? null : String(p.id);
103
+ const login = p.login == null ? null : String(p.login);
104
+ const name = p.name == null ? null : String(p.name);
105
+ if (id == null && login == null && name == null) return null;
106
+ return { id, login, name };
107
+ }
108
+
109
+ function tally(t) {
110
+ if (!t || typeof t !== 'object') return null;
111
+ const n = (x) => (Number.isFinite(Number(x)) ? Number(x) : 0);
112
+ return {
113
+ for: n(t.for),
114
+ against: n(t.against),
115
+ open: n(t.open),
116
+ needs: t.needs == null ? null : String(t.needs),
117
+ };
118
+ }
119
+
120
+ // One matter as a contributor handed it → the docket's one shape. Pure. Unknown
121
+ // rule → null (the card says so), unknown state → 'moving' (never a false
122
+ // "needs you"), unknown building → the source's own building.
123
+ function normalizeMatter(raw, { source, building }) {
124
+ if (!raw || typeof raw !== 'object' || raw.id == null || !raw.title) return null;
125
+ const b = vocab.BUILDING_KEYS.includes(raw.building) ? raw.building : building;
126
+ return {
127
+ id: `${source}:${raw.id}`,
128
+ source,
129
+ building: b,
130
+ rule: vocab.RULE_KEYS.includes(raw.rule) ? raw.rule : null,
131
+ state: vocab.STATES.includes(raw.state) ? raw.state : 'moving',
132
+ title: String(raw.title),
133
+ short: raw.short ? String(raw.short) : null,
134
+ where: raw.where ? String(raw.where) : null,
135
+ opened_at: iso(raw.opened_at),
136
+ deadline: iso(raw.deadline),
137
+ proposer: person(raw.proposer),
138
+ tally: tally(raw.tally),
139
+ href: raw.href ? String(raw.href) : null,
140
+ ref: raw.ref && typeof raw.ref === 'object' ? { type: String(raw.ref.type), id: String(raw.ref.id) } : null,
141
+ };
142
+ }
143
+
144
+ // Does this viewer pass this gate? Fail-closed: anything unrecognised is false.
145
+ function gateAllows(gate, ctx) {
146
+ if (!gate || typeof gate !== 'object') return false;
147
+ if (gate.anyBuilder === true) return true;
148
+ if (typeof gate.permission === 'string' && gate.permission) return ctx.has(gate.permission);
149
+ return false;
150
+ }
151
+
152
+ function hasValidGate(gate) {
153
+ return !!gate && typeof gate === 'object'
154
+ && (gate.anyBuilder === true || (typeof gate.permission === 'string' && !!gate.permission));
155
+ }
156
+
157
+ // needs first, then by deadline (soonest first, none last), then oldest first.
158
+ const STATE_ORDER = { needs: 0, moving: 1, settled: 2 };
159
+ function compareMatters(a, b) {
160
+ if (STATE_ORDER[a.state] !== STATE_ORDER[b.state]) return STATE_ORDER[a.state] - STATE_ORDER[b.state];
161
+ const da = a.deadline ? Date.parse(a.deadline) : Infinity;
162
+ const dbb = b.deadline ? Date.parse(b.deadline) : Infinity;
163
+ if (da !== dbb) return da - dbb;
164
+ const oa = a.opened_at ? Date.parse(a.opened_at) : Infinity;
165
+ const ob = b.opened_at ? Date.parse(b.opened_at) : Infinity;
166
+ return oa - ob;
167
+ }
168
+
169
+ function countStates(matters) {
170
+ const c = { needs: 0, moving: 0, settled: 0 };
171
+ for (const m of matters) c[m.state] += 1;
172
+ return c;
173
+ }
174
+
175
+ // The home line under "start governing", in the design's words.
176
+ function homeLine(counts) {
177
+ if (!counts.needs && !counts.moving) return 'nothing waits on you.';
178
+ const parts = [];
179
+ if (counts.needs) parts.push(`${counts.needs} ${counts.needs === 1 ? 'matter waits' : 'matters wait'} on you`);
180
+ if (counts.moving) parts.push(`${counts.moving} moving`);
181
+ return parts.join(' · ');
182
+ }
183
+
184
+ // ── the board, read here (this module owns it) ───────────────────────────────
185
+
186
+ const PASS_RULE_NEEDS = Object.freeze({
187
+ first_ratifier: 'one yes decides',
188
+ consent: 'passes unless someone objects',
189
+ majority: 'needs more than half',
190
+ unanimous: 'needs everyone who votes',
191
+ });
192
+
193
+ // One open or recently closed sitting, for one viewer. Pure.
194
+ //
195
+ // "needs" is board.awaitsVoter, the SAME predicate pendingVotesFor (the /me
196
+ // summons) runs — open, not past its deadline, a member under the item's OWN
197
+ // snapshot, not already cast, not the subject's author (a genesis stage has no
198
+ // author to hold back) — so the city never promises a ballot the vote route then
199
+ // refuses, and never disagrees with the roster about it. One definition, held by
200
+ // tests/governor_docket.mjs driving both over the same matrix.
201
+ function boardMatter(item, { viewerId, members }) {
202
+ const viewer = String(viewerId);
203
+ const constitution = item.constitution || {};
204
+ const votes = item.votes || [];
205
+ const memberIds = new Set((members || []).map((m) => String(m.builder_id)));
206
+ const voted = votes.some((v) => String(v.voter_id) === viewer);
207
+ const authorId = item.subject_type === 'genesis_stage'
208
+ ? null
209
+ : (item.subject && item.subject.author_id != null ? String(item.subject.author_id) : null);
210
+ const isOpen = !item.closed_at;
211
+ let state;
212
+ if (!isOpen) state = 'settled';
213
+ else if (board.awaitsVoter({ builderId: viewer, authorId, alreadyVoted: voted, isMember: memberIds.has(viewer), expired: item.expired === true })) state = 'needs';
214
+ else if (voted || memberIds.has(viewer) || authorId === viewer) state = 'moving';
215
+ else state = 'settled';
216
+
217
+ const building = item.subject_type === 'full_idea' ? 'planning' : 'charter';
218
+ const title = item.subject_type === 'constitutional_amendment'
219
+ ? (item.subject && item.subject.rationale_md ? firstLine(item.subject.rationale_md) : 'A change to the charter')
220
+ : item.subject_type === 'genesis_stage'
221
+ ? `Close the founding stage: ${(item.subject && item.subject.label) || item.subject_id}`
222
+ : ((item.subject && item.subject.title) || `Full idea ${item.subject_id}`);
223
+ const where = item.subject_type === 'constitutional_amendment' ? 'charter · amendment'
224
+ : item.subject_type === 'genesis_stage' ? 'charter · founding'
225
+ : 'planning · idea to ratify';
226
+ const forN = votes.filter((v) => v.direction === 'yes').length;
227
+ const againstN = votes.filter((v) => v.direction === 'no').length;
228
+ const castIds = new Set(votes.map((v) => String(v.voter_id)));
229
+ const open = [...memberIds].filter((id) => !castIds.has(id)).length;
230
+ const proposerRow = authorId == null ? null
231
+ : (members || []).find((m) => String(m.builder_id) === authorId)
232
+ || votes.find((v) => String(v.voter_id) === authorId);
233
+ return {
234
+ id: item.id,
235
+ building,
236
+ rule: vocab.PASS_RULE_TO_RULE[constitution.pass_rule] || null,
237
+ state,
238
+ title,
239
+ short: item.subject_type === 'constitutional_amendment' ? 'amendment'
240
+ : item.subject_type === 'genesis_stage' ? 'founding stage' : 'idea to ratify',
241
+ where,
242
+ opened_at: item.opened_at,
243
+ deadline: item.closes_at,
244
+ proposer: authorId == null ? null : {
245
+ id: authorId,
246
+ login: proposerRow ? proposerRow.github_login : null,
247
+ name: proposerRow ? proposerRow.display_name : null,
248
+ },
249
+ tally: { for: forN, against: againstN, open: isOpen ? open : 0, needs: PASS_RULE_NEEDS[constitution.pass_rule] || null },
250
+ href: `/board-room?item=${encodeURIComponent(item.id)}`,
251
+ ref: { type: 'board_item', id: item.id },
252
+ };
253
+ }
254
+
255
+ function firstLine(md) {
256
+ const line = String(md).split(/\r?\n/).map((s) => s.replace(/^[#>*\-\s]+/, '').trim()).find(Boolean);
257
+ if (!line) return 'A change to the charter';
258
+ return line.length > 120 ? `${line.slice(0, 117)}…` : line;
259
+ }
260
+
261
+ // A closed sitting stays on the docket as settled for this long, so a pass is
262
+ // seen once before its pin drops (README: "Passed matters drop their pin").
263
+ const SETTLED_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
264
+
265
+ // The board source: open sittings + sittings closed in the last week. Members are
266
+ // resolved once per DISTINCT predicate, as pendingVotesFor does.
267
+ async function readBoard(ctx, { now = Date.now() } = {}) {
268
+ const view = await board.boardRoomView({ closedLimit: 20 });
269
+ const recent = (view.closed || []).filter((i) => i.closed_at && now - Date.parse(i.closed_at) <= SETTLED_WINDOW_MS);
270
+ const items = [...(view.open || []), ...recent];
271
+ const membersBy = new Map();
272
+ const out = [];
273
+ for (const item of items) {
274
+ const key = String((item.constitution || {}).membership || '');
275
+ if (!membersBy.has(key)) membersBy.set(key, await resolveBoardMembers(item.constitution || {}).catch(() => []));
276
+ out.push(boardMatter(item, { viewerId: ctx.builderId, members: membersBy.get(key) }));
277
+ }
278
+ return out;
279
+ }
280
+
281
+ const BOARD_SOURCE = Object.freeze({
282
+ source: 'board',
283
+ building: 'charter',
284
+ gate: Object.freeze({ permission: 'board.vote.cast' }),
285
+ read: readBoard,
286
+ });
287
+
288
+ // ── readings this module can answer ──────────────────────────────────────────
289
+
290
+ const OWN_READINGS = Object.freeze([
291
+ Object.freeze({
292
+ reading: 'people',
293
+ gate: Object.freeze({ anyBuilder: true }),
294
+ read: async () => {
295
+ const n = await db.countActiveBuilders();
296
+ return { value: n, figure: `${n} ${n === 1 ? 'builder' : 'builders'}`, line: 'active people in the project', hot: false };
297
+ },
298
+ }),
299
+ ]);
300
+
301
+ // Open disputes, from the matters the viewer can see at Disputes. Pure.
302
+ function fairnessReading(matters, sourceStatus) {
303
+ const seen = sourceStatus.filter((s) => s.building === 'disputes' && s.status === 'live');
304
+ if (!seen.length) return null;
305
+ const open = matters.filter((m) => m.building === 'disputes' && m.state !== 'settled');
306
+ const n = open.length;
307
+ const oldest = open.map((m) => m.opened_at).filter(Boolean).sort()[0] || null;
308
+ return {
309
+ value: n,
310
+ figure: `${n} open`,
311
+ line: n ? `${n === 1 ? 'one dispute' : `${n} disputes`} waiting · oldest since ${oldest ? oldest.slice(0, 10) : 'unknown'}` : 'no open disputes',
312
+ hot: open.some((m) => m.state === 'needs'),
313
+ };
314
+ }
315
+
316
+ function withTimeout(promise, ms) {
317
+ let timer;
318
+ return Promise.race([
319
+ promise,
320
+ new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('timeout')), ms); if (timer.unref) timer.unref(); }),
321
+ ]).finally(() => clearTimeout(timer));
322
+ }
323
+
324
+ // The registered contributions, last registration per key winning (a route
325
+ // factory re-runs whenever a test rebuilds the router; the newest closure is the
326
+ // live one).
327
+ function registeredContributions() {
328
+ const sources = new Map();
329
+ const readings = new Map();
330
+ for (const c of api.contributions(CONTRIBUTION_POINT)) {
331
+ if (!c || typeof c !== 'object' || typeof c.read !== 'function' || !hasValidGate(c.gate)) continue;
332
+ if (typeof c.source === 'string' && c.source) sources.set(c.source, c);
333
+ else if (typeof c.reading === 'string' && c.reading) readings.set(c.reading, c);
334
+ }
335
+ return { sources, readings };
336
+ }
337
+
338
+ // ── the collector ────────────────────────────────────────────────────────────
339
+
340
+ // Build the docket for one viewer. `permissions` is the viewer's resolved set;
341
+ // `contributions` defaults to what is registered on the seam (a test passes its
342
+ // own). Never throws for a source: each is isolated and timed.
343
+ async function buildDocket({ viewer, permissions, contributions = registeredContributions(), now = Date.now(), timeoutMs = SOURCE_TIMEOUT_MS } = {}) {
344
+ const held = new Set(permissions || []);
345
+ const ctx = Object.freeze({
346
+ builderId: String(viewer.id),
347
+ rank: viewer.rank || null,
348
+ has: (p) => held.has(p),
349
+ });
350
+ const sources = new Map(contributions.sources);
351
+ sources.set('board', BOARD_SOURCE); // the board is read here, never contributed
352
+ const comingKeys = new Set(COMING_SOURCES.map((c) => c.source));
353
+
354
+ const matters = [];
355
+ const sourceStatus = [];
356
+ const declared = [...EXPECTED_SOURCES, ...COMING_SOURCES.filter((c) => !EXPECTED_SOURCES.some((e) => e.source === c.source))];
357
+ await Promise.all(declared.map(async (decl) => {
358
+ const c = sources.get(decl.source);
359
+ const row = { source: decl.source, building: decl.building, label: decl.label, status: null, count: 0, reason: null };
360
+ sourceStatus.push(row);
361
+ if (!c) {
362
+ if (comingKeys.has(decl.source)) {
363
+ row.status = 'coming';
364
+ row.reason = COMING_SOURCES.find((x) => x.source === decl.source).reason;
365
+ } else {
366
+ row.status = 'off';
367
+ row.reason = 'The module that holds this is not enabled here.';
368
+ }
369
+ return;
370
+ }
371
+ if (!gateAllows(c.gate, ctx)) { row.status = 'not_yours'; return; }
372
+ try {
373
+ const raw = await withTimeout(Promise.resolve(c.read(ctx, { now })), timeoutMs);
374
+ const list = (Array.isArray(raw) ? raw : []).map((m) => normalizeMatter(m, { source: decl.source, building: c.building || decl.building })).filter(Boolean);
375
+ row.status = 'live';
376
+ row.count = list.length;
377
+ matters.push(...list);
378
+ } catch (err) {
379
+ row.status = 'unavailable';
380
+ row.reason = err && err.message === 'timeout' ? 'It did not answer in time.' : 'It could not be read just now.';
381
+ log.warn({ source: decl.source, err: err && err.message }, 'governor docket source failed');
382
+ }
383
+ }));
384
+ // Stable order: the declared order, not the order the reads finished in.
385
+ const order = declared.map((d) => d.source);
386
+ sourceStatus.sort((a, b) => order.indexOf(a.source) - order.indexOf(b.source));
387
+ matters.sort(compareMatters);
388
+
389
+ // Readings: contributed + this module's own + the ones derived from matters.
390
+ const readingImpls = new Map(contributions.readings);
391
+ for (const r of OWN_READINGS) readingImpls.set(r.reading, r);
392
+ const readings = await Promise.all(vocab.READINGS.map(async (decl) => {
393
+ const base = { key: decl.key, label: decl.label, building: decl.building, status: null, value: null, figure: null, line: null, hot: false, reason: null };
394
+ if (decl.key === 'fairness') {
395
+ const f = fairnessReading(matters, sourceStatus);
396
+ return f ? { ...base, status: 'live', ...f } : { ...base, status: 'not_yours' };
397
+ }
398
+ const impl = readingImpls.get(decl.key);
399
+ if (!impl) return { ...base, status: 'off', reason: 'The module that holds this is not enabled here.' };
400
+ if (!gateAllows(impl.gate, ctx)) return { ...base, status: 'not_yours' };
401
+ try {
402
+ const r = await withTimeout(Promise.resolve(impl.read(ctx, { now, matters })), timeoutMs);
403
+ if (!r) return { ...base, status: 'unavailable', reason: 'It could not be read just now.' };
404
+ return {
405
+ ...base,
406
+ status: 'live',
407
+ value: r.value ?? null,
408
+ figure: r.figure == null ? null : String(r.figure),
409
+ line: r.line == null ? null : String(r.line),
410
+ hot: r.hot === true,
411
+ };
412
+ } catch (err) {
413
+ log.warn({ reading: decl.key, err: err && err.message }, 'governor docket reading failed');
414
+ return { ...base, status: 'unavailable', reason: 'It could not be read just now.' };
415
+ }
416
+ }));
417
+
418
+ const counts = countStates(matters);
419
+ const buildings = vocab.BUILDINGS.map((b) => {
420
+ const here = matters.filter((m) => m.building === b.key);
421
+ return { key: b.key, name: b.name, group: b.group, counts: countStates(here), matter_ids: here.map((m) => m.id) };
422
+ });
423
+ return {
424
+ viewer: { id: ctx.builderId },
425
+ generated_at: new Date(now).toISOString(),
426
+ counts,
427
+ line: homeLine(counts),
428
+ matters,
429
+ buildings,
430
+ sources: sourceStatus,
431
+ readings,
432
+ vocabulary: { buildings: vocab.BUILDINGS, rules: vocab.RULES, states: vocab.STATES },
433
+ };
434
+ }
435
+
436
+ // The route's entry point: resolve the viewer's permissions server-side (fresh,
437
+ // uncached — ADR 0016), then build.
438
+ async function docketFor(builder) {
439
+ const permissions = await resolver.resolveBuilderPermissions(builder.id);
440
+ return buildDocket({ viewer: builder, permissions });
441
+ }
442
+
443
+ module.exports = {
444
+ CONTRIBUTION_POINT,
445
+ EXPECTED_SOURCES,
446
+ COMING_SOURCES,
447
+ SOURCE_TIMEOUT_MS,
448
+ SETTLED_WINDOW_MS,
449
+ normalizeMatter,
450
+ gateAllows,
451
+ compareMatters,
452
+ homeLine,
453
+ boardMatter,
454
+ fairnessReading,
455
+ registeredContributions,
456
+ buildDocket,
457
+ docketFor,
458
+ };
@@ -26,6 +26,7 @@ const sessionScopes = require('../session-scopes');
26
26
  const boardMembership = require('../board-membership');
27
27
  const board = require('../board');
28
28
  const recommendations = require('../recommendations');
29
+ const docket = require('../docket');
29
30
  // task 1003208: structured logging (pino via the doorway) — was console.*.
30
31
  const log = api.logger('government');
31
32
 
@@ -531,6 +532,39 @@ module.exports = function buildGovernmentRouter() {
531
532
  }
532
533
  });
533
534
 
535
+ // rank: perm board.vote.cast — the governor's DOCKET (task 1004523, goal
536
+ // 1000125): ONE read of every matter waiting on, or moving for, a governor,
537
+ // which the Governor City screens all draw from. Same coarse atom as the room
538
+ // (xenos floor: whoever the constitution may enfranchise may open the city);
539
+ // the real scoping is PER SOURCE, inside docket.js — each source is shown only
540
+ // to a viewer holding the same atom its own list route requires, and a hidden
541
+ // source reports `not_yours` with no rows (ADR 0016, server-side, uncached).
542
+ //
543
+ // Response (docket.buildDocket):
544
+ // { viewer: { id }, generated_at, line, // "3 matters wait on you · 2 moving"
545
+ // counts: { needs, moving, settled },
546
+ // matters: [{ id: '<source>:<id>', source, building, rule, state,
547
+ // title, short, where, opened_at, deadline,
548
+ // proposer: { id, login, name } | null,
549
+ // tally: { for, against, open, needs } | null, href, ref }],
550
+ // buildings: [{ key, name, group, counts, matter_ids }], // street order
551
+ // sources: [{ source, building, label, status, count, reason }],
552
+ // // status: live | not_yours | coming | off | unavailable
553
+ // readings: [{ key, label, building, status, value, figure, line, hot, reason }],
554
+ // vocabulary: { buildings, rules, states } }
555
+ // `building` ∈ charter rules disputes people planning automation review release
556
+ // treasury; `rule` ∈ decide first consent majority unanimous signoff rule (or
557
+ // null); `state` ∈ needs moving settled. Nothing is ever sample data: a source
558
+ // with nothing recorded yet is `coming`, never a placeholder row.
559
+ router.get('/government/docket', api.requireBuilder, api.requirePermission('board.vote.cast'), async (req, res) => {
560
+ try {
561
+ res.json(await docket.docketFor(req.builder));
562
+ } catch (err) {
563
+ log.error('[gds] GET /government/docket', err);
564
+ res.fail('docket_read_failed', { status: 500, message: 'internal error' });
565
+ }
566
+ });
567
+
534
568
  // rank: perm board.item.open — put an AMENDMENT to the board (R14 of goal
535
569
  // 1000069, ADR 0175 §7). The board's other subject never comes through here:
536
570
  // a Full Idea's window opens AUTOMATICALLY on grade pass (R07), so requesting