@bongos/core 1.21.167 → 1.21.169

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 (83) hide show
  1. package/.bongos-core.json +157 -72
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +8 -0
  4. package/clients/bongos-client/index.cjs +8 -0
  5. package/clients/bongos-client/index.d.ts +13 -0
  6. package/clients/bongos-client/index.mjs +8 -0
  7. package/docs/api/openapi.json +308 -5
  8. package/docs/api-reference.md +7 -3
  9. package/docs/architecture.md +2 -0
  10. package/docs/copy-inventory.md +21 -20
  11. package/docs/copy-registry.json +34 -25
  12. package/docs/module-api-changelog.md +5 -1
  13. package/docs/modules-contract.md +2 -1
  14. package/docs/page-inventory.json +3 -0
  15. package/docs/page-readings.json +430 -430
  16. package/modules/autonomy/governor-docket.js +10 -5
  17. package/modules/builder-settings/builder-needs.js +29 -3
  18. package/modules/copy-desk/guarded-lines.js +89 -0
  19. package/modules/copy-desk/guarded-lines.json +14 -0
  20. package/modules/copy-desk/module.json +2 -2
  21. package/modules/copy-desk/page-status.js +77 -7
  22. package/modules/copy-desk/pages.js +4 -0
  23. package/modules/copy-desk/pollers/stuck-page-digest.js +34 -0
  24. package/modules/copy-desk/routes/copy-desk.js +8 -0
  25. package/modules/copy-desk/stuck-tweaks.js +79 -0
  26. package/modules/copy-desk/tests/copy_no_cms.mjs +6 -1
  27. package/modules/copy-desk/tests/copy_page_status.mjs +2 -2
  28. package/modules/government/asks-db.js +38 -2
  29. package/modules/government/catalog.js +1 -1
  30. package/modules/government/docket.js +20 -2
  31. package/modules/government/migrations/government_032_ask_flags.sql +35 -0
  32. package/modules/government/routes/government.js +43 -1
  33. package/modules/hall-ui/public/approval-queue-lib.js +4 -0
  34. package/modules/hall-ui/public/approval-queue.js +1 -0
  35. package/modules/hall-ui/public/city-decide.js +642 -0
  36. package/modules/hall-ui/public/city-draw.js +14 -3
  37. package/modules/hall-ui/public/city-switchboard.js +141 -0
  38. package/modules/hall-ui/public/city.css +176 -10
  39. package/modules/hall-ui/public/city.html +8 -3
  40. package/modules/hall-ui/public/city.js +185 -38
  41. package/modules/hall-ui/public/tweak-editor-lib.js +21 -2
  42. package/modules/hall-ui/public/tweak-editor.css +2 -0
  43. package/modules/hall-ui/public/tweak-editor.js +3 -1
  44. package/modules/lifecycle/db-claims.js +20 -5
  45. package/modules/lifecycle/rebase-gate.js +78 -0
  46. package/modules/lifecycle/routes/claims.js +10 -2
  47. package/package-lock.json +2 -2
  48. package/package.json +1 -1
  49. package/release-notes.json +28 -0
  50. package/scripts/gds/ask.js +68 -1
  51. package/scripts/gds/claim.js +6 -1
  52. package/scripts/gds/copy-apply.js +2 -0
  53. package/src/bongos/module-promotion.js +219 -0
  54. package/src/bongos/module-store.js +47 -9
  55. package/src/bongos/route-rank-check.js +7 -0
  56. package/src/bongos/routes/me.js +11 -0
  57. package/src/bongos/routes/modules.js +72 -10
  58. package/src/module-api.js +1 -1
  59. package/src/platform-server.js +5 -0
  60. package/tests/claim_gate_ci_unblock.mjs +4 -0
  61. package/tests/claim_gate_rebase.mjs +56 -3
  62. package/tests/copy_desk_page_editor.mjs +1 -1
  63. package/tests/copy_desk_page_submit.mjs +42 -0
  64. package/tests/copy_guarded_lines.mjs +54 -0
  65. package/tests/copy_stuck_page_tweaks.mjs +118 -0
  66. package/tests/government_ask_flags.mjs +80 -0
  67. package/tests/government_seed.mjs +1 -0
  68. package/tests/government_treasury.mjs +1 -0
  69. package/tests/governor_docket.mjs +1 -1
  70. package/tests/hall_approval_queue.mjs +15 -0
  71. package/tests/hall_city.mjs +5 -4
  72. package/tests/hall_city_living.mjs +118 -12
  73. package/tests/hall_city_rewording.mjs +51 -0
  74. package/tests/hall_city_switchboard.mjs +63 -0
  75. package/tests/hall_gov_real_site.mjs +2 -2
  76. package/tests/hall_street_deck.mjs +6 -4
  77. package/tests/hall_street_depth.mjs +1 -1
  78. package/tests/hall_street_desk.mjs +5 -3
  79. package/tests/hall_tweak_editor.mjs +26 -0
  80. package/tests/module_channel_install.mjs +112 -0
  81. package/tests/module_promotion.mjs +290 -0
  82. package/tests/module_upgrade_updates.mjs +2 -2
  83. package/tests/rebase_gate_set_aside.mjs +73 -0
@@ -135,11 +135,68 @@ async function check(file, deps = {}) {
135
135
  return { code: v.ok ? 0 : 1, lines: verdictLines(v) };
136
136
  }
137
137
 
