@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,192 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/governor-docket.js — lifecycle's sources for the governor's
4
+ // docket (task 1004523, goal 1000125; the collector is
5
+ // modules/government/docket.js).
6
+ //
7
+ // WHY. The Governor City reads one feed of every matter awaiting a governor. Four
8
+ // of those matters live in this module — override requests, removal proposals,
9
+ // criteria awaiting UAT and goal join requests — plus two of the six readings
10
+ // (flow: finished work that is stuck, and promises: done-when criteria kept of
11
+ // total). A module may not require a sibling (ADR 0083), so lifecycle contributes
12
+ // them to the `governor.docket` contribution point instead of government reaching
13
+ // in. Each source reads through the SAME db function its own list route serves,
14
+ // and declares the SAME atom that route requires, so the city can never show a
15
+ // viewer a matter the route would refuse them.
16
+ //
17
+ // Pure mappers (exported, unit-tested) + one registration function called from
18
+ // the lifecycle route factory, the boot hook that runs live and in tests.
19
+
20
+ const api = require('../../src/module-api');
21
+ const db = require('./db');
22
+ const doneWhen = require('./done-when');
23
+ const removalProposals = require('./removal-proposals');
24
+
25
+ const POINT = 'governor.docket';
26
+ const OVERRIDE_LIMIT = 50;
27
+ const STUCK_LIMIT = 100;
28
+ const DAY_MS = 24 * 60 * 60 * 1000;
29
+
30
+ function overrideMatter(r, mayDecide) {
31
+ return {
32
+ id: r.id,
33
+ rule: 'decide',
34
+ state: mayDecide ? 'needs' : 'moving',
35
+ title: r.task_title ? `Override: ${r.task_title}` : `Override for task ${r.task_id}`,
36
+ short: 'override',
37
+ where: 'release · past a failed check or grade',
38
+ opened_at: r.created_at,
39
+ deadline: null,
40
+ proposer: { id: r.requested_by_builder_id, login: r.requester_login, name: r.requester_display_name },
41
+ tally: null,
42
+ href: `/task/${encodeURIComponent(r.task_id)}`,
43
+ ref: { type: 'override_request', id: r.id },
44
+ };
45
+ }
46
+
47
+ function removalMatter(r) {
48
+ return {
49
+ id: r.proposal_task_id,
50
+ rule: 'decide',
51
+ state: 'needs',
52
+ title: r.title,
53
+ short: 'removal',
54
+ where: r.goal_title ? `disputes · removal · ${r.goal_title}` : 'disputes · removal',
55
+ opened_at: r.filed_at,
56
+ deadline: null,
57
+ proposer: null,
58
+ tally: null,
59
+ href: `/task/${encodeURIComponent(r.proposal_task_id)}`,
60
+ ref: { type: 'task', id: r.proposal_task_id },
61
+ };
62
+ }
63
+
64
+ function uatMatter(c) {
65
+ return {
66
+ id: c.id,
67
+ rule: 'signoff',
68
+ state: 'needs',
69
+ title: c.criterion_md,
70
+ short: 'waiting for a tester',
71
+ where: c.goal_title ? `review · ${c.goal_title}` : 'review · a promise to sign off',
72
+ opened_at: null,
73
+ deadline: null,
74
+ proposer: null,
75
+ tally: { for: c.shipped_total, against: 0, open: 0, needs: 'one sign-off by someone who did not build it' },
76
+ href: c.goal_id ? `/goals#/goal/${encodeURIComponent(c.goal_id)}` : '/goals',
77
+ ref: { type: 'criterion', id: c.id },
78
+ };
79
+ }
80
+
81
+ function joinRequestMatter(r, mine) {
82
+ return {
83
+ id: r.id,
84
+ rule: 'decide',
85
+ state: mine ? 'moving' : 'needs',
86
+ title: mine
87
+ ? `Your request to join ${r.goal_title || `goal ${r.goal_id}`}`
88
+ : `${r.display_name || r.github_login || 'Someone'} asks to join ${r.goal_title || `goal ${r.goal_id}`}`,
89
+ short: 'join request',
90
+ where: 'people · goal teams',
91
+ opened_at: r.created_at,
92
+ deadline: null,
93
+ proposer: mine ? null : { id: r.builder_id, login: r.github_login, name: r.display_name },
94
+ tally: null,
95
+ href: r.goal_id ? `/goals#/goal/${encodeURIComponent(r.goal_id)}` : '/goals',
96
+ ref: { type: 'goal_membership_request', id: r.id },
97
+ };
98
+ }
99
+
100
+ // Flow: finished work that has not moved on. `completed` waits for its grade or
101
+ // review; `confirmed` waits to land. Pure over the two task lists.
102
+ function flowReading({ completed = [], confirmed = [] }, now = Date.now()) {
103
+ const stuck = [...completed, ...confirmed];
104
+ const n = stuck.length;
105
+ if (!n) return { value: 0, figure: 'all moving', line: 'no finished work is waiting', hot: false };
106
+ const ages = stuck.map((t) => now - Date.parse(t.updated_at || t.created_at)).filter(Number.isFinite);
107
+ const longest = ages.length ? Math.max(...ages) : 0;
108
+ const days = Math.floor(longest / DAY_MS);
109
+ const where = completed.length >= confirmed.length ? 'waiting for review' : 'waiting to go live';
110
+ return {
111
+ value: n,
112
+ figure: `${n} stuck`,
113
+ line: `longest wait ${days} ${days === 1 ? 'day' : 'days'}, ${where}`,
114
+ hot: true,
115
+ };
116
+ }
117
+
118
+ // Promises: the building version's done-when criteria, kept of total. Pure.
119
+ function promisesReading(criteria) {
120
+ const total = criteria.length;
121
+ if (!total) return { value: null, figure: 'no promises yet', line: 'the version building now has no done-when criteria', hot: false };
122
+ const kept = criteria.filter((c) => c.satisfied === true).length;
123
+ return { value: { kept, total }, figure: `${kept} of ${total} kept`, line: 'what the goals say they deliver', hot: false };
124
+ }
125
+
126
+ function contributeLifecycleToDocket() {
127
+ api.contribute(POINT, {
128
+ source: 'override_requests',
129
+ building: 'release',
130
+ gate: { permission: 'override_request.decide' },
131
+ read: async (ctx) => (await db.listOverrideRequests({ status: 'open', limit: OVERRIDE_LIMIT }))
132
+ .map((r) => overrideMatter(r, ctx.has('override_request.decide'))),
133
+ });
134
+ api.contribute(POINT, {
135
+ source: 'removal_proposals',
136
+ building: 'disputes',
137
+ gate: { permission: 'task.promote' },
138
+ read: async () => (await removalProposals.listPending({ limit: removalProposals.MAX_PROPOSAL_ROWS })).map(removalMatter),
139
+ });
140
+ api.contribute(POINT, {
141
+ source: 'criteria_uat',
142
+ building: 'review',
143
+ gate: { permission: 'criterion.review' },
144
+ read: async () => (await doneWhen.listPendingReviewCriteria({})).filter((c) => c.awaiting_uat === true).map(uatMatter),
145
+ });
146
+ // Own-scoped by its read, like GET /goals/inbox: a goal's lead sees the requests
147
+ // to join their goals, and everyone sees their own pending ones. An Archon is
148
+ // deliberately NOT shown every goal's requests (listInboxFor's rule).
149
+ api.contribute(POINT, {
150
+ source: 'goal_join_requests',
151
+ building: 'people',
152
+ gate: { anyBuilder: true },
153
+ read: async (ctx) => {
154
+ const inbox = await db.listInboxFor(ctx.builderId);
155
+ return [
156
+ ...(inbox.joinRequests || []).map((r) => joinRequestMatter(r, false)),
157
+ ...(inbox.myPendingRequests || []).map((r) => joinRequestMatter(r, true)),
158
+ ];
159
+ },
160
+ });
161
+ // Readings. Flow reads the task board, which any builder may read (GET /tasks).
162
+ api.contribute(POINT, {
163
+ reading: 'flow',
164
+ gate: { anyBuilder: true },
165
+ read: async (_ctx, { now } = {}) => {
166
+ const [completed, confirmed] = await Promise.all([
167
+ db.listTasks({ status: 'completed', limit: STUCK_LIMIT }),
168
+ db.listTasks({ status: 'confirmed', limit: STUCK_LIMIT }),
169
+ ]);
170
+ return flowReading({ completed, confirmed }, now);
171
+ },
172
+ });
173
+ api.contribute(POINT, {
174
+ reading: 'promises',
175
+ gate: { anyBuilder: true },
176
+ read: async () => {
177
+ const versionId = await db.currentBuildingVersionId();
178
+ if (!versionId) return { value: null, figure: 'no version building', line: 'nothing is promised right now', hot: false };
179
+ return promisesReading(await doneWhen.listCriteria(versionId));
180
+ },
181
+ });
182
+ }
183
+
184
+ module.exports = {
185
+ contributeLifecycleToDocket,
186
+ overrideMatter,
187
+ removalMatter,
188
+ uatMatter,
189
+ joinRequestMatter,
190
+ flowReading,
191
+ promisesReading,
192
+ };
@@ -65,7 +65,84 @@ function summarizeGate(files) {
65
65
  return { verdict: r.verdict, reasons, surfaces: r.surfaces || [] };
66
66
  }
