@bongos/core 1.20.52 → 1.20.54

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 (35) hide show
  1. package/.bongos-core.json +61 -36
  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 +4 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/api/openapi.json +92 -3
  8. package/docs/api-reference.md +3 -2
  9. package/docs/copy-inventory.md +37 -27
  10. package/docs/copy-registry.json +119 -29
  11. package/docs/module-api-changelog.md +4 -0
  12. package/docs/page-readings.json +58 -46
  13. package/migrations/core_267_page_tweak_author_of_record.sql +41 -0
  14. package/modules/government/board.js +15 -21
  15. package/modules/government/recommendations.js +218 -0
  16. package/modules/government/routes/government.js +47 -1
  17. package/modules/hall-ui/public/builders.js +3 -1
  18. package/modules/hall-ui/public/government.js +125 -2
  19. package/modules/lifecycle/db-analytics.js +6 -1
  20. package/modules/lifecycle/db-ship.js +10 -3
  21. package/modules/lifecycle/page-tweak-artist.js +86 -0
  22. package/modules/lifecycle/page-tweak-reads.js +7 -5
  23. package/modules/lifecycle/routes/tasks.js +3 -0
  24. package/modules/sessions/db.js +8 -0
  25. package/modules/status-ui/public/status.js +3 -1
  26. package/package-lock.json +2 -2
  27. package/package.json +1 -1
  28. package/release-notes.json +12 -0
  29. package/src/bongos/route-rank-check.js +1 -0
  30. package/src/module-api.js +1 -1
  31. package/tests/government_charter_library.mjs +11 -5
  32. package/tests/government_constitution_view.mjs +4 -1
  33. package/tests/government_recommendations.mjs +119 -0
  34. package/tests/government_routes.mjs +35 -0
  35. package/tests/page_tweak_artist.mjs +236 -0
