@bongos/core 1.20.25 → 1.20.27

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 (41) hide show
  1. package/.bongos-core.json +92 -37
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +7 -1
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/docs/adr/0353-a-hub-invite-is-a-notice-of-the-projects-own-invite.md +1 -1
  8. package/docs/adr/0355-a-soft-limit-is-a-rule-with-a-reason-and-nobody-enforces-it.md +30 -0
  9. package/docs/adr/README.md +1 -0
  10. package/docs/api/openapi.json +137 -3
  11. package/docs/api-reference.md +4 -2
  12. package/docs/copy-inventory.md +34 -34
  13. package/docs/copy-registry.json +38 -38
  14. package/docs/module-api-changelog.md +4 -0
  15. package/docs/page-readings.json +75 -73
  16. package/modules/discord/board-broadcast.js +1 -0
  17. package/modules/government/board.js +261 -2
  18. package/modules/government/charter.js +232 -0
  19. package/modules/government/db.js +42 -1
  20. package/modules/government/migrations/government_020_genesis_stage_subject.sql +55 -0
  21. package/modules/government/routes/government.js +82 -0
  22. package/modules/hall-ui/public/board-room.js +5 -0
  23. package/modules/hall-ui/public/government.js +11 -0
  24. package/modules/platform-identity/migrations/platform_identity_028_purge_pre_notice_invites.sql +65 -0
  25. package/modules/provisioning/planet-physics.js +154 -0
  26. package/modules/provisioning/provisioning.js +29 -9
  27. package/modules/provisioning/routes/body-validators.js +25 -7
  28. package/modules/provisioning/routes/provisioning.js +8 -3
  29. package/modules/provisioning/screening.js +62 -0
  30. package/modules/provisioning/soft-limits.js +146 -0
  31. package/package-lock.json +2 -2
  32. package/package.json +1 -1
  33. package/release-notes.json +20 -0
  34. package/scripts/gds/run-unit-tests.js +4 -0
  35. package/src/bongos/route-rank-check.js +6 -0
  36. package/src/module-api.js +1 -1
  37. package/tests/government_board_genesis_stage.mjs +305 -0
  38. package/tests/government_charter_forms.mjs +283 -0
  39. package/tests/government_seed.mjs +1 -0
  40. package/tests/pending_invite_purge_db.mjs +129 -0
  41. package/tests/provisioning_planet_physics.mjs +377 -0
@@ -108,6 +108,18 @@ const governmentPort = {
108
108
  // is no counterpart that records an outcome, and there must never be one: an
109
109
  // outcome is written by a close, never by the module reading it.
110
110
  boardPassForIdea: board.boardPassForIdea,
111
+ // task 1004429 (BV2.PS04): a finished genesis stage is put to the board — the
112
+ // genesis home (task 1004423) calls this when a stage's tasks are done. The
113
+ // finished-tasks gate lives inside board.js; the item is an ordinary one under
114
+ // the constitution in force. And the founding band's read: per stage, what it
115
+ // is waiting on. A pass is announced as `board.genesis_stage.passed`.
116
+ openGenesisStageClose: board.openGenesisStageClose,
117
+ // task 1004427 (BV2.PS13): the genesis charter + legislation choice, filed as
118
+ // an ordinary amendment under the constitution in force — for the genesis
119
+ // home, which renders the stages from GET /government/constitution's
120
+ // `charter` block. Opening a sitting decides nothing; the vote does.
121
+ proposeCharter: board.proposeCharter,
122
+ genesisStageStatus: board.genesisStageStatus,
111
123
  };
112
124
 
113
125
  function registerSeams() {
@@ -460,6 +472,19 @@ module.exports = function buildGovernmentRouter() {
460
472
  }
461
473
  });
462
474
 
475
+ // rank: perm board.vote.cast — the founding band's read (task 1004429): each
476
+ // genesis stage's latest sitting, and who it is still waiting on under the
477
+ // item's own snapshot. Same atom as the room — seeing who is still to vote is
478
+ // part of the open ballot (§9). A read; nothing here opens or decides.
479
+ router.get('/government/board/genesis-stages', api.requireBuilder, api.requirePermission('board.vote.cast'), async (req, res) => {
480
+ try {
481
+ res.json(await board.genesisStageStatus());
482
+ } catch (err) {
483
+ log.error('[gds] GET /government/board/genesis-stages', err);
484
+ res.fail('genesis_stage_read_failed', { status: 500, message: 'internal error' });
485
+ }
486
+ });
487
+
463
488
  // rank: perm board.item.open — put an AMENDMENT to the board (R14 of goal
464
489
  // 1000069, ADR 0175 §7). The board's other subject never comes through here:
465
490
  // a Full Idea's window opens AUTOMATICALLY on grade pass (R07), so requesting
@@ -516,6 +541,63 @@ module.exports = function buildGovernmentRouter() {
516
541
  }
517
542
  });
518
543
 