138
+ // ---------- the reword list (owner, 2026-10-23) ----------
139
+ // A reader pressed "?" on a city card: the matter's words did not make sense.
140
+ // `unclear` prints what waits for a rewrite (the flagged matters, or with
141
+ // --legacy every open matter that waits on a person and has no stored card
142
+ // yet), each with the words it has now; `put` stores the rewritten card
143
+ // (PUT /government/asks/:matter), which closes the matter's flag.
144
+ async function client(deps) {
145
+ if (deps && deps.client) return deps.client;
146
+ const lib = require('./cli-lib');
147
+ if (!lib.readSessionToken()) throw new Error('no Bongos session — run /builder-setup first');
148
+ return lib.cliClient();
149
+ }
150
+ function matterLines(m) {
151
+ const out = [`${m.id} [${m.building}]${m.flag && m.flag.times > 1 ? ` flagged ${m.flag.times}x` : ''}`];
152
+ out.push(` title: ${String(m.title || '').trim()}`);
153
+ if (m.detail) out.push(` detail: ${String(m.detail).replace(/\s+/g, ' ').trim().slice(0, 600)}`);
154
+ if (m.ask) out.push(` card: ${m.ask.question} (${(m.ask.options || []).map((o) => o.label).join(' / ')})`);
155
+ if (m.flag && m.flag.note) out.push(` why unclear: ${m.flag.note}`);
156
+ if (Array.isArray(m.acts) && m.acts.length) out.push(` acts: ${m.acts.map((a) => a.key + (Array.isArray(a.options) && a.options.length ? '(' + a.options.map((o) => o.key).join('|') + ')' : '')).join(', ')}`);
157
+ return out;
158
+ }
159
+ async function unclear({ legacy = false, json = false } = {}, deps = {}) {
160
+ const api = await client(deps);
161
+ let list;
162
+ if (legacy) {
163
+ const r = await api.request('GET', '/government/docket');
164
+ if (!r || !r.ok) return { code: 1, lines: [`ask: the docket could not be read (${r && r.status}).`] };
165
+ list = ((r.data && r.data.matters) || []).filter((m) => m && m.state === 'needs' && !m.ask)
166
+ .map((m) => ({ id: m.id, building: m.building, source: m.source, title: m.title, detail: m.detail || null, ask: null, acts: m.acts || [], flag: m.unclear || null }));
167
+ } else {
168
+ const r = await api.request('GET', '/government/asks/unclear');
169
+ if (!r || !r.ok) return { code: 1, lines: [`ask: the reword list could not be read (${r && r.status}).`] };
170
+ list = (r.data && r.data.matters) || [];
171
+ }
172
+ if (json) return { code: 0, lines: [JSON.stringify(list, null, 2)] };
173
+ if (!list.length) return { code: 0, lines: [legacy ? 'ask: every open matter already carries a card.' : 'ask: nothing on the reword list.'] };
174
+ const lines = [`${list.length} ${legacy ? 'open matter(s) with no card yet' : 'matter(s) flagged as unclear'}:`, ''];
175
+ list.forEach((m) => { lines.push(...matterLines(m), ''); });
176
+ lines.push('Write each one a card (node scripts/gds/ask.js template), check it, then: node scripts/gds/ask.js put <matter-id> <file>');
177
+ return { code: 0, lines };
178
+ }
179
+ async function put(matterId, file, deps = {}) {
180
+ let body;
181
+ try { body = readJson(file); } catch (e) { return { code: 2, lines: [`ask: could not read the card as JSON (${e.message}).`] }; }
182
+ const local = asks.checkAsk(askOf(body));
183
+ if (!local.ok) return { code: 1, lines: verdictLines({ ok: false, ask: null, errors: local.errors, where: 'local' }) };
184
+ const api = await client(deps);
185
+ const r = await api.request('PUT', '/government/asks/' + encodeURIComponent(matterId), { body: { ask: askOf(body) } });
186
+ if (r && r.ok && r.data && r.data.ok) return { code: 0, lines: [`ask: saved the card on ${matterId}; its "?" flag is closed.`, '', ...verdictLines({ ok: true, ask: r.data.ask, where: 'server' })] };
187
+ const err = r && r.data && r.data.error;
188
+ return { code: 1, lines: [`ask: ${matterId} was not saved (${r && r.status}${err ? ' ' + err.code : ''}). ${err && err.message ? err.message : ''}`.trim()] };
189
+ }
190
+
138
191
  const USAGE = [
139
192
  'usage:',
140
193
  ' node scripts/gds/ask.js template print a blank card to fill in',
141
194
  ' node scripts/gds/ask.js check <file|-> check a card; on a pass, print it as a person reads it',
142
195
  ' --local check with the rules here, without the server',
196
+ ' node scripts/gds/ask.js unclear the reword list: matters a reader flagged with "?"',
197
+ ' --legacy instead: every open matter on a person with no card yet',
198
+ ' --json as JSON',
199
+ ' node scripts/gds/ask.js put <matter> <file> store a rewritten card on a matter (closes its flag)',
143
200
  ].join('\n');
144
201
 
145
202
  async function main(argv = process.argv.slice(2)) {
@@ -153,6 +210,16 @@ async function main(argv = process.argv.slice(2)) {
153
210
  console.log(r.lines.join('\n'));
154
211
  return r.code;
155
212
  }
213
+ if (cmd === 'unclear') {
214
+ const r = await unclear({ legacy: argv.includes('--legacy'), json: argv.includes('--json') });
215
+ console.log(r.lines.join('\n'));
216
+ return r.code;
217
+ }
218
+ if (cmd === 'put' && argv[1] && argv[2]) {
219
+ const r = await put(argv[1], argv[2]);
220
+ console.log(r.lines.join('\n'));
221
+ return r.code;
222
+ }
156
223
  console.error(USAGE);
157
224
  return 2;
158
225
  }
@@ -161,4 +228,4 @@ if (require.main === module) {
161
228
  main().then((code) => { process.exitCode = code; }, (e) => { console.error(`ask: ${e && e.message}`); process.exitCode = 1; });
162
229
  }
163
230
 