@@ -0,0 +1,86 @@
1
+ // modules/lifecycle/page-tweak-artist.js — WHO a page-tweak round belongs to,
2
+ // written once (task 1004432 / WA6.RP02, ADR 0341 D10).
3
+ //
4
+ // A round has two people. The ARTIST wrote the words in the studio and holds the
5
+ // round's WEB claim (no worktree, no creating session: the claim the submit
6
+ // released). The APPLIER is the session that put the words into the page, on an
7
+ // ordinary CLI claim in a worktree. The artist is the author of record: they are
8
+ // the one paid (economy's awardArtistPageCredit), the one the changelog names
9
+ // (page-tweak-reads.js), and, since this task, the one `tasks.shipped_by` names
10
+ // once the round ships. The applier is a helper.
11
+ //
12
+ // Pure: SQL fragments and nothing else, so every reader takes the rule from here
13
+ // instead of re-typing it. Economy cannot require this file (a module reaches
14
+ // another only through a port), so credits.js keeps its own copy of the
15
+ // predicate and tests/page_tweak_artist.mjs holds the two strings together.
16
+
17
+ 'use strict';
18
+
19
+ const PAGE_TWEAK_SOURCE = 'page-tweak';
20
+
21
+ // A WEB claim: the artist's. Neither a worktree (a CLI applier) nor a creating
22
+ // session (an agent-made claim) is on it.
23
+ function webClaimPredicate(alias = 'c') {
24
+ return `${alias}.worktree_name IS NULL AND ${alias}.creator_session_id IS NULL`;
25
+ }
26
+
27
+ // The newest claim first; the id breaks a same-instant tie the same way in
28
+ // every reader.
29
+ function latestFirst(alias = 'c') {
30
+ return `${alias}.claimed_at DESC, ${alias}.id DESC`;
31
+ }
32
+
33
+ // The round's artist: the builder of its latest web claim, as a scalar
34
+ // subquery over `taskIdExpr` (a column or a bind parameter). NULL when the
35
+ // round never had a web claim, which a caller treats as "no artist".
36
+ function artistIdSql(taskIdExpr) {
37
+ return `(SELECT c.builder_id FROM claims c
38
+ WHERE c.task_id = ${taskIdExpr} AND ${webClaimPredicate('c')}
39
+ ORDER BY ${latestFirst('c')}
40
+ LIMIT 1)`;
41
+ }
42
+
43
+ // THE AUTHOR OF RECORD, as shipTask writes it (db-ship.js). Everywhere shipped
44
+ // work is counted or listed reads tasks.shipped_by: the profile, the shipped
45
+ // count, the activity log, the status page's recent list, streaks,
46
+ // achievements, the hub rollup. For an ordinary task that is the claim holder,
47
+ // set at confirm, and this keeps it (COALESCE with `fallbackParam`, the caller's
48
+ // builder). For a page tweak it is the ARTIST, written at the SHIPPED flip and
49
+ // nowhere earlier: until a round ships, shipped_by still names the applier,
50
+ // because the rebase gate (needsRebaseTasksForBuilder, status='confirmed') must
51
+ // refuse the session that can actually rebase, never an artist with no
52
+ // worktree. A round with no web claim keeps its applier. Used inside
53
+ // `UPDATE tasks`, so the row is `tasks`.
54
+ function shippedBySql(fallbackParam) {
55
+ return `CASE WHEN source = '${PAGE_TWEAK_SOURCE}'
56
+ THEN COALESCE(${artistIdSql('tasks.id')}, shipped_by, ${fallbackParam})
57
+ ELSE COALESCE(shipped_by, ${fallbackParam}) END`;
58
+ }
59
+
60
+ // The helper beside a shipped row, for a list that shows people. The helper is
61
+ // the round's APPLIER, the builder of its latest WORKTREE claim: display only
62
+ // ("with help from"), never attribution or pay. A LEFT JOIN
63
+ // LATERAL yielding helper_login and helper_display_name on a page tweak whose
64
+ // applier is someone other than the author of record, and nothing otherwise
65
+ // (an ordinary task, a self-applied round, a round with no worktree claim).
66
+ // `alias` is the tasks alias of the surrounding query.
67
+ //
68
+ // THE COST, since it runs once per row of a feed. The source test sits inside
69
+ // the claims scan and names only the outer row, so Postgres plans it as a
70
+ // one-time filter: an ordinary task reads no claim at all. A page tweak's lookup
71
+ // is idx_claims_task_all (core_263, claims (task_id)) plus a sort of that one
72
+ // task's few claims, and the feeds are LIMITed.
73
+ function helperJoinSql(alias = 't') {
74
+ return `LEFT JOIN LATERAL (
75
+ SELECT hb.github_login AS helper_login, hb.display_name AS helper_display_name
76
+ FROM (SELECT c.builder_id FROM claims c
77
+ WHERE ${alias}.source = '${PAGE_TWEAK_SOURCE}'
78
+ AND c.task_id = ${alias}.id AND c.worktree_name IS NOT NULL
79
+ ORDER BY ${latestFirst('c')}
80
+ LIMIT 1) ap
81
+ JOIN builders hb ON hb.id = ap.builder_id
82
+ WHERE ap.builder_id IS DISTINCT FROM ${alias}.shipped_by
83
+ ) helper ON true`;
84
+ }
85
+
86
+ module.exports = { PAGE_TWEAK_SOURCE, webClaimPredicate, latestFirst, artistIdSql, shippedBySql, helperJoinSql };
@@ -13,6 +13,7 @@
13
13
 
14
14
  const api = require('../../src/module-api');
15
15
  const hold = require('./page-tweak-hold');
16
+ const artistRule = require('./page-tweak-artist');
16
17
 
17
18
  // Every page-tweak task, oldest first, with what the round state (D7) and the
18
19
  // changelog (D8) need beside the task itself:
@@ -35,9 +36,10 @@ const hold = require('./page-tweak-hold');
35
36
  // * the active claim is a plain join: uniq_active_claim_per_task allows at
36
37
  // most one row per task, and its partial index serves it.
37
38
  // * the latest web claim is ONE pass over claims (the `web` CTE, DISTINCT ON
38
- // per task), joined once, rather than a correlated subquery per task:
39
- // claims has no full index on task_id, so a per-row LATERAL would scan it
40
- // once per round.
39
+ // per task), joined once, rather than a correlated subquery per task.
40
+ // (When this was written claims had no full index on task_id; core_263 has
41
+ // since added idx_claims_task_all, so a per-task lookup is an index probe.
42
+ // The single pass stays: it is still the cheaper plan for every round at once.)
41
43
  //
42
44
  // `pageId` narrows it to ONE page's rounds (task 1004319 / TW08): the draft
43
45
  // autosave runs this on every debounced save, and a page's rounds are a handful
@@ -52,8 +54,8 @@ async function listPageTweakTasks({ pageId = null } = {}) {
52
54
  ), web AS (
53
55
  SELECT DISTINCT ON (c.task_id) c.task_id, c.builder_id
54
56
  FROM claims c JOIN pt ON pt.id = c.task_id
55
- WHERE c.worktree_name IS NULL AND c.creator_session_id IS NULL
56
- ORDER BY c.task_id, c.claimed_at DESC, c.id DESC
57
+ WHERE ${artistRule.webClaimPredicate('c')}
58
+ ORDER BY c.task_id, ${artistRule.latestFirst('c')}
57
59
  )
58
60
  SELECT t.id, t.status, t.title, t.description, t.source_ref, t.touches,
59
61
  t.shipped_at, t.created_at, t.updated_at,
@@ -33,6 +33,7 @@ const { depRefusalRow } = require('../dead-deps.js');
33
33
  // quality verdict rather than an outage or an errored worker (task 1002652).
34
34
  // The acclaim feed reads it so a grader outage never reaches a voter as 0.0.
35
35
  const { gradeQualityWhere } = require('../db-shared');
36
+ const { helperJoinSql } = require('../page-tweak-artist'); // task 1004432: a page tweak's applier as a helper
36
37
  const auth = api;
37
38
  const db = require('../db');
38
39
  const pageTweakReads = require('../page-tweak-reads'); const { HELD_NOT_ARMED } = require('../page-tweak-hold'); // ADR 0341 D7 (tests stub the read)