544
+ // rank: perm board.item.open — the genesis charter + legislation choice (task
545
+ // 1004427, spec D10): a form of government and/or its two live settings
546
+ // (franchise, pass rule), built into a whole constitution on top of the live
547
+ // one and filed through the SAME amendment path as the route above — the same
548
+ // atom, the same strict validation, the same self-removal guard. Nothing here
549
+ // changes the constitution; the sitting it opens is what decides.
550
+ router.post('/government/board/charter', api.requireBuilder, api.requirePermission('board.item.open'), async (req, res) => {
551
+ if (validateOrRespond(req, res, {
552
+ form: { type: 'string', maxLength: 64 },
553
+ franchise: { type: 'string', maxLength: 32 },
554
+ council_rank: { type: 'string', maxLength: RANK_KEY_MAX },
555
+ pass_rule: { type: 'string', maxLength: 32 },
556
+ rationale_md: { type: 'string', maxLength: 5000 },
557
+ acknowledge_self_removal: { type: 'boolean' },
558
+ })) return;
559
+ const body = req.body || {};
560
+ if (body.window_minutes !== undefined && body.window_minutes !== null && !Number.isInteger(body.window_minutes)) {
561
+ return res.fail('invalid_proposal', 400, { reason: 'bad_window_minutes', expected: 'null (no clock) or a whole number of minutes' });
562
+ }
563
+ try {
564
+ const out = await board.proposeCharter({
565
+ form: body.form ?? undefined,
566
+ franchise: body.franchise ?? undefined,
567
+ councilRank: body.council_rank ?? undefined,
568
+ passRule: body.pass_rule ?? undefined,
569
+ windowMinutes: body.window_minutes,
570
+ rationaleMd: body.rationale_md ?? null,
571
+ proposedBy: req.builder.id,
572
+ acknowledgeSelfRemoval: body.acknowledge_self_removal === true,
573
+ });
574
+ if (!out.ok) {
575
+ if (out.reason === 'proposer_would_lose_their_seat') {
576
+ return res.fail('proposer_would_lose_their_seat', 409, {
577
+ membership: out.membership,
578
+ describes: out.describes,
579
+ what_this_means: out.what_this_means,
580
+ hint: out.hint,
581
+ retry_with: { acknowledge_self_removal: true },
582
+ });
583
+ }
584
+ if (out.reason === 'no_change') {
585
+ return res.fail('no_change', 409, { reason: 'the constitution in force already has these settings' });
586
+ }
587
+ return res.fail('invalid_charter', 400, {
588
+ reason: out.reason,
589
+ ...(out.field ? { field: out.field } : {}),
590
+ ...(out.expected ? { expected: out.expected } : {}),
591
+ ...(out.rank_key ? { rank_key: out.rank_key } : {}),
592
+ });
593
+ }
594
+ res.status(201).json({ ok: true, amendment: out.amendment, item: out.item, reads_as: out.reads_as });
595
+ } catch (err) {
596
+ log.error('[gds] POST /government/board/charter', err);
597
+ res.fail('charter_failed', { status: 500, message: 'internal error' });
598
+ }
599
+ });
600
+
519
601
  // rank: perm board.vote.cast — cast a vote on an open board item (R08 of goal
520
602
  // 1000069, ADR 0175). The atom FLOORS AT XENOS deliberately (the R04 carry-
521
603
  // note): the permission is only the coarse gate, and the real wall is the
@@ -132,6 +132,11 @@
132
132
  const title = item.subject ? item.subject.title : `idea ${item.subject_id}`;
133
133
  return `<a href="/#/idea/${escapeHtml(String(item.subject_id))}">${escapeHtml(title)}</a> ${pill('quiet', 'Full Idea')}`;
134
134
  }
135
+ // task 1004429: the third subject — closing a founding stage.
136
+ if (item.subject_type === 'genesis_stage') {
137
+ const label = item.subject ? item.subject.label : `stage ${item.subject_id}`;
138
+ return `Close the ${escapeHtml(label)} stage ${pill('quiet', 'genesis stage')}`;
139
+ }
135
140
  return `Constitutional amendment ${pill('quiet', 'amendment')}`;
136
141
  }
137
142
 
@@ -678,6 +678,16 @@
678
678
  // hand" note; PASS_RULE_NAME sits beside its sentence-length twin
679
679
  // PASS_RULE_PLAIN there, so the two renderings of one pass rule cannot drift.
680
680
 
681
+ // The form of government the constitution in force READS AS (task 1004427):
682
+ // the real name and its plain explanation, or Custom. The server does the
683
+ // reading (charter.js readCharter) — the name follows the settings, so this
684
+ // page never guesses it. An older server without the block is said plainly.
685
+ function formOfGovernmentSentence(charter) {
686
+ const r = charter && charter.reads_as;
687
+ if (!r || !r.name) return 'Not available from this server yet.';
688
+ return `<strong>${escapeHtml(r.name)}</strong>. ${escapeHtml(r.explanation || '')}`;
689
+ }
690
+
681
691
  function dialRow(name, sentenceHtml) {
682
692
  return `<li class="ov-row"><div class="ov-row__main"><span class="ov-row__title">${escapeHtml(name)}</span><p class="ov-row__sub">${sentenceHtml}</p></div></li>`;
683
693
  }