164
- module.exports = { TEMPLATE, askOf, checkedAsk, postWithAsk, verdictLines, serverVerdict, check, main };
231
+ module.exports = { TEMPLATE, askOf, checkedAsk, postWithAsk, verdictLines, serverVerdict, check, unclear, put, main };
@@ -369,7 +369,10 @@ function claimFailureGuidance({ code, message, d }, r, taskId, ctx) {
369
369
  // posture) and this surface dropped it on the floor. Name the owed tasks, then
370
370
  // print the server's menu.
371
371
  if (code === 'REBASE_REQUIRED') {
372
- const owed = (d.rebaseTasks || d.rebase_tasks || []).slice(0, MAX_LISTED_TASKS);
372
+ // task 1004788: only the fixable ones gate; set-aside ones are listed apart.
373
+ const all = (d.rebaseTasks || d.rebase_tasks || []);
374
+ const owed = all.filter((t) => !t.waiting).slice(0, MAX_LISTED_TASKS);
375
+ const waiting = all.filter((t) => t.waiting).slice(0, MAX_LISTED_TASKS);
373
376
  lines.push(owed.length
374
377
  ? `Your queue is gated — ${owed.length} confirmed task(s) are waiting on your rebase:`
375
378
  : 'Your queue is gated — a confirmed task is waiting on your rebase.');
@@ -377,6 +380,8 @@ function claimFailureGuidance({ code, message, d }, r, taskId, ctx) {
377
380
  lines.push(` - task ${t.id}${t.title ? ` ${clip(t.title)}` : ''}`);
378
381
  if (t.reason) lines.push(` flagged: ${clip(t.reason)}`);
379
382
  }
383
+ if (waiting.length) lines.push(' Waiting, not on you (set aside; they land on their own):');
384
+ for (const t of waiting) lines.push(` - task ${t.id}${t.title ? ` ${clip(t.title)}` : ''} — ${clip(t.waits_on || t.reason || 'outside you')}`);
380
385
  lines.push(` ${clip(d.hint, 600) || 'Clear it with /strand-fix N, or rebase the branch and re-ship — or /builder-release it to give up on one.'}`);
381
386
  return { lines, exit: QUEUE_GATED_EXIT };
382
387
  }
@@ -598,6 +598,8 @@ async function batchMain({ taskId, batchFile, dryRun, pageId = null, now = () =>
598
598
  log(` - ${a.before}`);
599
599
  log(` + ${a.after}`);
600
600
  }
601
+ // task 1004776: the submit's guarded-line warnings, so whoever applies sees them too.
602
+ for (const w of batch.warnings || []) log(` ${w.key} WARNING: no longer says what it must: ${w.must_still_say}${w.guarded_by ? ` (guarded by ${w.guarded_by})` : ''}`);
601
603
  for (const x of result.refused) {
602
604
  log(` ${x.key} REFUSED (${x.code})${x.detail && x.detail.what_this_means ? `: ${x.detail.what_this_means}` : ''}`);
603
605
  log(` line: ${JSON.stringify(x.before)}`);
@@ -0,0 +1,219 @@
1
+ 'use strict';
2
+
3
+ // src/bongos/module-promotion.js — the promotion gate: when a module version on
4
+ // the tester channel moves to general, and when it is held (task 1003804, ADR 0344
5
+ // D3; owner decisions 2026-10-23 recorded on the task).
6
+ //
7
+ // THE RULE, in the order it is applied:
8
+ // 1. A version only ever moves while its Security gate has passed and its own
9
+ // tests pass (ADR 0343). Tests pass = every test file passed (score 100), or
10
+ // the module declares no tests (`no_data` is not a fail). Anything else —
11
+ // failed, pending, not yet run — waits; it is never held for it.
12
+ // 2. HOLD: any tester reports a failed install, or any runtime error, on the
13
+ // version. A held version stays on tester until a Metic+ person releases it
14
+ // (`module.delist`, owner 2026-10-23) or a newer version replaces it.
15
+ // 3. EARLY: from day 2 on tester, 5 or more holders reported a clean install and
16
+ // nobody reported a problem.
17
+ // 4. SEVEN DAYS: on tester for 7 days. This applies whether or not any tester
18
+ // picked it up, so a module nobody volunteers for still gets updated — a
19
+ // version is only ever held on evidence, never for lack of testers.
20
+ //
21
+ // Every report on a tester-channel version comes from a tester, because only
22
+ // testers are handed one (task 1003805), so the reports are counted as they are.
23
+ //
24
+ // A move is a NEW store_module_version_channels row (core_276), never an update:
25
+ // set_by NULL when the sweep moves it, the person when a person releases it, and a
26
+ // short reason code. decidePromotion is the whole rule and is pure, so the tests
27
+ // drive it directly; sweepPromotions and releaseHeld are the two writers.
28
+
29
+ const DAY_MS = 24 * 60 * 60 * 1000;
30
+ const PROMOTE_AFTER_DAYS = 7;
31
+ const EARLY_AFTER_DAYS = 2;
32
+ const EARLY_CLEAN_INSTALLS = 5;
33
+ const SWEEP_LIMIT = 200;
34
+
35
+ const REASONS = Object.freeze({
36
+ AFTER_7_DAYS: 'promoted_after_7_days',
37
+ EARLY: 'promoted_early_clean_installs',
38
+ HELD: 'held_tester_errors',
39
+ RELEASED: 'released_by_person',
40
+ });
41
+
42
+ function poolOf(db) { return db || require('./pool').pool; }
43
+
44
+ // Did the ADR 0343 checks pass? signals: the current row per part.
45
+ function checksPass({ security, tests } = {}) {
46
+ if (!security || security.outcome !== 'passed') return false;
47
+ if (!tests) return false;
48
+ if (tests.outcome === 'no_data') return true;
49
+ return tests.outcome === 'scored' && Number(tests.score) === 100;
50
+ }
51
+
52
+ // The rule. evidence: { onTesterSince, held, security, tests, cleanInstalls,
53
+ // failedInstalls, runtimeErrors }. Returns { action: 'promote'|'hold'|'wait',
54
+ // reason, days_on_tester, ... } — the same object an author reads.
55
+ function decidePromotion(evidence, now = new Date()) {
56
+ const since = new Date(evidence.onTesterSince);
57
+ const days = Math.max(0, (now.getTime() - since.getTime()) / DAY_MS);
58
+ const base = {
59
+ days_on_tester: Math.floor(days),
60
+ clean_installs: evidence.cleanInstalls || 0,
61
+ failed_installs: evidence.failedInstalls || 0,
62
+ runtime_errors: evidence.runtimeErrors || 0,
63
+ checks_passed: checksPass(evidence),
64
+ general_on: new Date(since.getTime() + PROMOTE_AFTER_DAYS * DAY_MS).toISOString(),
65
+ };
66
+ if (evidence.held) return { action: 'wait', reason: 'held_until_released', ...base };
67
+ if (base.failed_installs > 0 || base.runtime_errors > 0) return { action: 'hold', reason: REASONS.HELD, ...base };
68
+ if (!base.checks_passed) return { action: 'wait', reason: 'checks_not_passed', ...base };
69
+ if (days >= PROMOTE_AFTER_DAYS) return { action: 'promote', reason: REASONS.AFTER_7_DAYS, ...base };
70
+ if (days >= EARLY_AFTER_DAYS && base.clean_installs >= EARLY_CLEAN_INSTALLS) {
71
+ return { action: 'promote', reason: REASONS.EARLY, ...base };
72
+ }
73
+ return { action: 'wait', reason: 'waiting_for_time_or_installs', ...base };
74
+ }
75
+
76
+ // The evidence for every version currently on tester (optionally one version),
77
+ // bounded. onTesterSince is when it reached tester (its first tester row since
78
+ // it was last off tester — in practice its publish), held is whether its current
79
+ // row is a hold.
80
+ async function readTesterEvidence({ db, versionId = null, limit = SWEEP_LIMIT } = {}) {
81
+ const pool = poolOf(db);
82
+ const { rows } = await pool.query(
83
+ // One probe per version into idx_store_module_version_channels_current (its
84
+ // newest row, LIMIT 1), never a DISTINCT ON over every version's whole move
85
+ // history: the work grows with versions, not with how often they moved.
86
+ `SELECT v.id AS version_id, v.module_key, v.version, cur.reason AS current_reason,
87
+ (SELECT min(t.set_at) FROM store_module_version_channels t
88
+ WHERE t.version_id = v.id AND t.channel = 'tester') AS on_tester_since,
89
+ (SELECT count(*) FROM module_install_reports r WHERE r.version_id = v.id AND r.ok)::int AS clean_installs,
90
+ (SELECT count(*) FROM module_install_reports r WHERE r.version_id = v.id AND NOT r.ok)::int AS failed_installs,
91
+ (SELECT coalesce(sum(e.error_count), 0) FROM module_runtime_error_counts e WHERE e.version_id = v.id)::int AS runtime_errors
92
+ FROM store_module_versions v
93
+ CROSS JOIN LATERAL (
94
+ SELECT c.channel, c.reason FROM store_module_version_channels c
95
+ WHERE c.version_id = v.id ORDER BY c.set_at DESC, c.id DESC LIMIT 1
96
+ ) cur
97
+ WHERE cur.channel = 'tester' AND ($1::bigint IS NULL OR v.id = $1)
98
+ ORDER BY v.id
99
+ LIMIT $2`, [versionId, limit]);
100
+ if (!rows.length) return [];
101
+ const { rows: signals } = await pool.query(
102
+ `SELECT DISTINCT ON (version_id, part) version_id, part, outcome, score
103
+ FROM module_assessment_signals
104
+ WHERE version_id = ANY($1) AND part IN ('security', 'tests')
105
+ ORDER BY version_id, part, measured_at DESC, id DESC`, [rows.map((r) => r.version_id)]);
106
+ const parts = new Map();
107
+ for (const s of signals) {
108
+ const k = String(s.version_id);
109
+ if (!parts.has(k)) parts.set(k, {});
110
+ parts.get(k)[s.part] = s;
111
+ }
112
+ return rows.map((r) => ({
113
+ versionId: r.version_id,
114
+ moduleKey: r.module_key,
115
+ version: r.version,
116
+ onTesterSince: r.on_tester_since,
117
+ currentReason: r.current_reason,
118
+ held: r.current_reason === REASONS.HELD,
119
+ cleanInstalls: r.clean_installs,
120
+ failedInstalls: r.failed_installs,
121
+ runtimeErrors: r.runtime_errors,
122
+ security: (parts.get(String(r.version_id)) || {}).security || null,
123
+ tests: (parts.get(String(r.version_id)) || {}).tests || null,
124
+ }));
125
+ }
126
+
127
+ // Insert the move only if the version's current row is still the one we read, so
128
+ // a sweep racing a release (or another sweep) writes at most one move.
129
+ async function insertMove(pool, { versionId, channel, reason, setBy = null, fromReason }) {
130
+ const { rowCount } = await pool.query(
131
+ `INSERT INTO store_module_version_channels (version_id, channel, reason, set_by)
132
+ SELECT $1, $2, $3, $4
133
+ WHERE (SELECT c.reason FROM store_module_version_channels c
134
+ WHERE c.version_id = $1 ORDER BY c.set_at DESC, c.id DESC LIMIT 1) = $5`,
135
+ [versionId, channel, reason, setBy, fromReason]);
136
+ return rowCount === 1;
137
+ }
138
+
139
+ // One pass: promote or hold every version on tester the rule says to. Returns
140
+ // { promoted: [...], held: [...] }. Per-version failures are isolated.
141
+ async function sweepPromotions({ db, now = new Date(), log = null } = {}) {
142
+ const pool = poolOf(db);
143
+ const out = { promoted: [], held: [] };
144
+ const evidence = await readTesterEvidence({ db: pool });
145
+ for (const e of evidence) {
146
+ const d = decidePromotion(e, now);
147
+ if (d.action === 'wait') continue;
148
+ try {
149
+ const channel = d.action === 'promote' ? 'general' : 'tester';
150
+ const moved = await insertMove(pool, { versionId: e.versionId, channel, reason: d.reason, fromReason: e.currentReason });
151
+ if (!moved) continue;
152
+ const entry = { version_id: String(e.versionId), module_key: e.moduleKey, version: e.version, reason: d.reason };
153
+ (d.action === 'promote' ? out.promoted : out.held).push(entry);
154
+ if (log) log.info(entry, `${e.moduleKey} ${e.version}: ${d.reason}`);
155
+ } catch (err) {
156
+ if (log) log.warn({ versionId: e.versionId, err: err.message }, 'promotion move failed; the next sweep retries');
157
+ }
158
+ }
159
+ return out;
160
+ }
161
+
162
+ // Where one version stands against the rule, for its author. null when the
163
+ // version has no channel row.
164
+ async function readStanding(versionId, { db, now = new Date() } = {}) {
165
+ const pool = poolOf(db);
166
+ const { rows } = await pool.query(
167
+ `SELECT channel, reason, set_by, set_at FROM store_module_version_channels
168
+ WHERE version_id = $1 ORDER BY set_at DESC, id DESC LIMIT 1`, [versionId]);
169
+ if (!rows.length) return null;
170
+ const cur = rows[0];
171
+ const standing = {
172
+ channel: cur.channel,
173
+ reason: cur.reason,
174
+ set_at: cur.set_at,
175
+ moved_by: cur.set_by == null ? 'system' : String(cur.set_by),
176
+ rule: RULE_TEXT,
177
+ };
178
+ if (cur.channel !== 'tester') return standing;
179
+ const [e] = await readTesterEvidence({ db: pool, versionId });
180
+ if (!e) return standing;
181
+ return { ...standing, next: decidePromotion(e, now) };
182
+ }
183
+
184
+ // A Metic+ person releases a HELD version to general. Only a held version: a
185
+ // version still waiting for its time is the rule's to move, not a person's.
186
+ async function releaseHeld(versionId, builderId, { db } = {}) {
187
+ const pool = poolOf(db);
188
+ const moved = await insertMove(pool, {
189
+ versionId, channel: 'general', reason: REASONS.RELEASED, setBy: builderId, fromReason: REASONS.HELD,
190
+ });
191
+ if (!moved) {
192
+ return { ok: false, code: 'not_held', status: 409, message: 'Only a version held on the tester channel can be released by a person. Others move on their own after seven days.' };
193
+ }
194
+ return { ok: true, channel: 'general', reason: REASONS.RELEASED };
195
+ }
196
+
197
+ const RULE_TEXT = `A new version goes to testers first. It reaches everyone after ${PROMOTE_AFTER_DAYS} days, `
198
+ + `or after ${EARLY_AFTER_DAYS} days once ${EARLY_CLEAN_INSTALLS} or more testers installed it cleanly, `
199
+ + 'as long as its security check and its own tests pass. Any failed tester install or runtime error '
200
+ + 'holds it on testers until a reviewer releases it.';
201
+
202
+ // The in-process sweep, started by src/platform-server.js after listen (never by
203
+ // a test). Hourly is plenty for a rule counted in days. MODULE_PROMOTION_DISABLED=1
204
+ // switches it off.
205
+ const SWEEP_MS = 60 * 60 * 1000;
206
+ let timer = null;
207
+
208
+ function start({ env = process.env, log = require('./logger').logger.child({ src: 'module-promotion' }) } = {}) {
209
+ if (env.MODULE_PROMOTION_DISABLED === '1' || timer) return false;
210
+ const run = () => sweepPromotions({ log }).catch((err) => log.warn({ err: err.message }, 'promotion sweep failed'));
211
+ setTimeout(run, 30_000).unref();
212
+ timer = setInterval(run, SWEEP_MS);
213
+ timer.unref();
214
+ return true;
215
+ }
216
+
217
+ module.exports = {
218
+ PROMOTE_AFTER_DAYS, EARLY_AFTER_DAYS, EARLY_CLEAN_INSTALLS, decidePromotion, sweepPromotions, readStanding, releaseHeld, start,
219
+ };
@@ -256,12 +256,34 @@ function isFreePrice(priceRow) {
256
256
  return priceRow.price_cents === 0 || priceRow.price_credits === 0;
257
257
  }
258
258
 
259
+ // The release channels a project is handed versions from (task 1003805, ADR 0344
260
+ // D1/D2): a project that tests this module gets tester AND general versions, every
261
+ // other project general only. 'withdrawn' is never in either list, so a withdrawn
262
+ // version is never handed out (a copy already installed keeps working, ADR 0338 D2).
263
+ function servedChannels(tester) {
264
+ return tester ? ['tester', 'general'] : ['general'];
265
+ }
266
+
267
+ // A version's current channel is its newest store_module_version_channels row
268
+ // (core_276): a move is a new row, never an update. One probe per version into
269
+ // idx_store_module_version_channels_current. `idCol` is the outer query's version
270
+ // id column, and only these two literals are accepted, so no text reaches the SQL.
271
+ const VERSION_ID_COLS = new Set(['store_module_versions.id', 'v.id']);
272
+ function currentChannelSql(idCol) {
273
+ if (!VERSION_ID_COLS.has(idCol)) throw new Error(`currentChannelSql: unexpected column ${idCol}`);
274
+ return `(SELECT c.channel FROM store_module_version_channels c
275
+ WHERE c.version_id = ${idCol} ORDER BY c.set_at DESC, c.id DESC LIMIT 1)`;
276
+ }
277
+
259
278
  // Resolve what an acquire of `key` (optionally at an exact `version`) would hand
260
279
  // over. Refuses an unknown or delisted module (ADR 0338 D2: a delisted module takes
261
280
  // no new acquisitions and serves no further versions) and an unknown version. With
262
281
  // no version asked for, the newest published version (semver order, not text).
282
+ // `channels` (from servedChannels) limits it to versions currently on those
283
+ // channels — the newest one there, or a refusal for an exact version that is not;
284
+ // null skips the channel check (recording a version already placed).
263
285
  // Returns { ok: true, module, version, price, free } or { ok: false, status, code, message }.
264
- async function resolveAcquirable(key, { version = null, db } = {}) {
286
+ async function resolveAcquirable(key, { version = null, channels = null, db } = {}) {
265
287
  const pool = db || require('./pool').pool;
266
288
  const refuse = (status, code, message) => ({ ok: false, status, code, message });
267
289
  const { rows: [mod] } = await pool.query(
@@ -270,16 +292,28 @@ async function resolveAcquirable(key, { version = null, db } = {}) {
270
292
  if (mod.status === 'delisted') return refuse(409, 'module_delisted', `"${key}" is delisted: it can no longer be acquired or downloaded (ADR 0338 D2).`);
271
293
  const { rows: versions } = await pool.query(
272
294
  `SELECT id, module_key, version, core_version, tree_sha256, tarball_sha256, tarball_bytes,
273
- artifact_path, published_at
295
+ artifact_path, published_at, ${currentChannelSql('store_module_versions.id')} AS channel
274
296
  FROM store_module_versions WHERE module_key = $1`, [key]);
297
+ const served = (r) => !channels || channels.includes(r.channel);
275
298
  let row;
276
299
  if (version) {
277
300
  row = versions.find((r) => r.version === version);
278
301
  if (!row) return refuse(404, 'version_not_found', `"${key}" has no published version ${version}.`);
302
+ if (!served(row)) {
303
+ return refuse(409, 'version_not_on_channel', row.channel === 'withdrawn'
304
+ ? `"${key}" ${version} was withdrawn: it is no longer handed out.`
305
+ : `"${key}" ${version} is still with testers, and this project takes general versions of it.`);
306
+ }
279
307
  } else {
280
- row = versions.filter((r) => parseVersion(r.version))
308
+ row = versions.filter((r) => parseVersion(r.version) && served(r))
281
309
  .sort((a, b) => cmp(parseVersion(a.version), parseVersion(b.version))).pop();
282
- if (!row) return refuse(404, 'no_versions', `"${key}" has no published version yet.`);
310
+ if (!row) {
311
+ if (!versions.length) return refuse(404, 'no_versions', `"${key}" has no published version yet.`);
312
+ const testing = versions.some((r) => r.channel === 'tester');
313
+ return refuse(404, 'no_versions_on_channel', testing
314
+ ? `"${key}" has no version on the channel this project takes yet: its newest is still with testers.`
315
+ : `"${key}" has no version this project can be handed: every version was withdrawn.`);
316
+ }
283
317
  }
284
318
  const { rows: [price] } = await pool.query(
285
319
  `SELECT price_cents, price_credits, set_at FROM store_module_prices
@@ -321,21 +355,25 @@ async function publishedVersionSet(keys, { db } = {}) {
321
355
  // takes no entitlement and refuses nothing, so a delisted module still answers,
322
356
  // with its status, and the caller says it gets no further versions (ADR 0338 D2).
323
357
  // A key the store does not know is simply absent. One query for the whole list.
324
- // Returns { [key]: { version, core_version, status } }.
325
- async function latestVersions(keys, { db } = {}) {
358
+ // `channelsFor(key)` (servedChannels per module) counts only versions on the
359
+ // channels this project takes, so upgrade reports what update would hand over
360
+ // (task 1003805); a key with nothing on them is absent too.
361
+ // Returns { [key]: { version, core_version, status, channel } }.
362
+ async function latestVersions(keys, { db, channelsFor = null } = {}) {
326
363
  if (!keys.length) return {};
327
364
  const pool = db || require('./pool').pool;
328
365
  const { rows } = await pool.query(
329
- `SELECT v.module_key, v.version, v.core_version, m.status
366
+ `SELECT v.module_key, v.version, v.core_version, m.status, ${currentChannelSql('v.id')} AS channel
330
367
  FROM store_module_versions v JOIN store_modules m ON m.module_key = v.module_key
331
368
  WHERE v.module_key = ANY($1)`, [keys]);
332
369
  const out = {};
333
370
  for (const r of rows) {
334
371
  const next = parseVersion(r.version);
335
372
  if (!next) continue;
373
+ if (channelsFor && !channelsFor(r.module_key).includes(r.channel)) continue;
336
374
  const have = out[r.module_key];
337
375
  if (have && cmp(parseVersion(have.version), next) >= 0) continue;
338
- out[r.module_key] = { version: r.version, core_version: r.core_version, status: r.status };
376
+ out[r.module_key] = { version: r.version, core_version: r.core_version, status: r.status, channel: r.channel };
339
377
  }
340
378
  return out;
341
379
  }
@@ -383,6 +421,6 @@ function versionArtifactFile(row, { dir = storeDir() } = {}) {
383
421
 
384
422
  module.exports = {
385
423
  stageArtifact, commitArtifact, removeArtifact, relativeArtifactPath, publishVersion,
386
- isFreePrice, resolveAcquirable, resolveReadable, publishedVersionSet, latestVersions, versionArtifactFile,
424
+ isFreePrice, servedChannels, resolveAcquirable, resolveReadable, publishedVersionSet, latestVersions, versionArtifactFile,
387
425
  delistModule, delistedKeySet, storeDir, storeDirFor, migrateLegacyStore,
388
426
  };
@@ -245,6 +245,9 @@ const EXPECTED_RANKS = {
245
245
  // Delisting a store module (task 1003797, ADR 0338 D2) stops every new install of
246
246
  // it for every instance, so it is pinned to its own atom.
247
247
  'POST /store/modules/:key/delist': 'perm:module.delist',
248
+ // Releasing a version the promotion gate held on tester (task 1003804) hands it to
249
+ // every project; the owner put it on the delist atom (2026-10-23), so it is pinned.
250
+ 'POST /store/modules/:key/versions/:version/release': 'perm:module.delist',
248
251
  };
249
252
 
250
253
  // The authz-core route pins (ADR 0174). Paths and the gating atom were renamed
@@ -320,6 +323,10 @@ const GOVERNMENT_PINS = {
320
323
  'POST /government/asks/check': 'perm:board.vote.cast',
321
324
  'PUT /government/asks/:matterId': 'perm:board.vote.cast',
322
325
  'POST /government/asks/:matterId/pass': 'perm:board.vote.cast',
326
+ // the reword list (owner 2026-10-23): a reader flags a card it cannot follow,
327
+ // a session reads the open flags; both under the docket's own atom
328
+ 'POST /government/asks/:matterId/unclear': 'perm:board.vote.cast',
329
+ 'GET /government/asks/unclear': 'perm:board.vote.cast',
323
330
  // task 1004564: a proposer withdraws their own open sitting before any vote —
324
331
  // the proposer's half of putting one up, so the same atom. The proposer-only
325
332
  // and no-votes walls are in withdrawItem (board-withdraw.js) and its write.
@@ -184,6 +184,15 @@ module.exports = function buildMeRouter() {
184
184
  // same GET /me -> Conductor path, deliberately not a second mechanism.
185
185
  // default-on, so present in prod; null when the module is off.
186
186
  const specialities = seams.resolveOptional('specialities');
187
+ // task 1004777: stuck page tweaks this builder should be told about, through
188
+ // the copy-desk PORT (core never imports the module, ADR 0093). Started here so
189
+ // it runs beside the fan-out below; [] when copy-desk is off or the read fails.
190
+ const stuckPageTweaksP = (async () => {
191
+ try {
192
+ const port = seams.resolveOptional('copy-desk.stuckPageTweaksFor');
193
+ return typeof port === 'function' ? await port(req.builder.id, req.builder.rank) : [];
194
+ } catch (_) { return []; }
195
+ })();
187
196
  const [
188
197
  builder, activeClaims, estVsActual, unlocked, lockedWithProgress, streakInfo,
189
198
  recentGrades, builderAvg, onboardingRow, shipCount, recentKarma, karma30d, discordLink, cliAuthed,
@@ -593,6 +602,8 @@ module.exports = function buildMeRouter() {
593
602
  openTaskRecommendations: taskRecommendationCount,
594
603
  // task 1003178 (C08): open mingle introductions waiting on this builder.
595
604
  openMingleIntroductions: mingleIntroductionCount,
605
+ // task 1004777: one Needs-attention line per stuck page tweak.
606
+ stuckPageTweaks: await stuckPageTweaksP,
596
607
  }) : [],
597
608
  };
598
609
  // Module→core contribution seam (BV1.R42, ADR 0083). Let each enabled module
@@ -96,7 +96,7 @@ const moduleAssessVersion = require('../../../scripts/gds/module-assess-version'
96
96
  const { checkEntitlement, listEntitlements, grantEntitlement, recordAcquired } = require('../module-entitlements');
97
97
  const {
98
98
  stageArtifact, commitArtifact, removeArtifact, relativeArtifactPath, publishVersion,
99
- resolveAcquirable, resolveReadable, publishedVersionSet, latestVersions, versionArtifactFile,
99
+ servedChannels, resolveAcquirable, resolveReadable, publishedVersionSet, latestVersions, versionArtifactFile,
100
100
  delistModule, delistedKeySet, migrateLegacyStore,
101
101
  } = require('../module-store');
102
102
  const {
@@ -111,7 +111,19 @@ const log = require('../logger').logger.child({ src: 'modules' });
111
111
  // as unused exports and tripped the dead-code ratchet that held this branch out of main.
112
112
  const { listOverrides, setOverride } = require('../module-overrides');
113
113
  const { listCatalog, SORT_PARTS } = require('../module-listing');
114
- const { readTesting, setTester, setGeneralOnly } = require('../module-testing');
114
+ const { readTesting, setTester, setGeneralOnly, testsVersionsOf } = require('../module-testing');
115
+ const { readStanding, releaseHeld } = require('../module-promotion');
116
+
117
+ // The channels this project takes, per module (task 1003805, ADR 0344 D1/D2): a
118
+ // tester project gets tester and general versions, except of a module it marked
119
+ // "general only"; everyone else gets general. A choice that cannot be read reads as
120
+ // NOT a tester (module-testing.js), so it hands out general, never tester.
121
+ async function projectChannels() {
122
+ let testing = null;
123
+ try { testing = await readTesting(); }
124
+ catch (e) { log.warn({ err: e.message }, 'tester choice unreadable; serving general versions'); }
125
+ return (key) => servedChannels(testsVersionsOf(testing, key));
126
+ }
115
127
 
116
128
  // The sentence for a toggle that waits on a restart (task 1004463). 'routes' are the
117
129
  // module's pages, 'pollers' its background jobs. PURE.
@@ -578,6 +590,51 @@ module.exports = function buildModulesRouter() {
578
590
  res.json({ ok: true, module_key: key, status: r.module.status, delisted_at: r.module.delisted_at });
579
591
  }));
580
592
 
593
+ // GET /api/bongos/store/modules/:key/versions/:version/channel — where a version
594
+ // stands on the promotion gate (task 1003804, ADR 0344 D3): its current channel,
595
+ // who moved it there and why, the rule in plain words, and on tester the numbers
596
+ // the rule reads and what it is waiting for. Any signed-in builder may read it, so
597
+ // an author is never left guessing why a version has not reached everyone.
598
+ router.get('/store/modules/:key/versions/:version/channel', auth.requireBuilder,
599
+ asyncHandler('GET /store/modules/:key/versions/:version/channel', async (req, res) => {
600
+ const { key, version } = req.params;
601
+ if (!KEY_RE.test(key) || !parseVersion(version)) {
602
+ return res.fail('bad_request', { status: 400, message: 'A module key is lowercase kebab-case and a version is exact X.Y.Z.' });
603
+ }
604
+ const r = await resolveReadable(key, version);
605
+ if (!r.ok) return res.fail(r.code, { status: r.status, message: r.message });
606
+ const standing = await readStanding(r.version.id);
607
+ if (!standing) return res.fail('version_has_no_channel', { status: 404, message: `${key} ${version} has no release channel recorded.` });
608
+ res.set('Cache-Control', 'no-store');
609
+ res.json({ module_key: key, version, ...standing });
610
+ }));
611
+
612
+ // POST /api/bongos/store/modules/:key/versions/:version/release — a Metic+ person
613
+ // releases a version the promotion gate HELD on tester (a tester reported a failed
614
+ // install or errors) to general (task 1003804). Gated on module.delist: the owner
615
+ // ruled (2026-10-23) the people who may take a module out of the store are the ones
616
+ // who may let a held version out, with no new permission. Only a held version: one
617
+ // still waiting for its time is the rule's to move. A reason is required; the audit
618
+ // middleware records it with who released it, and set_by on the new channel row.
619
+ router.post('/store/modules/:key/versions/:version/release', auth.requireBuilder, auth.requirePermission('module.delist'),
620
+ asyncHandler('POST /store/modules/:key/versions/:version/release', async (req, res) => {
621
+ if (validateOrRespond(req, res, { reason: { type: 'string', maxLength: 2000 } })) return;
622
+ const { key, version } = req.params;
623
+ if (!KEY_RE.test(key) || !parseVersion(version)) {
624
+ return res.fail('bad_request', { status: 400, message: 'A module key is lowercase kebab-case and a version is exact X.Y.Z.' });
625
+ }
626
+ const reason = String(req.body?.reason || '').trim();
627
+ if (!reason) {
628
+ return res.fail('reason_required', { status: 400, message: 'Say why this held version is safe for everyone, in reason.' });
629
+ }
630
+ const r = await resolveReadable(key, version);
631
+ if (!r.ok) return res.fail(r.code, { status: r.status, message: r.message });
632
+ const out = await releaseHeld(r.version.id, req.builder.id);
633
+ if (!out.ok) return res.fail(out.code, { status: out.status, message: out.message });
634
+ log.info({ versionId: r.version.id, builderId: req.builder.id }, `${key} ${version} released from hold to general`);
635
+ res.json({ ok: true, module_key: key, version, channel: out.channel, reason: out.reason });
636
+ }));
637
+
581
638
  // GET /api/bongos/store/entitlements and /store/modules/:key/entitlement — the
582
639
  // read side of the entitlement record (task 1003813, ADR 0338 D1). Own-scoped
583
640
  // like /me/sessions: the holder is ALWAYS the caller (holder_kind 'builder',
@@ -621,9 +678,12 @@ module.exports = function buildModulesRouter() {
621
678
  // here changes what runs on this instance (install never enables; the CLI places
622
679
  // files on the CALLER's checkout).
623
680
  //
624
- // Channels: every version is on the general channel until the channel table
625
- // (task 1003815) and "install honours the channel" (task 1003805) land; those
626
- // add the channel check to step 1 and step 2, beside the entitlement check.
681
+ // Channels (task 1003805, ADR 0344): steps 1 and 2 hand out only versions on a
682
+ // channel this project takes (projectChannels) — with no version asked for, the
683
+ // newest there. Install and update share step 1, so both honour it. A project
684
+ // that stops testing keeps the tester version it has; its next update is the
685
+ // newest general one, and update only moves forward, so nothing rolls back.
686
+ // Step 3 records what was already placed, so it does not re-check the channel.
627
687
  router.post('/store/modules/:key/acquire', auth.requireBuilder,
628
688
  asyncHandler('POST /store/modules/:key/acquire', async (req, res) => {
629
689
  const { key } = req.params;
@@ -635,7 +695,7 @@ module.exports = function buildModulesRouter() {
635
695
  if (wanted != null && !parseVersion(String(wanted))) {
636
696
  return res.fail('bad_version', { status: 400, message: 'A version is exact X.Y.Z.' });
637
697
  }
638
- const r = await resolveAcquirable(key, { version: wanted || null });
698
+ const r = await resolveAcquirable(key, { version: wanted || null, channels: (await projectChannels())(key) });
639
699
  if (!r.ok) return res.fail(r.code, { status: r.status, message: r.message });
640
700
 
641
701
  const holder = { moduleKey: key, holderKind: 'builder', holderRef: String(req.builder.id) };
@@ -660,7 +720,7 @@ module.exports = function buildModulesRouter() {
660
720
  tarball_sha256: v.tarball_sha256,
661
721
  tree_sha256: v.tree_sha256,
662
722
  tarball_bytes: Number(v.tarball_bytes),
663
- channel: 'general',
723
+ channel: v.channel,
664
724
  entitlement: held.entitlement,
665
725
  });
666
726
  }));
@@ -671,7 +731,7 @@ module.exports = function buildModulesRouter() {
671
731
  if (!KEY_RE.test(key) || !parseVersion(version)) {
672
732
  return res.fail('bad_request', { status: 400, message: 'A module key is lowercase kebab-case and a version is exact X.Y.Z.' });
673
733
  }
674
- const r = await resolveAcquirable(key, { version });
734
+ const r = await resolveAcquirable(key, { version, channels: (await projectChannels())(key) });
675
735
  if (!r.ok) return res.fail(r.code, { status: r.status, message: r.message });
676
736
  const held = await checkEntitlement({ moduleKey: key, holderKind: 'builder', holderRef: String(req.builder.id) });
677
737
  if (!held.entitled) {
@@ -755,7 +815,9 @@ module.exports = function buildModulesRouter() {
755
815
 
756
816
  // The newest published version of each named module — what `bongos upgrade`
757
817
  // compares an instance's installed store modules against to report the updates
758
- // available (task 1003787). `?keys=a,b,c`, at most STORE_LATEST_MAX_KEYS.
818
+ // available (task 1003787). `?keys=a,b,c`, at most STORE_LATEST_MAX_KEYS. Only
819
+ // versions on a channel this project takes count (task 1003805), so it reports
820
+ // exactly what `bongos module update` would hand over.
759
821
  //
760
822
  // rank: any-builder — a read of public store metadata (version numbers and the
761
823
  // core range each needs, the same facts the how-to page shows). It takes no
@@ -768,7 +830,7 @@ module.exports = function buildModulesRouter() {
768
830
  if (!keys.length || keys.length > STORE_LATEST_MAX_KEYS || !keys.every((k) => KEY_RE.test(k))) {
769
831
  return res.fail('bad_request', { status: 400, message: `keys is a comma-separated list of 1 to ${STORE_LATEST_MAX_KEYS} module keys (lowercase kebab-case).` });
770
832
  }
771
- const latest = await latestVersions(keys);
833
+ const latest = await latestVersions(keys, { channelsFor: await projectChannels() });
772
834
  res.set('Cache-Control', 'no-store');
773
835
  res.json({ latest });
774
836
  }));