@@ -783,6 +784,7 @@ module.exports = function buildTasksRouter() {
783
784
  `SELECT t.id, t.title, t.value_summary, t.version_id, t.status,
784
785
  t.shipped_at, t.shipped_by,
785
786
  b.github_login, b.display_name,
787
+ helper.helper_login, helper.helper_display_name,
786
788
  COALESCE(pv.net, 0)::int AS net_votes,
787
789
  mine.vote AS my_vote,
788
790
  -- task 1002835: a karma vote is uninformed without the two facts
@@ -801,6 +803,7 @@ module.exports = function buildTasksRouter() {
801
803
  cost.total_usd AS cost_usd
802
804
  FROM tasks t
803
805
  LEFT JOIN builders b ON b.id = t.shipped_by
806
+ ${helperJoinSql('t')}
804
807
  LEFT JOIN task_grades g ON g.task_id = t.id
805
808
  LEFT JOIN LATERAL (
806
809
  SELECT SUM(amount_usd) AS total_usd
@@ -741,6 +741,10 @@ async function sessionUploadCoverage({ days, windowHours } = {}) {
741
741
  AND t.shipped_by IS NOT NULL
742
742
  AND t.shipped_at IS NOT NULL
743
743
  AND t.shipped_at >= now() - ($1 * interval '1 day')
744
+ -- task 1004432: a shipped page tweak names its ARTIST, who writes in the
745
+ -- studio and runs no session, so it can never be "covered"; the session
746
+ -- that applied it is the applier's, on an ordinary task of its own kind.
747
+ AND t.source IS DISTINCT FROM 'page-tweak'
744
748
  GROUP BY t.shipped_by, b.github_login
745
749
  ORDER BY 4 DESC`,
746
750
  [d, w]
@@ -765,6 +769,10 @@ async function shipsMissingSessionRecord({ days, windowHours, limit, builderId }
765
769
  AND t.shipped_by IS NOT NULL
766
770
  AND t.shipped_at IS NOT NULL
767
771
  AND t.shipped_at >= now() - ($1 * interval '1 day')
772
+ -- task 1004432: a shipped page tweak names its ARTIST, who writes in the
773
+ -- studio and runs no session, so it can never be "covered"; the session
774
+ -- that applied it is the applier's, on an ordinary task of its own kind.
775
+ AND t.source IS DISTINCT FROM 'page-tweak'
768
776
  AND ($3::bigint IS NULL OR t.shipped_by = $3)
769
777
  AND NOT EXISTS (
770
778
  SELECT 1
@@ -984,7 +984,9 @@ function renderShippedPage() {
984
984
  const meta = document.createElement('p');
985
985
  meta.className = 'shipped__meta';
986
986
  const by = t.display_name || t.github_login;
987
- meta.textContent = `${by ? `inscribed by ${by} · ` : ''}${fmtRelativeTime(t.shipped_at)}`;
987
+ // task 1004432: an approved page is the artist's; the session that applied it is a helper.
988
+ const helper = t.helper_display_name || t.helper_login;
989
+ meta.textContent = `${by ? `inscribed by ${by} · ` : ''}${helper ? `with help from ${helper} · ` : ''}${fmtRelativeTime(t.shipped_at)}`;
988
990
  li.appendChild(title);
989
991
  if (thumb) li.appendChild(thumb);
990
992
  li.appendChild(meta);
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.52",
3
+ "version": "1.20.54",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.20.52",
9
+ "version": "1.20.54",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.52",
3
+ "version": "1.20.54",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -8429,5 +8429,17 @@
8429
8429
  "id": "1004475",
8430
8430
  "text": "Equity or shares is now also greyed out for a single-owner company and for a research entity, each with its own short reason. Only a regular for-profit company or a division of a corporation is offered equity. As before, it's"
8431
8431
  }
8432
+ ],
8433
+ "1.20.53": [
8434
+ {
8435
+ "id": "1004432",
8436
+ "text": "An approved page now counts as the artist's own work everywhere the project shows who did what: their profile, their shipped count, their activity, the status page and the leaderboard. Before, the page was credited to the sess"
8437
+ }
8438
+ ],
8439
+ "1.20.54": [
8440
+ {
8441
+ "id": "1004400",
8442
+ "text": "The Government page now recommends a starting government and a starting set of ranks for your kind of project (game, research, business, non-profit) and its size. It only ever recommends a form you can adopt today. A team of a"
8443
+ }
8432
8444
  ]
8433
8445
  }
@@ -253,6 +253,7 @@ const GOVERNMENT_PINS = {
253
253
  'PUT /government/ranks/:rankKey/permissions/:permissionKey': 'perm:government.manage',
254
254
  'DELETE /government/ranks/:rankKey/permissions/:permissionKey': 'perm:government.manage',
255
255
  'POST /government/ranks/:rankKey/reset': 'perm:government.manage',
256
+ 'POST /government/rank-sets/apply': 'perm:government.manage',
256
257
  'POST /government/assignments': 'perm:government.manage',
257
258
  'DELETE /government/assignments/:builderId/:rankKey': 'perm:government.manage',
258
259
  // The Board Room's first route (R08 of goal 1000069 — the R04-carried pin).
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.20.52'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.54'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -24,11 +24,16 @@ test('the ladder bands track exactly the thresholds the synthesis names', () =>
24
24
  assert.equal(recommendedCharter(2).key, 'monarchy', 'this project today: two builders → the founding monarchy');
25
25
  assert.equal(recommendedCharter(3).key, 'direct-democracy', 'the circle rung starts at 3');
26
26
  assert.equal(recommendedCharter(8).key, 'direct-democracy');
27
- assert.equal(recommendedCharter(9).key, 'presidential', '~9: name an executive');
28
- assert.equal(recommendedCharter(49).key, 'presidential');
27
+ // ~9 and ~150 are where the ladder's own shapes (Presidential, Federation)
28
+ // cannot be adopted yet, so the recommendation names the nearest form that
29
+ // can — and says what is coming in `later` (task 1004400).
30
+ assert.equal(recommendedCharter(9).key, 'timocratic-republic', '~9: the circle gets too big');
31
+ assert.equal(recommendedCharter(9).later.key, 'presidential');
32
+ assert.equal(recommendedCharter(49).key, 'timocratic-republic');
29
33
  assert.equal(recommendedCharter(50).key, 'foundation', '~50: written charter + council');
30
34
  assert.equal(recommendedCharter(149).key, 'foundation');
31
- assert.equal(recommendedCharter(150).key, 'federation', '~150: adjudication separates — federate');
35
+ assert.equal(recommendedCharter(150).key, 'foundation', '~150: a council carries it until federation exists');
36
+ assert.equal(recommendedCharter(150).later.key, 'federation');
32
37
  assert.equal(recommendedCharter(0).key, 'monarchy', 'a zero/unknown headcount fails to the founding shape');
33
38
  assert.equal(recommendedCharter('nonsense').key, 'monarchy');
34
39
  });
@@ -74,7 +79,7 @@ test('the library presents all seven structures with honest selectable marks (cl
74
79
  const entry = b.split('{ key:')[0];
75
80
  assert.ok(entry.includes('how:'), 'a selectable structure names its configuration');
76
81
  }
77
- assert.match(client, /recommended at your headcount/, 'the one live datum is rendered');
82
+ assert.match(client, /recommended for this type and size/, 'the one live datum is rendered');
78
83
  assert.match(client, /c\.key === recommendedKey/, "the pill is driven by the SERVER's recommendation, never re-derived client-side");
79
84
  for (const t of ['Locked', 'Laddered', 'Scheduled']) {
80
85
  assert.ok(client.includes(`name: '${t}'`), `trajectory ${t} present`);
@@ -171,7 +176,8 @@ test('the recommendation names a charter the config can actually express', () =>
171
176
  const rec = recommendedCharter(n);
172
177
  const charter = by[rec.key];
173
178
  assert.ok(charter, `headcount ${n} recommends "${rec.key}", which is not in the library`);
174
- if (charter.selectable) {
179
+ assert.ok(charter.selectable, `headcount ${n} recommends "${rec.key}", which cannot be adopted (task 1004400)`);
180
+ {
175
181
  assert.ok(charter.predicate !== undefined,
176
182
  `headcount ${n} recommends selectable "${rec.key}" — it must carry a checkable predicate`);
177
183
  }
@@ -82,7 +82,10 @@ test('the read rides the vote atom, and the client renders the SERVER value (sou
82
82
  assert.equal(rrc.MODULE_EXPECTED_RANKS.government['GET /government/constitution'],
83
83
  `${rrc.PERM_PIN_PREFIX}board.vote.cast`);
84
84
  const client = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'government.js'), 'utf8');
85
- assert.match(client, /fetchJson\('\/government\/constitution'\)/, 'the panel loads the resolved value');
85
+ // Since task 1004400 the path may carry ?type= / &headcount= (advice-only
86
+ // inputs for the recommendation), so constitutionPath() builds it.
87
+ assert.match(client, /fetchJson\(constitutionPath\(\)\)/, 'the panel loads the resolved value');
88
+ assert.match(client, /`\/government\/constitution\$\{qs/, 'from the constitution read, nowhere else');
86
89
  assert.ok(!/'first_ratifier'[^]*hardcod/i.test(client), 'sanity');
87
90
  assert.match(client, /loadConstitution\(\)/, 'the panel loads for everyone the page admits');
88
91
  });
@@ -0,0 +1,119 @@
1
+ // tests/government_recommendations.mjs — recommended governments and base-rank
2
+ // sets by project TYPE and SIZE (task 1004400, criterion wa7-government).
3
+ //
4
+ // The rules held here, each because the live site once broke it:
5
+ // • a recommendation only ever names a charter that can be ADOPTED — the
6
+ // ladder used to recommend Presidential (~9) and Federation (150+), and the
7
+ // live project, at 10 builders, was told to adopt one nobody can;
8
+ // • the type really changes the answer, not just the wording;
9
+ // • every grant a recommended rank carries is one a custom rank can hold;
10
+ // • a Foundation is always recommended together with the council rank it is
11
+ // governed through, and that rank makes a valid charter proposal.
12
+ import assert from 'node:assert/strict';
13
+ import { test } from 'node:test';
14
+ import { createRequire } from 'node:module';
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+ import { fileURLToPath } from 'node:url';
18
+
19
+ const require = createRequire(import.meta.url);
20
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
21
+ const reco = require(path.join(ROOT, 'modules', 'government', 'recommendations.js'));
22
+ const charter = require(path.join(ROOT, 'modules', 'government', 'charter.js'));
23
+ const catalog = require(path.join(ROOT, 'modules', 'government', 'catalog.js'));
24
+ const provisioning = require(path.join(ROOT, 'modules', 'provisioning', 'provisioning.js'));
25
+
26
+ const TYPES = reco.PROJECT_TYPES.map((t) => t.key);
27
+ // Every band edge, plus nonsense — the edges are where a ladder goes wrong.
28
+ const HEADCOUNTS = [0, 1, 2, 3, 8, 9, 10, 49, 50, 149, 150, 151, 1000, -4, 'nonsense', null];
29
+ const CUSTOM_RANK = { is_system: false, seeded_rank: null };
30
+
31
+ // The hall's charter library says which charters are selectable; read it from
32
+ // the client source, since browser code cannot be required.
33
+ function hallSelectableKeys() {
34
+ const client = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'government.js'), 'utf8');
35
+ const lib = client.slice(client.indexOf('const CHARTERS'), client.indexOf('const TRAJECTORIES'));
36
+ return [...lib.matchAll(/key: '([a-z-]+)', name: '[^']+', selectable: true/g)].map((m) => m[1]);
37
+ }
38
+
39
+ test('the project types are exactly the ones a project declares at creation', () => {
40
+ assert.deepEqual(TYPES, provisioning.PROJECT_TYPES);
41
+ });
42
+
43
+ test('every type and size recommends a charter that can be adopted today', () => {
44
+ const available = charter.FORMS.filter((f) => f.available).map((f) => f.key);
45
+ const selectable = hallSelectableKeys();
46
+ assert.ok(selectable.length >= 4, 'the hall library parse found its selectable charters');
47
+ for (const type of [...TYPES, undefined, 'spaceship']) {
48
+ for (const n of HEADCOUNTS) {
49
+ const c = reco.recommendedCharter(n, type);
50
+ assert.ok(available.includes(c.key), `${type}@${n} recommends "${c.key}", which charter.js cannot adopt`);
51
+ assert.ok(selectable.includes(c.key), `${type}@${n} recommends "${c.key}", which the hall marks not selectable`);
52
+ if (c.later) assert.ok(!available.includes(c.later.key), 'a `later` shape is one that cannot be adopted yet');
53
+ }
54
+ }
55
+ });
56
+
57
+ test('the live case: a project of 10 is no longer told to adopt Presidential', () => {
58
+ const c = reco.recommendedCharter(10, 'not-sure');
59
+ assert.equal(c.key, 'timocratic-republic');
60
+ assert.equal(c.later.key, 'presidential', 'what the ladder would name is still said, as coming later');
61
+ });
62
+
63
+ test('the project type changes the answer, not just the words', () => {
64
+ assert.equal(reco.recommendedCharter(20, 'game').key, 'timocratic-republic');
65
+ assert.equal(reco.recommendedCharter(20, 'non-profit').key, 'foundation', 'a non-profit is governed by trustees early');
66
+ assert.equal(reco.recommendedRankSet('game', 5).ranks[0].rank_key, 'playtester');
67
+ assert.equal(reco.recommendedRankSet('research', 5).ranks[0].rank_key, 'peer-reviewer');
68
+ assert.equal(reco.recommendedCharter(20, 'spaceship').type, 'not-sure', 'an unknown type reads as not sure');
69
+ });
70
+
71
+ test('the rank set grows with size', () => {
72
+ const sizes = [1, 5, 20, 80, 200].map((n) => reco.recommendedRankSet('business', n).ranks.length);
73
+ assert.deepEqual(sizes, [0, 1, 2, 3, 3]);
74
+ });
75
+
76
+ test('every recommended rank is creatable and every grant is one a custom rank can hold', () => {
77
+ for (const type of TYPES) {
78
+ for (const n of HEADCOUNTS) {
79
+ for (const r of reco.recommendedRankSet(type, n).ranks) {
80
+ assert.match(r.rank_key, /^[a-z][a-z0-9-]*$/, `${r.rank_key} must be a slug the rank API accepts`);
81
+ assert.ok(r.rank_key.length >= 2 && r.rank_key.length <= 64);
82
+ assert.ok(!catalog.RANK_ORDER.includes(r.rank_key), `${r.rank_key} must not collide with a standard rank`);
83
+ assert.ok(r.label && r.description);
84
+ assert.ok(r.permissions.length > 0);
85
+ for (const key of r.permissions) {
86
+ assert.ok(catalog.byKey(key), `${r.rank_key}: ${key} is not in the catalog`);
87
+ assert.ok(catalog.isGrantableTo(key, CUSTOM_RANK), `${r.rank_key}: ${key} cannot be granted to a custom rank`);
88
+ }
89
+ }
90
+ }
91
+ }
92
+ });
93
+
94
+ test('a recommended Foundation always comes with its council rank, and that rank makes a valid charter', () => {
95
+ const live = { membership: 'rank:archon', pass_rule: 'first_ratifier', window_minutes: null, close_early_on_full_turnout: true };
96
+ let foundations = 0;
97
+ for (const type of TYPES) {
98
+ for (const n of HEADCOUNTS) {
99
+ const c = reco.recommendedCharter(n, type);
100
+ if (c.key !== 'foundation') { assert.equal(c.council_rank, null); continue; }
101
+ foundations += 1;
102
+ const set = reco.recommendedRankSet(type, n).ranks.map((r) => r.rank_key);
103
+ assert.ok(set.includes(c.council_rank), `${type}@${n}: the rank set must carry ${c.council_rank}`);
104
+ const built = charter.buildCharterProposal(live, { form: 'foundation', councilRank: c.council_rank });
105
+ assert.ok(built.ok, `${type}@${n}: ${c.council_rank} must make a valid Foundation (${built.reason})`);
106
+ }
107
+ }
108
+ assert.ok(foundations > 0, 'the walk reached a Foundation');
109
+ });
110
+
111
+ test('recommendationsFor carries both answers and the vocabulary to ask about another', () => {
112
+ const r = reco.recommendationsFor({ type: 'research', headcount: 60 });
113
+ assert.equal(r.type, 'research');
114
+ assert.equal(r.size, 'organisation');
115
+ assert.equal(r.charter.key, 'foundation');
116
+ assert.equal(r.rank_set.ranks.length, 3);
117
+ assert.equal(r.project_types.length, TYPES.length);
118
+ for (const b of r.size_bands) assert.equal(reco.sizeBandFor(b.example).key, b.key, 'each size option lands in its own band');
119
+ });
@@ -60,6 +60,7 @@ test('the routes file declares the full R110 surface', () => {
60
60
  'POST /government/ranks/:rankKey/reset',
61
61
  'POST /government/assignments',
62
62
  'DELETE /government/assignments/:builderId/:rankKey',
63
+ 'POST /government/rank-sets/apply',
63
64
  ];
64
65
  for (const key of wanted) assert.ok(declared.has(key), `missing route ${key}`);
65
66
  });
@@ -203,6 +204,7 @@ test('without government.manage EVERY route 403s (reads included)', async () =>
203
204
  ['DELETE', '/government/ranks/r/permissions/task.create'],
204
205
  ['POST', '/government/assignments'],
205
206
  ['DELETE', '/government/assignments/7/r'],
207
+ ['POST', '/government/rank-sets/apply'],
206
208
  ]) {
207
209
  const r = await call(method, p, method === 'GET' ? undefined : {});
208
210
  assert.equal(r.status, 403, `${method} ${p} must deny`);
@@ -243,6 +245,39 @@ test('GET /government/assignments returns builder↔role rows', async () => {
243
245
  assert.equal(r.json.assignments[0].rank_key, 'metic');
244
246
  });
245
247
 
248
+ // --- recommended rank sets (task 1004400) -------------------------------------
249
+
250
+ test('POST /government/rank-sets/apply creates the missing ranks with their grants and leaves existing ones alone', async () => {
251
+ const realGet = stub.getRankByKey;
252
+ // 'design-lead' already exists: it must be skipped, never re-granted.
253
+ stub.getRankByKey = async (k) => (k === 'design-lead' ? { rank_key: k, label: 'Mine', is_system: false, seeded_rank: null } : null);
254
+ try {
255
+ const r = await call('POST', '/government/rank-sets/apply', { type: 'game', headcount: 20 });
256
+ assert.equal(r.status, 200);
257
+ assert.deepEqual(r.json.skipped, ['design-lead']);
258
+ assert.deepEqual(r.json.created.map((c) => c.rank_key), ['playtester']);
259
+ const creates = calls.filter(([k]) => k === 'createRank');
260
+ assert.equal(creates.length, 1);
261
+ assert.equal(creates[0][1].createdBy, 42, 'created_by is the session builder');
262
+ const grants = calls.filter(([k]) => k === 'grantRankPermission').map(([, a]) => a);
263
+ assert.ok(grants.length > 0 && grants.every((g) => g.rankKey === 'playtester'), 'an existing rank is never re-granted');
264
+ assert.ok(grants.every((g) => catalog.isGrantableTo(g.permissionKey, { is_system: false, seeded_rank: null })));
265
+ } finally { stub.getRankByKey = realGet; }
266
+ });
267
+
268
+ test('POST /government/rank-sets/apply needs a headcount and does nothing at founding size', async () => {
269
+ const bad = await call('POST', '/government/rank-sets/apply', { type: 'game' });
270
+ assert.equal(bad.status, 400);
271
+ const realGet = stub.getRankByKey;
272
+ stub.getRankByKey = async () => null;
273
+ try {
274
+ const solo = await call('POST', '/government/rank-sets/apply', { type: 'game', headcount: 1 });
275
+ assert.equal(solo.status, 200);
276
+ assert.deepEqual(solo.json.created, []);
277
+ assert.equal(calls.filter(([k]) => k === 'createRank').length, 0);
278
+ } finally { stub.getRankByKey = realGet; }
279
+ });
280
+
246
281
  // --- create role -------------------------------------------------------------
247
282
 
248
283
  test('POST /government/ranks creates a custom role, created_by = the session builder', async () => {