@@ -829,6 +839,7 @@
829
839
  live.innerHTML = `
830
840
  ${divergenceHtml(data.divergence)}
831
841
  <ul class="ov-rows">
842
+ ${dialRow('Form of government', formOfGovernmentSentence(data.charter))}
832
843
  ${dialRow('Who sits on the board', membershipSentence(b))}
833
844
  ${dialRow('What counts as passing', passRuleSentence(b))}
834
845
  ${dialRow('How long a sitting runs', windowSentence(b, data.clock))}
@@ -0,0 +1,65 @@
1
+ -- platform_identity_028_purge_pre_notice_invites.sql — delete the `pending` rows the
2
+ -- OLD hub invite wrote (task 1004408, the owner's call on ADR 0353's open consequence).
3
+ --
4
+ -- WHY. Before ADR 0353 the hub's POST /projects/invite wrote a `pending` membership
5
+ -- row and nothing on the project. My Projects renders that row as "Open project →",
6
+ -- and on any door but `open` (the default is `apply`) it leads to the project's own
7
+ -- gate, which never heard of the invite: a locked door. Since ADR 0353 a `pending`
8
+ -- row is a NOTICE that only the project writes (POST /sso/invites/notify), after it
9
+ -- wrote its own `invited` row. So a pending row is a dead end exactly when it
10
+ -- predates the hub's first run of ADR 0353 code.
11
+ --
12
+ -- THE CUTOFF IS THAT FIRST RUN, read from the core_upgrades ledger: the earliest
13
+ -- upgrade to core 1.20.22 or later (the first release carrying ADR 0353, PR 1367).
14
+ -- No such row means the hub has never served a notice route, so every pending row
15
+ -- is an old one and all of them go. That is the expected case: the hub served
16
+ -- 1.19.1081 when this was written, and upgrade.js writes the ledger row AFTER
17
+ -- migrate, so the upgrade applying this file is not yet in the ledger. The ledger
18
+ -- row lands just after the restart, so a notice written in those few seconds would
19
+ -- also go; an old-style row cannot survive either way.
20
+ --
21
+ -- WHAT IS LOST. The owner accepted this (option (a)): an old row on an `open` door
22
+ -- did lead somewhere, and it is deleted too. That invitee can still sign in to the
23
+ -- project, and the inviter can re-invite through the project's own hall.
24
+ --
25
+ -- Touches `pending` rows only, so no real membership (owner, member, visitor) can
26
+ -- be removed. One-shot: a replay after this file is recorded does nothing, so a
27
+ -- hand-run later can never sweep up real notices. Deletes data but adds nothing
28
+ -- the previous release cannot read, so it is forward-safe (ADR 0083 §5).
29
+
30
+ BEGIN;
31
+
32
+ DO $$
33
+ DECLARE
34
+ notices_since timestamptz;
35
+ removed integer;
36
+ BEGIN
37
+ IF EXISTS (SELECT 1 FROM schema_migrations
38
+ WHERE version = 'platform_identity_028_purge_pre_notice_invites') THEN
39
+ RETURN;
40
+ END IF;
41
+
42
+ -- Only a well-formed x.y.z counts (any suffix ignored); the digit caps keep the
43
+ -- int cast from overflowing, so a malformed ledger row can never fail the deploy.
44
+ IF to_regclass('core_upgrades') IS NOT NULL THEN
45
+ SELECT min(applied_at) INTO notices_since
46
+ FROM (SELECT applied_at,
47
+ substring(to_version FROM '^v?([0-9]{1,6}\.[0-9]{1,6}\.[0-9]{1,6})(?:[^0-9]|$)') AS xyz
48
+ FROM core_upgrades) u
49
+ WHERE xyz IS NOT NULL
50
+ AND string_to_array(xyz, '.')::int[] >= ARRAY[1, 20, 22];
51
+ END IF;
52
+
53
+ DELETE FROM platform_identity_project_memberships
54
+ WHERE membership_kind = 'pending'
55
+ AND (notices_since IS NULL OR joined_at < notices_since);
56
+ GET DIAGNOSTICS removed = ROW_COUNT;
57
+
58
+ RAISE NOTICE 'platform_identity_028: removed % pre-ADR-0353 pending invite row(s) (cutoff: %)',
59
+ removed, COALESCE(notices_since::text, 'none, the hub never ran ADR 0353');
60
+ END $$;
61
+
62
+ INSERT INTO schema_migrations (version) VALUES ('platform_identity_028_purge_pre_notice_invites')
63
+ ON CONFLICT DO NOTHING;
64
+
65
+ COMMIT;
@@ -0,0 +1,154 @@
1
+ // modules/provisioning/planet-physics.js — the seven "planet physics" answers a new
2
+ // project carries (BV2.PS02, task 1004416; goal 1000121, spec
3
+ // docs/specs/bongos-v2-project-startup.md).
4
+ //
5
+ // WHAT. The questions the startup flow asks before any technical setup, as a closed
6
+ // vocabulary the provisioning `detail` jsonb stores (the R15 dumb-store contract in
7
+ // provisioning.js: a later question costs no migration). In the canvas's order:
8
+ //
9
+ // 1 state project_state, existing_work (all that apply), main_work
10
+ // 2 geography home_country, home_region, builder_geography
11
+ // 3 ages minors_on_board
12
+ // 4 entity entity — a legal type (already formed) or an intent (not yet)
13
+ // 5 builders builders_at_start, builders_expected (the month-three estimate)
14
+ // 6 rewards reward_intents (all being considered — intent, never binding)
15
+ // 7 framing framing (A–D: managed, hybrid, local, open-source) and services
16
+ //
17
+ // THREE KINDS OF QUESTION. `one` takes a single member of its list, `many` takes an
18
+ // array of members (stored de-duplicated, in the list's own order, so two spellings of
19
+ // one answer store the same bytes), and `count` takes a whole number in a range.
20
+ // Every kind has the same two rules as `team_shape`: an unknown value is REFUSED at the
21
+ // boundary (badProjectDetail says which and why), and an explicit null un-answers.
22
+ //
23
+ // THESE ARE FACTS, NOT POLICY. Nothing here restricts what a project may do: the age
24
+ // tiers and the payout geography are enforced by nobody yet (out of scope, spec "Out
25
+ // of scope"). They are stored so the soft-limit table (soft-limits.js) can grey an
26
+ // option with a reason, and so the later enforcement has the facts it will need.
27
+ //
28
+ // PURE, and requires nothing — provisioning.js composes these into
29
+ // PROJECT_DETAIL_VOCAB, and soft-limits.js reads them, so a require of either from
30
+ // here would be a cycle.
31
+
32
+ 'use strict';
33
+
34
+ // ISO 3166-1 alpha-2, the officially assigned codes. A closed list rather than a
35
+ // two-letter pattern, because "ZZ" is a typo the pattern would store as a country.
36
+ const COUNTRY_CODES = Object.freeze((
37
+ 'AD AE AF AG AI AL AM AO AQ AR AS AT AU AW AX AZ BA BB BD BE BF BG BH BI BJ BL BM BN BO BQ ' +
38
+ 'BR BS BT BV BW BY BZ CA CC CD CF CG CH CI CK CL CM CN CO CR CU CV CW CX CY CZ DE DJ DK DM ' +
39
+ 'DO DZ EC EE EG EH ER ES ET FI FJ FK FM FO FR GA GB GD GE GF GG GH GI GL GM GN GP GQ GR GS ' +
40
+ 'GT GU GW GY HK HM HN HR HT HU ID IE IL IM IN IO IQ IR IS IT JE JM JO JP KE KG KH KI KM KN ' +
41
+ 'KP KR KW KY KZ LA LB LC LI LK LR LS LT LU LV LY MA MC MD ME MF MG MH MK ML MM MN MO MP MQ ' +
42
+ 'MR MS MT MU MV MW MX MY MZ NA NC NE NF NG NI NL NO NP NR NU NZ OM PA PE PF PG PH PK PL PM ' +
43
+ 'PN PR PS PT PW PY QA RE RO RS RU RW SA SB SC SD SE SG SH SI SJ SK SL SM SN SO SR SS ST SV ' +
44
+ 'SX SY SZ TC TD TF TG TH TJ TK TL TM TN TO TR TT TV TW TZ UA UG UM US UY UZ VA VC VE VG VI ' +
45
+ 'VN VU WF WS YE YT ZA ZM ZW'
46
+ ).split(' '));
47
+
48
+ // The sub-national level the canvas asks for ("your country · state"), ISO 3166-2
49
+ // style. Only where the law that matters to a project — minors' work permits, what
50
+ // kind of company you can form — is set at that level: the US states (with DC and the
51
+ // territories) and the Canadian provinces and territories. Anywhere else the answer is
52
+ // the country alone, and a region is refused rather than stored unread.
53
+ const US_REGIONS = (
54
+ 'AL AK AZ AR CA CO CT DE FL GA HI ID IL IN IA KS KY LA ME MD MA MI MN MS MO MT NE NV NH NJ ' +
55
+ 'NM NY NC ND OH OK OR PA RI SC SD TN TX UT VT VA WA WV WI WY DC AS GU MP PR UM VI'
56
+ ).split(' ').map((s) => `US-${s}`);
57
+ const CA_REGIONS = 'AB BC MB NB NL NS NT NU ON PE QC SK YT'.split(' ').map((s) => `CA-${s}`);
58
+ const REGION_CODES = Object.freeze([...US_REGIONS, ...CA_REGIONS]);
59
+
60
+ // The European Union's member states — what "EU builders" means to the soft limits.
61
+ const EU_COUNTRIES = Object.freeze(
62
+ 'AT BE BG CY CZ DE DK EE ES FI FR GR HR HU IE IT LT LU LV MT NL PL PT RO SE SI SK'.split(' ')
63
+ );
64
+
65
+ const WORK_KINDS = Object.freeze(['code', 'digital-files', 'physical-work']);
66
+
67
+ // Question 4. A project that already exists picks its legal type; a new one says
68
+ // roughly where it is heading (the canvas: "there's nothing to sign yet"). One key,
69
+ // because they answer one question and a project holds exactly one of them.
70
+ const ENTITY_INTENTS = Object.freeze(['intent-company', 'intent-nonprofit', 'intent-solo', 'intent-unsure']);
71
+ const ENTITY_FORMS = Object.freeze([
72
+ 'nonprofit', 'for-profit', 'corporate-division', 'research', // organization
73
+ 'university-project', 'social-group', // group
74
+ 'single-owner-llc', 'private-individual', // individual
75
+ ]);
76
+
77
+ // Question 7's A–D, in that order (A = managed … D = open-source).
78
+ const FRAMINGS = Object.freeze(['managed', 'hybrid', 'local', 'open-source']);
79
+
80
+ // The suite a hybrid or local framing chooses from.
81
+ const SERVICES = Object.freeze([
82
+ 'hosting', 'labor-payment', 'starter-kit', 'marketplace-buy', 'marketplace-sell',
83
+ 'guild-access', 'guild-create', 'company-creation', 'cbaas-consulting', 'public-marketing',
84
+ ]);
85
+
86
+ // The ceiling on a builder count is a sanity bound, not a product limit.
87
+ const BUILDER_COUNT = Object.freeze({ kind: 'count', min: 1, max: 100000 });
88
+
89
+ const PLANET_PHYSICS_QUESTIONS = Object.freeze({
90
+ project_state: Object.freeze({ kind: 'one', values: Object.freeze(['new', 'existing']) }),
91
+ existing_work: Object.freeze({ kind: 'many', values: WORK_KINDS }),
92
+ main_work: Object.freeze({ kind: 'one', values: WORK_KINDS }),
93
+ home_country: Object.freeze({ kind: 'one', values: COUNTRY_CODES }),
94
+ home_region: Object.freeze({ kind: 'one', values: REGION_CODES }),
95
+ builder_geography: Object.freeze({ kind: 'one', values: Object.freeze(['us-only', 'us-eu', 'global', 'not-sure']) }),
96
+ minors_on_board: Object.freeze({ kind: 'one', values: Object.freeze([false, true]) }),
97
+ entity: Object.freeze({ kind: 'one', values: Object.freeze([...ENTITY_INTENTS, ...ENTITY_FORMS]) }),
98
+ builders_at_start: BUILDER_COUNT,
99
+ builders_expected: BUILDER_COUNT,
100
+ reward_intents: Object.freeze({ kind: 'many', values: Object.freeze(['non-monetary', 'cash', 'equity', 'future-payouts']) }),
101
+ framing: Object.freeze({ kind: 'one', values: FRAMINGS }),
102
+ services: Object.freeze({ kind: 'many', values: SERVICES }),
103
+ });
104
+
105
+ // The answer as it may be STORED, or undefined when it is not one. `many` answers come
106
+ // back de-duplicated in the list's order; an empty array is a real answer ("none of
107
+ // these"), distinct from never answering. PURE.
108
+ function canonicalAnswer(question, value) {
109
+ if (!question) return undefined;
110
+ if (question.kind === 'one') return question.values.includes(value) ? value : undefined;
111
+ if (question.kind === 'many') {
112
+ if (!Array.isArray(value) || !value.every((v) => question.values.includes(v))) return undefined;
113
+ return question.values.filter((v) => value.includes(v));
114
+ }
115
+ if (question.kind === 'count') {
116
+ return Number.isInteger(value) && value >= question.min && value <= question.max ? value : undefined;
117
+ }
118
+ return undefined;
119
+ }
120
+
121
+ // Why a value is not an answer, in a sentence the caller can act on. Lists longer
122
+ // than a dozen (the country and region codes) are named by their standard instead of
123
+ // printed. PURE.
124
+ function answerFaultMessage(key, question) {
125
+ const list = (values) => (values.length > 12
126
+ ? (key === 'home_country' ? 'an ISO 3166-1 alpha-2 country code, like US or DE' : 'a US state or Canadian province code, like US-CA or CA-ON')
127
+ : `one of: ${values.join(', ')}`);
128
+ if (question.kind === 'many') return `"${key}" must be a list, each item ${list(question.values)} — or null to un-answer it.`;
129
+ if (question.kind === 'count') return `"${key}" must be a whole number from ${question.min} to ${question.max} — or null to un-answer it.`;
130
+ return `"${key}" must be ${list(question.values)} — or null to un-answer it.`;
131
+ }
132
+
133
+ // Faults BETWEEN answers sent together in one body. Only within the body: a merge
134
+ // patch may legitimately change one half now and the other later, and refusing a
135
+ // write for disagreeing with a stored answer would make the order of edits matter.
136
+ // Returns a message, or null. PURE.
137
+ function crossAnswerFault(detail) {
138
+ if (Array.isArray(detail.existing_work) && typeof detail.main_work === 'string'
139
+ && !detail.existing_work.includes(detail.main_work)) {
140
+ return `"main_work" must be one of the kinds in "existing_work" (${detail.existing_work.join(', ') || 'none given'}).`;
141
+ }
142
+ if (typeof detail.home_region === 'string' && typeof detail.home_country === 'string'
143
+ && !detail.home_region.startsWith(`${detail.home_country}-`)) {
144
+ return `"home_region" ${detail.home_region} is not in "home_country" ${detail.home_country}.`;
145
+ }
146
+ return null;
147
+ }
148
+
149
+ module.exports = {
150
+ PLANET_PHYSICS_QUESTIONS,
151
+ COUNTRY_CODES, REGION_CODES, EU_COUNTRIES,
152
+ ENTITY_FORMS,
153
+ canonicalAnswer, answerFaultMessage, crossAnswerFault,
154
+ };
@@ -19,6 +19,11 @@
19
19
  // valued, and the value it resolves to when nobody answered is the type's starter
20
20
  // bundle — so storage and the read projection both have to read that declaration.
21
21
  const { normalizeModuleSelection, effectiveModules } = require('./starter-bundles');
22
+ // The planet-physics answers and the soft limits over them (BV2.PS02, task 1004416) —
23
+ // pure declarations beside this file, for the same reason: storage and the read
24
+ // projection both have to read them.
25
+ const { PLANET_PHYSICS_QUESTIONS, canonicalAnswer } = require('./planet-physics');
26
+ const { softLimitsFor } = require('./soft-limits');
22
27
 
23
28
  // ---------------------------------------------------------------------------
24
29
  // Constants (mirror the CHECK constraints in provisioning_001_tables.sql)
@@ -253,9 +258,18 @@ const PLANET_VOCAB = Object.freeze({
253
258
  // obey, so an absent one has to resolve to today's behaviour; a detail answer is
254
259
  // something the owner SAID, and inventing one they never said would feed R17 a
255
260
  // recommendation nobody asked for. Absent stays absent, and skipped stays skipped.
256
- const PROJECT_DETAIL_VOCAB = Object.freeze({
257
- team_shape: ['solo', 'small-team', 'community'],
261
+ //
262
+ // The seven planet-physics answers (BV2.PS02, task 1004416) ride the same column under
263
+ // the same contract. They add two kinds of question beside the closed single answer:
264
+ // a list of members (`many`) and a whole number (`count`) — see planet-physics.js.
265
+ // PROJECT_DETAIL_QUESTIONS is the whole contract; PROJECT_DETAIL_VOCAB is its closed
266
+ // answer lists, the shape every reader and the `options` reply already take.
267
+ const PROJECT_DETAIL_QUESTIONS = Object.freeze({
268
+ team_shape: Object.freeze({ kind: 'one', values: Object.freeze(['solo', 'small-team', 'community']) }),
269
+ ...PLANET_PHYSICS_QUESTIONS,
258
270
  });
271
+ const PROJECT_DETAIL_VOCAB = Object.freeze(Object.fromEntries(Object.entries(PROJECT_DETAIL_QUESTIONS)
272
+ .filter(([, question]) => question.values).map(([key, question]) => [key, question.values])));
259
273
  // The publish-gate half of the interview is prose, so it needs a ceiling. Long
260
274
  // enough for a real paragraph, short enough that the column is never a document —
261
275
  // the Explore card renders `tagline`, not this.
@@ -401,6 +415,7 @@ async function isProvisionedHostname(db, hostname) {
401
415
  // explicitly means a future sensitive column is never leaked by accident.
402
416
  function publicInstance(row) {
403
417
  if (!row) return { state: 'none' };
418
+ const detail = projectDetail(row);
404
419
  return {
405
420
  id: row.id,
406
421
  slug: row.slug,
@@ -431,7 +446,10 @@ function publicInstance(row) {
431
446
  // in (ADR 0182 D4). Surfaced so a form can render "here's what's still
432
447
  // missing" from a plain instance read, without a second endpoint.
433
448
  description: row.description || null,
434
- detail: projectDetail(row),
449
+ detail,
450
+ // Which startup options those answers hide, grey or annotate, each with its reason
451
+ // (BV2.PS02, spec D6). Advice only — `enforced: false` says so on every read.
452
+ soft_limits: softLimitsFor(detail),
435
453
  // The creation picker's module set (task 1002339): the always-on core, what
436
454
  // the owner ends up with, and whether that came from their own toggles or
437
455
  // from the type's starter bundle because they never answered. Resolved, not
@@ -631,8 +649,9 @@ function normalizeDescription(input) {
631
649
  function normalizeProjectDetail(input) {
632
650
  const given = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
633
651
  const out = {};
634
- for (const [key, vocab] of Object.entries(PROJECT_DETAIL_VOCAB)) {
635
- if (vocab.includes(given[key])) out[key] = given[key];
652
+ for (const [key, question] of Object.entries(PROJECT_DETAIL_QUESTIONS)) {
653
+ const answer = canonicalAnswer(question, given[key]);
654
+ if (answer !== undefined) out[key] = answer;
636
655
  }
637
656
  return out;
638
657
  }
@@ -650,9 +669,10 @@ function projectDetailPatch(input) {
650
669
  const given = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
651
670
  const set = {};
652
671
  const clear = [];
653
- for (const [key, vocab] of Object.entries(PROJECT_DETAIL_VOCAB)) {
672
+ for (const [key, question] of Object.entries(PROJECT_DETAIL_QUESTIONS)) {
673
+ const answer = canonicalAnswer(question, given[key]);
654
674
  if (given[key] === null) clear.push(key);
655
- else if (vocab.includes(given[key])) set[key] = given[key];
675
+ else if (answer !== undefined) set[key] = answer;
656
676
  }
657
677
  return { set, clear };
658
678
  }
@@ -1445,7 +1465,7 @@ module.exports = {
1445
1465
  SETTINGS_APPLY_FROM, SETTINGS_APPLY_SHAPES,
1446
1466
  RESTART_FROM, RESTART_SHAPES,
1447
1467
  TIER_MONTHLY_USD, CERT_ELIGIBLE_STATES,
1448
- SETTINGS_VOCAB, DEFAULT_SETTINGS, SETTINGS_COMPANION_ENV, PROJECT_DETAIL_VOCAB, DESCRIPTION_MAX,
1468
+ SETTINGS_VOCAB, DEFAULT_SETTINGS, SETTINGS_COMPANION_ENV, PROJECT_DETAIL_VOCAB, PROJECT_DETAIL_QUESTIONS, DESCRIPTION_MAX,
1449
1469
  PLANET_TEMPLATES, PLANET_ACCENTS, PLANET_VOCAB,
1450
1470
  // pure
1451
1471
  isValidSlug, costEstimateUsd, DEFAULT_DEDICATED_TIER, publicInstance, normalizeHostname,
@@ -1453,7 +1473,7 @@ module.exports = {
1453
1473
  settingsPushOwed,
1454
1474
  // pure — the artist gate's non-retroactivity stamp (ADR 0241 §5, task 1003575)
1455
1475
  ARTIST_GATE_STRICT_SINCE_KEY, artistGateStrictSince, artistGateStamp,
1456
- normalizeDescription, normalizeProjectDetail, projectDetailPatch, projectDetail,
1476
+ normalizeDescription, normalizeProjectDetail, projectDetailPatch, projectDetail, softLimitsFor,
1457
1477
  // pure — GitHub App Manifest flow (task 2080)
1458
1478
  githubAppName, buildGithubAppManifest, manifestPostUrl, publicGithubApp,
1459
1479
  // pure — the address race's database backstop (task 1002901, provisioning_015)
@@ -15,6 +15,8 @@
15
15
 
16
16
  const provisioning = require('../provisioning');
17
17
  const { ALWAYS_ON_CORE, OPTIONAL_MODULES } = require('../starter-bundles');
18
+ const { canonicalAnswer, answerFaultMessage, crossAnswerFault } = require('../planet-physics');
19
+ const { screenProject, STOP_MESSAGE } = require('../screening');
18
20
 
19
21
  // The project-detail answers, validated LOUDLY (task 1002334). `detail` arrives as
20
22
  // a nested object, and the strict body validator only reaches top-level fields — so
@@ -28,21 +30,37 @@ function badProjectDetail(detail) {
28
30
  if (typeof detail !== 'object' || Array.isArray(detail)) {
29
31
  return { code: 'bad_detail', message: 'detail must be an object of answers.' };
30
32
  }
31
- const vocab = provisioning.PROJECT_DETAIL_VOCAB;
33
+ const questions = provisioning.PROJECT_DETAIL_QUESTIONS;
32
34
  for (const [key, value] of Object.entries(detail)) {
33
- if (!Object.prototype.hasOwnProperty.call(vocab, key)) {
34
- return { code: 'bad_detail', message: `Unknown detail answer "${key}" — the interview asks: ${Object.keys(vocab).join(', ')}.` };
35
+ if (!Object.prototype.hasOwnProperty.call(questions, key)) {
36
+ return { code: 'bad_detail', message: `Unknown detail answer "${key}" — the interview asks: ${Object.keys(questions).join(', ')}.` };
35
37
  }
36
38
  // An explicit null is the UN-ANSWER, not an off-vocabulary value (task 1003577):
37
39
  // the merge-patch way back to "never answered". Only null — an empty string or any
38
40
  // other sentinel stays a typo, so a caller trying to SET one is never silently
39
41
  // read as having erased their answer.
40
42
  if (value === null) continue;
41
- if (!vocab[key].includes(value)) {
42
- return { code: 'bad_detail', message: `"${key}" must be one of: ${vocab[key].join(', ')} — or null to un-answer it.` };
43
+ // Every kind of question (planet-physics.js, task 1004416) — a single answer, a
44
+ // list of members, or a whole number — refuses with a sentence naming its own shape.
45
+ if (canonicalAnswer(questions[key], value) === undefined) {
46
+ return { code: 'bad_detail', message: answerFaultMessage(key, questions[key]) };
43
47
  }
44
48
  }
45
- return null;
49
+ const crossFault = crossAnswerFault(detail);
50
+ return crossFault ? { code: 'bad_detail', message: crossFault } : null;
51
+ }
52
+
53
+ // The screening hook (BV2.PS02, task 1004416) at the boundary: the project's own words,
54
+ // screened BEFORE anything is created — the canvas's stop screen ("nothing has been set
55
+ // up"). 422, because the body is well-formed and the refusal is about what it describes.
56
+ // Neither the sentence nor `details` names the rule: a gate that says which rule it
57
+ // tripped teaches a caller how to reword around it (the task 1004416 review).
58
+ // Returns null when clean, or the res.fail options to stop with. The rules and the seam
59
+ // a real classifier replaces are in ../screening.js.
60
+ function screenedOut(description) {
61
+ const verdict = screenProject({ description });
62
+ if (!verdict.stop) return null;
63
+ return { status: 422, message: STOP_MESSAGE, details: { screened: true } };
46
64
  }
47
65
 
48
66
  // The creation picker's module selection, validated LOUDLY (BV1.R20, task
@@ -91,4 +109,4 @@ function badProjectType(type) {
91
109
  return null;
92
110
  }
93
111
 
94
- module.exports = { badProjectDetail, badModuleSelection, badProjectType };
112
+ module.exports = { badProjectDetail, badModuleSelection, badProjectType, screenedOut };
@@ -61,7 +61,7 @@ const catalogBridge = require('../catalog-bridge');
61
61
  const capacity = require('../capacity'); // the box's slot ceiling (ADR 0285, task 1003927)
62
62
  const { callbackPage } = require('./callback-page');
63
63
  // the LOUD body validators (task 1003504) — lifted out when this file hit the size ratchet
64
- const { badProjectDetail, badModuleSelection, badProjectType } = require('./body-validators');
64
+ const { badProjectDetail, badModuleSelection, badProjectType, screenedOut } = require('./body-validators');
65
65
  const { failFrom } = require('../public-refusal'); // every catch: a declared refusal reaches the owner, anything else stays opaque (task 1004126)
66
66
  // the kernel ports this module provides (moved out at the size ratchet, task 1003578)
67
67
  const { registerProvisioningSeams } = require('../seams');
@@ -330,6 +330,8 @@ module.exports = function provisioningRoutes() {
330
330
  if (detailFault) return res.fail(detailFault.code, { status: 400, message: detailFault.message });
331
331
  const modulesFault = badModuleSelection(body.modules);
332
332
  if (modulesFault) return res.fail(modulesFault.code, { status: 400, message: modulesFault.message });
333
+ const screened = screenedOut(body.description); // the stop screen (task 1004416): before anything exists
334
+ if (screened) return res.fail('screened_out', screened);
333
335
  const slug = String(body.slug || '').trim().toLowerCase();
334
336
  if (!provisioning.isValidSlug(slug)) {
335
337
  return res.fail('bad_slug', 400, { reason: 'lowercase, 2–40 chars of [a-z0-9-], no leading/trailing/double dash' });
@@ -698,6 +700,8 @@ module.exports = function provisioningRoutes() {
698
700
  if (detailFault) return res.fail(detailFault.code, { status: 400, message: detailFault.message });
699
701
  const modulesFault = badModuleSelection(body.modules);
700
702
  if (modulesFault) return res.fail(modulesFault.code, { status: 400, message: modulesFault.message });
703
+ const screened = screenedOut(body.description); // the stop screen (task 1004416): before anything exists
704
+ if (screened) return res.fail('screened_out', screened);
701
705
  // `type: null` reaches here because the kernel validator reads null as ABSENT —
702
706
  // which is exactly why the refusal has to be its own check and not an enum rule.
703
707
  const typeFault = badProjectType(Object.prototype.hasOwnProperty.call(body, 'type') ? body.type : undefined);
@@ -731,11 +735,12 @@ module.exports = function provisioningRoutes() {
731
735
  // one CLEARED here has to leave it, which is why the hub assigns `description`
732
736
  // rather than COALESCEing it.
733
737
  await catalogBridge.publishInstanceToCatalog(pool, updated).catch(() => {});
738
+ const detail = provisioning.projectDetail(updated);
734
739
  res.json({
735
740
  ok: true,
736
741
  description: updated.description || null,
737
- detail: provisioning.projectDetail(updated),
738
- options: provisioning.PROJECT_DETAIL_VOCAB,
742
+ detail, options: provisioning.PROJECT_DETAIL_VOCAB,
743
+ soft_limits: provisioning.softLimitsFor(detail), // advice, never enforced (task 1004416)
739
744
  // The whole point of the step, answered in the same breath (R16, task
740
745
  // 1002335): an owner who just wrote a description learns HERE that the
741
746
  // project is now on the map, and one who cleared it learns it just left.
@@ -0,0 +1,62 @@
1
+ // modules/provisioning/screening.js — the screening hook that can stop a project
2
+ // before anything is created (BV2.PS02, task 1004416; the canvas's "this can't be
3
+ // built on Cloud Bongos" stop screen).
4
+ //
5
+ // WHAT IT SCREENS. The words an owner uses to say what the project is: the idea text
6
+ // for a new planet, and the description of the work that already exists for an
7
+ // existing one. Both arrive as `description` today (the canvas asks one question, "what
8
+ // is your project, and what do you want Bongos to do for you?"), so the hook takes an
9
+ // object of named texts — a later field is one more key, not a new call site.
10
+ //
11
+ // THE SEAM. `screenProject(texts, rules)` is the whole contract: texts in, a verdict
12
+ // out, `{ stop: false }` or `{ stop: true, rule }`. The real classifier is out of scope
13
+ // (spec, PS02); when it exists it replaces SCREENING_RULES or this function's body, and
14
+ // no caller changes.
15
+ //
16
+ // THE RULE SET IS DELIBERATELY SMALL. A keyword rule that stops an honest project is a
17
+ // worse failure than one that misses a bad one — the classifier is the real answer, and
18
+ // the stop screen always offers "contact us". So each rule names something no
19
+ // legitimate project is built to BE, and a defensive project that merely talks about it
20
+ // (a ransomware detector, a phishing-awareness course) carries an `unless` that lets it
21
+ // through. When in doubt, a rule does not belong here.
22
+ //
23
+ // PURE — no I/O, no requires.
24
+
25
+ 'use strict';
26
+
27
+ const SCREENING_RULES = Object.freeze([
28
+ Object.freeze({
29
+ id: 'child-sexual-abuse',
30
+ match: /\b(csam|child (sexual abuse|porn\w*)|(sexual|nude|explicit) (images?|photos?|videos?|content) of (children|minors|kids))\b/i,
31
+ unless: null,
32
+ }),
33
+ Object.freeze({
34
+ id: 'malware',
35
+ match: /\b(build|make|create|write|develop|sell|spread|distribute|deploy)\w*\b[^.!?\n]{0,40}\b(ransomware|keyloggers?|info-?stealers?|credential stealers?|botnets?)\b/i,
36
+ unless: /\b(detect\w*|defen[cs]\w*|protect\w*|prevent\w*|block\w*|remov\w*|scann\w*|research\w*|against|anti-?\w+)\b/i,
37
+ }),
38
+ Object.freeze({
39
+ id: 'phishing',
40
+ match: /\b(build|make|create|write|develop|sell|run|host|launch)\w*\b[^.!?\n]{0,40}\bphishing (kits?|pages?|sites?|campaigns?)\b/i,
41
+ unless: /\b(awareness|training|simulat\w*|detect\w*|report\w*|defen[cs]\w*|protect\w*|against)\b/i,
42
+ }),
43
+ ]);
44
+
45
+ // The verdict for one project's texts. `texts` is `{ description, … }`; anything that is
46
+ // not a string is skipped, so an unanswered field can never stop a project. PURE.
47
+ function screenProject(texts, rules = SCREENING_RULES) {
48
+ const given = texts && typeof texts === 'object' ? texts : {};
49
+ const words = Object.values(given).filter((t) => typeof t === 'string' && t.trim()).join('\n');
50
+ if (!words) return { stop: false };
51
+ for (const rule of rules) {
52
+ if (rule.match.test(words) && !(rule.unless && rule.unless.test(words))) return { stop: true, rule: rule.id };
53
+ }
54
+ return { stop: false };
55
+ }
56
+
57
+ // The owner-facing sentence the stop screen shows — the canvas's own copy. It names no
58
+ // rule, and nothing else in the reply does either: the person who answers "contact us"
59
+ // reads the description itself.
60
+ const STOP_MESSAGE = "What you described isn't something we can help create, so we've stopped here. Nothing has been set up. If you think we've read it wrong, contact us and a person will look at it.";
61
+
62
+ module.exports = { SCREENING_RULES, STOP_MESSAGE, screenProject };