67
67
 
68
+ // The awaiting list itself: open, non-draft PRs whose gate-review check is
69
+ // COMPLETED and red, each with its verdict and reasons. Shared by GET
70
+ // /gate-approvals and the governor's docket (task 1004523), so the two can never
71
+ // disagree about what is waiting. `withFiles: false` skips the per-PR file read
72
+ // (the docket needs only the count and the titles, not the per-file reasons).
73
+ async function listAwaitingGateApprovals({ withFiles = true } = {}) {
74
+ const prs = await githubPush.listOpenPullRequests({});
75
+ const awaiting = [];
76
+ let checking = 0;
77
+ for (const pr of prs) {
78
+ if (pr.is_draft) continue;
79
+ const gate = findCheck(pr, 'gate-review');
80
+ if (!gate) continue; // not a gate-reviewed PR (or it hasn't run yet)
81
+ if (String(gate.status || '').toUpperCase() !== 'COMPLETED') { checking += 1; continue; }
82
+ if (!isFailure(gate)) continue; // green/neutral → not awaiting
83
+ // gate-review is red → blocked awaiting trust/owner approval. Render WHY.
84
+ let summary = { verdict: 'unknown', reasons: [], surfaces: [], files: [] };
85
+ if (withFiles) {
86
+ try {
87
+ const files = await githubPush.getPullRequestFiles({ number: pr.number });
88
+ summary = summarizeGate(files);
89
+ summary.files = files.map((f) => ({ path: f.path, status: f.status }));
90
+ } catch (_) {
91
+ summary.files = [];
92
+ }
93
+ }
94
+ awaiting.push({
95
+ number: pr.number,
96
+ title: pr.title,
97
+ url: pr.url,
98
+ task: taskIdFromPr(pr),
99
+ author: pr.author,
100
+ head_sha: pr.head_sha,
101
+ verdict: summary.verdict,
102
+ reasons: summary.reasons,
103
+ files: summary.files,
104
+ author_trusted: isSuccess(findCheck(pr, 'gate-author-trust')),
105
+ already_approved: isSuccess(findCheck(pr, githubPush.GATE_OWNER_APPROVED_CONTEXT)),
106
+ gate_review_url: gate.url || null,
107
+ });
108
+ }
109
+ return { prs, awaiting, checking };
110
+ }
111
+
112
+ // The governor's docket (task 1004523): a PR held at the gate stands at Release,
113
+ // shown only to a viewer holding `gate_approval.read` (this list route's own atom)
114
+ // and needing only a viewer who may clear it (`gate_approval.decide`). An
115
+ // unconfigured server credential is a read error, so the docket reports the
116
+ // source `unavailable` rather than an empty, falsely calm Release.
117
+ function contributeGateApprovalsToDocket() {
118
+ api.contribute('governor.docket', {
119
+ source: 'gate_approvals',
120
+ building: 'release',
121
+ gate: { permission: 'gate_approval.read' },
122
+ read: async (ctx) => {
123
+ if (!githubPush.isConfigured()) throw new Error('publish_unconfigured');
124
+ const { awaiting } = await listAwaitingGateApprovals({ withFiles: false });
125
+ const mayDecide = ctx.has('gate_approval.decide');
126
+ return awaiting.filter((pr) => !pr.already_approved).map((pr) => ({
127
+ id: pr.number,
128
+ rule: 'decide',
129
+ state: mayDecide ? 'needs' : 'moving',
130
+ title: pr.title || `Pull request ${pr.number}`,
131
+ short: 'held at the gate',
132
+ where: 'release · held at the gate',
133
+ opened_at: null,
134
+ deadline: null,
135
+ proposer: pr.author ? { login: pr.author } : null,
136
+ tally: null,
137
+ href: '/gate',
138
+ ref: { type: 'pull_request', id: pr.number },
139
+ }));
140
+ },
141
+ });
142
+ }
143
+
68
144
  module.exports = function buildGateApprovalsRouter() {
145
+ contributeGateApprovalsToDocket();
69
146
  const router = express.Router();
70
147
 
71
148
  // GET /gate-approvals — open PRs awaiting owner approval (gate-review red), with the
@@ -81,39 +158,7 @@ module.exports = function buildGateApprovalsRouter() {
81
158
  return res.fail('publish_unconfigured', { status: 503, message: 'No server GitHub credential configured — cannot read pull requests.' });
82
159
  }
83
160
  try {
84
- const prs = await githubPush.listOpenPullRequests({});
85
- const awaiting = [];
86
- let checking = 0;
87
- for (const pr of prs) {
88
- if (pr.is_draft) continue;
89
- const gate = findCheck(pr, 'gate-review');
90
- if (!gate) continue; // not a gate-reviewed PR (or it hasn't run yet)
91
- if (String(gate.status || '').toUpperCase() !== 'COMPLETED') { checking += 1; continue; }
92
- if (!isFailure(gate)) continue; // green/neutral → not awaiting
93
- // gate-review is red → blocked awaiting trust/owner approval. Render WHY.
94
- let summary = { verdict: 'unknown', reasons: [], surfaces: [] };
95
- try {
96
- const files = await githubPush.getPullRequestFiles({ number: pr.number });
97
- summary = summarizeGate(files);
98
- summary.files = files.map((f) => ({ path: f.path, status: f.status }));
99
- } catch (_) {
100
- summary.files = [];
101
- }
102
- awaiting.push({
103
- number: pr.number,
104
- title: pr.title,
105
- url: pr.url,
106
- task: taskIdFromPr(pr),
107
- author: pr.author,
108
- head_sha: pr.head_sha,
109
- verdict: summary.verdict,
110
- reasons: summary.reasons,
111
- files: summary.files,
112
- author_trusted: isSuccess(findCheck(pr, 'gate-author-trust')),
113
- already_approved: isSuccess(findCheck(pr, githubPush.GATE_OWNER_APPROVED_CONTEXT)),
114
- gate_review_url: gate.url || null,
115
- });
116
- }
161
+ const { prs, awaiting, checking } = await listAwaitingGateApprovals();
117
162
  // The PR check rollup is unreadable when the server credential lacks `Checks: read`
118
163
  // (operator blocker): listOpenPullRequests then returns partialErrors and every PR's
119
164
  // `checks` is empty, so the awaiting list is silently empty. Surface that so the hall
@@ -31,6 +31,8 @@ const cascade = require('../cascade');
31
31
  const removalDerivation = require('../removal-derivation');
32
32
  const removalTargets = require('../removal-targets');
33
33
  const shipFiles = require('../ship-files');
34
+ // task 1004523: lifecycle's sources + readings for the governor's docket.
35
+ const governorDocket = require('../governor-docket');
34
36
 
35
37
  // task 1003549 — the cross-role cascade listener. `task.shipped` already exists
36
38
  // as a kernel event (emitted post-commit by the publish reconciler and the ship
@@ -136,6 +138,7 @@ function registerSeams() {
136
138
 
137
139
  module.exports = function buildLifecycleRouter() {
138
140
  registerSeams();
141
+ governorDocket.contributeLifecycleToDocket();
139
142
  // No HTTP surface — the lifecycle capability is a pure kernel port. The empty
140
143
  // router is only the loader's contributes.routes mount vehicle; returning a
141
144
  // valid express.Router() keeps mountModuleRoutes' `router.use(sub)` happy
@@ -187,7 +187,72 @@ async function clearHubNoticeIfUninvited(login) {
187
187
  }
188
188
  }
189
189
 
190
+ // The access-request queue at one status, shaped as GET /access-requests serves
191
+ // it (before the hub profiles are attached). One read, two callers: the route
192
+ // and the governor's docket (task 1004523), so the two cannot disagree about
193
+ // who is waiting to join.
194
+ async function listAccessRequests(status) {
195
+ // `kind` + `invited_by_login` let the invited history tell an invite from
196
+ // an approved application (ADR 0335 D2) — descriptive, like the vouch.
197
+ // `expired` + `expires_at` mark an invite whose 30 days are up (task 1002969):
198
+ // still 'invited' in storage, but its door no longer opens.
199
+ const { rows } = await pool.query(
200
+ `SELECT ar.id, ar.github_login, ar.display_name, ar.note, ar.status,
201
+ ar.created_at, ar.resolved_at, ar.kind,
202
+ (ar.status = 'invited' AND ${inviteExpiredSql('ar')}) AS expired,
203
+ ${inviteExpiresAtSql('ar')} AS expires_at,
204
+ ar.applicant_github_id, ar.applicant_handle, ar.vouched_at,
205
+ (ar.applicant_github_id IS NOT NULL) AS vouched,
206
+ rb.github_login AS resolved_by_login,
207
+ ib.github_login AS invited_by_login
208
+ FROM access_requests ar
209
+ LEFT JOIN builders rb ON rb.id = ar.resolved_by_builder_id
210
+ LEFT JOIN builders ib ON ib.id = ar.created_by
211
+ WHERE ar.status = $1
212
+ ORDER BY ar.created_at DESC
213
+ LIMIT 200`,
214
+ [status]
215
+ );
216
+ // On an invite row the stored note is the INVITER's words, but this read's
217
+ // `note` has always meant the applicant's (the hall quotes it as theirs),
218
+ // so an invite's note travels as `invite_note` instead.
219
+ return rows.map(({ note, ...r }) => (r.kind === 'invite'
220
+ ? { ...r, note: null, invite_note: note }
221
+ : { ...r, note, invite_note: null }));
222
+ }
223
+
224
+ // One pending request as a docket matter. Pure; exported for tests.
225
+ function accessRequestDocketMatter(r) {
226
+ return {
227
+ id: r.id,
228
+ rule: 'decide',
229
+ state: 'needs',
230
+ title: `Admit ${r.display_name || r.github_login} as a builder`,
231
+ short: `admit ${r.github_login}`,
232
+ where: r.vouched ? 'people · waiting to join · vouched' : 'people · waiting to join',
233
+ opened_at: r.created_at,
234
+ deadline: null,
235
+ proposer: { login: r.github_login, name: r.display_name },
236
+ tally: null,
237
+ href: '/watch',
238
+ ref: { type: 'access_request', id: r.id },
239
+ };
240
+ }
241
+
242
+ // The governor's docket: a pending request to join stands at People, shown only
243
+ // to a viewer holding `access_request.review` (this queue's own atom, because it
244
+ // exposes would-be builders' usernames) and needing that same viewer's act.
245
+ function contributeAccessRequestsToDocket() {
246
+ api.contribute('governor.docket', {
247
+ source: 'access_requests',
248
+ building: 'people',
249
+ gate: { permission: 'access_request.review' },
250
+ read: async () => (await listAccessRequests('pending')).map(accessRequestDocketMatter),
251
+ });
252
+ }
253
+
190
254
  module.exports = function buildAccessRequestsRouter() {
255
+ contributeAccessRequestsToDocket();
191
256
  const router = express.Router();
192
257
 
193
258
  // POST /access-requests — PUBLIC. A would-be builder asks to be admitted.
@@ -339,33 +404,7 @@ module.exports = function buildAccessRequestsRouter() {
339
404
  return res.fail('bad_status', 400);
340
405
  }
341
406
  try {
342
- // `kind` + `invited_by_login` let the invited history tell an invite from
343
- // an approved application (ADR 0335 D2) — descriptive, like the vouch.
344
- // `expired` + `expires_at` mark an invite whose 30 days are up (task 1002969):
345
- // still 'invited' in storage, but its door no longer opens.
346
- const { rows } = await pool.query(
347
- `SELECT ar.id, ar.github_login, ar.display_name, ar.note, ar.status,
348
- ar.created_at, ar.resolved_at, ar.kind,
349
- (ar.status = 'invited' AND ${inviteExpiredSql('ar')}) AS expired,
350
- ${inviteExpiresAtSql('ar')} AS expires_at,
351
- ar.applicant_github_id, ar.applicant_handle, ar.vouched_at,
352
- (ar.applicant_github_id IS NOT NULL) AS vouched,
353
- rb.github_login AS resolved_by_login,
354
- ib.github_login AS invited_by_login
355
- FROM access_requests ar
356
- LEFT JOIN builders rb ON rb.id = ar.resolved_by_builder_id
357
- LEFT JOIN builders ib ON ib.id = ar.created_by
358
- WHERE ar.status = $1
359
- ORDER BY ar.created_at DESC
360
- LIMIT 200`,
361
- [status]
362
- );
363
- // On an invite row the stored note is the INVITER's words, but this read's
364
- // `note` has always meant the applicant's (the hall quotes it as theirs),
365
- // so an invite's note travels as `invite_note` instead.
366
- const shaped = rows.map(({ note, ...r }) => (r.kind === 'invite'
367
- ? { ...r, note: null, invite_note: note }
368
- : { ...r, note, invite_note: null }));
407
+ const shaped = await listAccessRequests(status);
369
408
  // C4's other half (privacy spec D7, task 1002972): the applicant's own
370
409
  // profile, as their OWN privacy rules would show it, resolved from the hub
371
410
  // at render. See attachApplicantProfiles — it is keyed on the vouch, never
@@ -610,3 +649,5 @@ module.exports = function buildAccessRequestsRouter() {
610
649
 
611
650
  return router;
612
651
  };
652
+
653
+ module.exports.accessRequestDocketMatter = accessRequestDocketMatter;
@@ -0,0 +1,72 @@
1
+ // modules/platform-identity/craft-rollup.js — each craft by its own work, on the
2
+ // hub (task 1004440 / WA6.RP10, goal 1000095, criterion
3
+ // wa6-roles-read-true-in-public).
4
+ //
5
+ // A reporting instance sends, per craft the builder has EARNED credits in, that
6
+ // craft's headline counts (src/bongos/auth-admission.js readCraftRollup):
7
+ //
8
+ // engineer { shipped, credits }
9
+ // artist { pages_approved, credits }
10
+ // ideator { filed, full_ideas, ratified, credits }
11
+ //
12
+ // Two halves live here, next to each other so they cannot disagree on the shape:
13
+ //
14
+ // sanitizeCrafts — what upsertActivity BINDS. The report is third-party
15
+ // self-report (ADR 0171 D7), so the shape and the caps are the hub's, not
16
+ // the sender's: only the three crafts, only the named counts, each a
17
+ // non-negative integer under a ceiling or null. Anything else is dropped.
18
+ // A count is never text, so nothing here can carry a title or wording.
19
+ //
20
+ // sumCrafts — the cross-project aggregate getRollupForBuilder returns. Summed
21
+ // over EVERY project row, named or not, exactly as the totals are: "the
22
+ // aggregate is not the leak, the name is" (ADR 0171 D2). It adds no field a
23
+ // hidden or private account could reach — it rides the same rollup
24
+ // predicate as credits and works shipped (account-visibility.js), and the
25
+ // recruiter sliver is untouched (ADR 0215: a ceiling, not a step).
26
+ //
27
+ // A craft appears in the sum when any project reported it. A count is null in the
28
+ // sum only when no project could report it ("could not ask"), never because one
29
+ // project could not while another could.
30
+ 'use strict';
31
+
32
+ const CRAFT_FIELDS = Object.freeze({
33
+ engineer: Object.freeze(['shipped', 'credits']),
34
+ artist: Object.freeze(['pages_approved', 'credits']),
35
+ ideator: Object.freeze(['filed', 'full_ideas', 'ratified', 'credits']),
36
+ });
37
+ // Far above any honest count, low enough that a sum over every project stays a
38
+ // safe integer and a hostile report cannot dominate a profile by one number.
39
+ const MAX_CRAFT_COUNT = 10_000_000;
40
+
41
+ function cleanCount(v) {
42
+ return Number.isInteger(v) && v >= 0 && v <= MAX_CRAFT_COUNT ? v : null;
43
+ }
44
+
45
+ function sanitizeCrafts(raw) {
46
+ const out = {};
47
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return out;
48
+ for (const [craft, fields] of Object.entries(CRAFT_FIELDS)) {
49
+ const block = raw[craft];
50
+ if (!block || typeof block !== 'object' || Array.isArray(block)) continue;
51
+ const clean = {};
52
+ for (const f of fields) clean[f] = cleanCount(block[f]);
53
+ out[craft] = clean;
54
+ }
55
+ return out;
56
+ }
57
+
58
+ function sumCrafts(rows) {
59
+ const out = {};
60
+ for (const row of Array.isArray(rows) ? rows : []) {
61
+ const crafts = sanitizeCrafts(row);
62
+ for (const [craft, block] of Object.entries(crafts)) {
63
+ const acc = out[craft] || (out[craft] = Object.fromEntries(CRAFT_FIELDS[craft].map((f) => [f, null])));
64
+ for (const f of CRAFT_FIELDS[craft]) {
65
+ if (block[f] != null) acc[f] = (acc[f] || 0) + block[f];
66
+ }
67
+ }
68
+ }
69
+ return out;
70
+ }
71
+
72
+ module.exports = { sanitizeCrafts, sumCrafts };
@@ -0,0 +1,23 @@
1
+ -- platform_identity_029_activity_crafts.sql — each craft's own numbers on a
2
+ -- builder's activity row (task 1004440 / WA6.RP10, goal 1000095).
3
+ --
4
+ -- WHY. A rollup carried one ship count and a "Roles" list taken from the declared
5
+ -- preference, so on the hub profile an artist with forty approved pages, or an
6
+ -- ideator whose ideas the board ratified, read as an engineer with nothing
7
+ -- shipped. A reporting instance now sends, for each craft the builder has earned
8
+ -- credits in, that craft's headline counts (an artist's approved pages; an
9
+ -- ideator's filed, Full and ratified ideas; an engineer's shipped works).
10
+ --
11
+ -- WHAT. One jsonb column, the sanitised `crafts` object (craft-rollup.js shapes and
12
+ -- caps it before it is bound — never the raw body). '{}' is "no craft numbers
13
+ -- reported": every row written before this column, and every report from an
14
+ -- instance too old to send them. Counts only, never a title or wording.
15
+ --
16
+ -- Additive + platform_identity_-namespaced (ADR 0083 §5). Idempotent.
17
+
18
+ BEGIN;
19
+
20
+ ALTER TABLE platform_identity_builder_project_activity
21
+ ADD COLUMN IF NOT EXISTS crafts jsonb NOT NULL DEFAULT '{}'::jsonb;
22
+
23
+ COMMIT;
@@ -27,6 +27,7 @@ const { urlWithinOrigin } = require('./origin-containment');
27
27
  // The windowed data plane's append-only snapshot history (task 1002977, community
28
28
  // spec D2) — one call from upsertActivity below; the mechanism itself lives there.
29
29
  const { recordActivitySnapshot } = require('./activity-snapshots');
30
+ const { sanitizeCrafts, sumCrafts } = require('./craft-rollup');
30
31
 
31
32
  const { pool: defaultPool } = api;
32
33
  const { ACCOUNT_VISIBILITY, accountVisibleSql, rollupVisibleSql, isRollupVisible, scoutingListableSql } = visibility;
@@ -665,7 +666,7 @@ function sanitizeRecentShips(raw, clientOrigin) {
665
666
  // origin-containment.js:urlWithinOrigin. `asOf` is when the instance read these
666
667
  // totals (task 1004268, migration 026): an older report never overwrites a newer
667
668
  // one; unstamped (an older instance) keeps last-writer-wins; a future stamp is clamped.
668
- async function upsertActivity({ clientId, githubId, credits = 0, tasksShipped = 0, karma = 0, rank = null, disciplines = [], recentShips = [], clientOrigin = null, asOf = null }, { pool = defaultPool } = {}) {
669
+ async function upsertActivity({ clientId, githubId, credits = 0, tasksShipped = 0, karma = 0, rank = null, disciplines = [], recentShips = [], crafts = null, clientOrigin = null, asOf = null }, { pool = defaultPool } = {}) {
669
670
  // api.LIVE_RANK_LADDER is a published doorway export (src/module-api.js, a
670
671
  // lazy getter over db-kernel's canonical four-rank ladder).
671
672
  const safeRank = api.LIVE_RANK_LADDER.includes(rank) ? rank : null;
@@ -673,24 +674,26 @@ async function upsertActivity({ clientId, githubId, credits = 0, tasksShipped =
673
674
  .filter((d) => typeof d === 'string' && d.length > 0 && d.length <= MAX_DISCIPLINE_LENGTH)
674
675
  .slice(0, MAX_ROLLUP_DISCIPLINES);
675
676
  const ships = sanitizeRecentShips(recentShips, clientOrigin);
677
+ // Each craft's own counts (task 1004440), shaped and capped here — see craft-rollup.js.
678
+ const craftCounts = sanitizeCrafts(crafts);
676
679
  // One transaction with the snapshot: the upsert's row lock queues racing reports (task 1004244).
677
680
  return api.withTx(async (client) => {
678
681
  const { rows } = await client.query(
679
682
  `INSERT INTO platform_identity_builder_project_activity
680
- (client_id, github_id, credits, tasks_shipped, karma, rank_held, disciplines, recent_ships, last_active, updated_at, reported_as_of)
683
+ (client_id, github_id, credits, tasks_shipped, karma, rank_held, disciplines, recent_ships, last_active, updated_at, reported_as_of, crafts)
681
684
  VALUES ($1, $2, $3, $4, $5, $6, $7, $8::jsonb, now(), now(),
682
- CASE WHEN $9::timestamptz IS NULL THEN NULL ELSE LEAST($9::timestamptz, now()) END)
685
+ CASE WHEN $9::timestamptz IS NULL THEN NULL ELSE LEAST($9::timestamptz, now()) END, $10::jsonb)
683
686
  ON CONFLICT (client_id, github_id) DO UPDATE SET
684
687
  credits = EXCLUDED.credits, tasks_shipped = EXCLUDED.tasks_shipped, karma = EXCLUDED.karma,
685
688
  rank_held = EXCLUDED.rank_held, disciplines = EXCLUDED.disciplines,
686
- recent_ships = EXCLUDED.recent_ships, reported_as_of = EXCLUDED.reported_as_of,
689
+ recent_ships = EXCLUDED.recent_ships, reported_as_of = EXCLUDED.reported_as_of, crafts = EXCLUDED.crafts,
687
690
  last_active = now(), updated_at = now()
688
691
  WHERE EXCLUDED.reported_as_of IS NULL OR platform_identity_builder_project_activity.reported_as_of IS NULL
689
692
  OR EXCLUDED.reported_as_of >= platform_identity_builder_project_activity.reported_as_of
690
693
  RETURNING id, client_id, github_id`,
691
694
  // jsonb is bound as a JSON STRING: node-postgres serializes a JS array as a
692
695
  // Postgres ARRAY literal, which the jsonb column would reject.
693
- [clientId, githubId, credits | 0, tasksShipped | 0, karma | 0, safeRank, disc, JSON.stringify(ships), asOf],
696
+ [clientId, githubId, credits | 0, tasksShipped | 0, karma | 0, safeRank, disc, JSON.stringify(ships), asOf, JSON.stringify(craftCounts)],
694
697
  );
695
698
  // Windowed data plane (task 1002977) — best-effort, see activity-snapshots.js.
696
699
  await recordActivitySnapshot({ clientId, githubId }, { client });
@@ -804,7 +807,7 @@ async function getRollupForBuilder(githubId, { pool = defaultPool } = {}) {
804
807
  );
805
808
  const agg = aggRows[0];
806
809
  const { rows: projRows } = await pool.query(
807
- `SELECT p.name, p.origin, a.rank_held, a.recent_ships,
810
+ `SELECT p.name, p.origin, a.rank_held, a.recent_ships, a.crafts,
808
811
  (p.id IS NOT NULL AND ${onMapSql('p')} AND p.visibility = 'public') AS is_public
809
812
  FROM platform_identity_builder_project_activity a
810
813
  LEFT JOIN platform_identity_sso_clients c ON c.client_id = a.client_id
@@ -834,6 +837,9 @@ async function getRollupForBuilder(githubId, { pool = defaultPool } = {}) {
834
837
  karma: agg.karma,
835
838
  },
836
839
  disciplines: agg.disciplines || [],
840
+ // Each craft's own numbers, summed over every project — named or not, like
841
+ // the totals above (task 1004440; craft-rollup.js says why that is safe).
842
+ crafts: sumCrafts(projRows.map((r) => r.crafts)),
837
843
  last_active: agg.last_active,
838
844
  projects: named,
839
845
  more_projects: projRows.length - named.length,
@@ -580,6 +580,9 @@ module.exports = function ssoRoutes() {
580
580
  // here, like disciplines: the shape is checked and hard-capped in
581
581
  // upsertActivity, next to the data, so no route can forget it.
582
582
  recent_ships: {},
583
+ // Each craft's own counts (task 1004440): untyped here for the same reason,
584
+ // shaped and capped in upsertActivity (craft-rollup.js sanitizeCrafts).
585
+ crafts: {},
583
586
  })) return;
584
587
  const b = req.body || {};
585
588
  try {
@@ -617,6 +620,7 @@ module.exports = function ssoRoutes() {
617
620
  rank: b.rank || null,
618
621
  disciplines: Array.isArray(b.disciplines) ? b.disciplines : [],
619
622
  recentShips: b.recent_ships,
623
+ crafts: b.crafts,
620
624
  // The AUTHENTICATED client's registered origin — the bound every pushed
621
625
  // ship link must fall inside (task 1002803). Taken from the client row,
622
626
  // never from the body, for the same reason client_id is.
@@ -602,7 +602,7 @@ await ta('upsertActivity upserts per (client_id, github_id) with the talent snap
602
602
  // an empty list when the sender reported none, never NULL. Its own cap,
603
603
  // shape and link bound are covered by tests/hub_recent_ships.mjs. The last param
604
604
  // is the report's as-of stamp (task 1004268): null when the sender gave none.
605
- assert.deepEqual(c.params, ['c1', 42, 100, 5, 3, 'metic', ['engineer'], '[]', null]);
605
+ assert.deepEqual(c.params, ['c1', 42, 100, 5, 3, 'metic', ['engineer'], '[]', null, '{}']);
606
606
  });
607
607
 
608
608
  await ta('upsertActivity sanitizes third-party self-report (ADR 0171 D7): off-ladder rank → null, disciplines validated + capped', async () => {