@bongos/core 1.19.933 → 1.19.934

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.
@@ -27,7 +27,7 @@
27
27
  // TASK 1003270's two rules came with the card and still govern edits here:
28
28
  // • NO RAW CONFIG KEY REACHES A READER. `first_ratifier` / `rank:archon` /
29
29
  // `why_matters` are grammar for the server; a person gets the sentence
30
- // (decidedByHtml, rankNames, SECTION_LABELS). The single exception is a
30
+ // (decidedByHtml, rankNames, SECTION_LABELS_BY_TEMPLATE). The single exception is a
31
31
  // membership predicate that cannot be PARSED, where the broken string is
32
32
  // the actionable fact.
33
33
  // • COPY THAT EXPLAINS THE UI TO ITSELF IS DELETED, not shortened.
@@ -50,8 +50,8 @@
50
50
  // `fetchJson` / `sendJson` stay per-page: a hall page script is a self-contained
51
51
  // IIFE over the DOM, and those two carry this page's own error copy.
52
52
  //
53
- // SECTION_LABELS and fmtRemaining stay HERE, not in the lib — this page is their
54
- // only reader, and the lib is for what the two pages share.
53
+ // SECTION_LABELS_BY_TEMPLATE and fmtRemaining stay HERE, not in the lib — this
54
+ // page is their only reader, and the lib is for what the two pages share.
55
55
  //
56
56
  // The function ORDER here is deliberate and matches the pre-carve source:
57
57
  // tests/government_board_room_copy.mjs slices this file by function-name
@@ -165,7 +165,7 @@
165
165
  // wrote under and the objector picked from. `why_matters` is a jsonb key,
166
166
  // not something a reader should have to decode.
167
167
  const objection = v.direction === 'no'
168
- ? `<p class="ov-row__sub">objected to <strong>${escapeHtml(SECTION_LABELS[v.section_key] || v.section_key || 'the idea')}</strong>: ${escapeHtml(v.reason_md || '')}</p>`
168
+ ? `<p class="ov-row__sub">objected to <strong>${escapeHtml(labelsOf(item)[v.section_key] || ANY_SECTION_LABEL[v.section_key] || v.section_key || 'the idea')}</strong>: ${escapeHtml(v.reason_md || '')}</p>`
169
169
  : '';
170
170
  return `<li class="ov-row ov-row--dense"><div class="ov-row__main">
171
171
  <span class="ov-row__title">${who}${mine}</span>
@@ -178,17 +178,59 @@
178
178
 
179
179
  // ---- the vote form (R17) -----------------------------------------------------
180
180
 
181
- // The five section labels, mirrored from the base template declaration
181
+ // The section labels, mirrored from the template declarations
182
182
  // (modules/ideas/templates.js — the words the author saw when writing; a test
183
183
  // holds the two equal, since browser code cannot import a server module).
184
- const SECTION_LABELS = {
185
- purpose: 'Purpose of Idea',
186
- big_picture: 'What does the Big Picture look like',
187
- why_had: 'Why did you have the Idea',
188
- why_matters: 'Why does the Idea Matter',
189
- fit: 'How does the Idea Fit into the big picture of the project',
184
+ //
185
+ // ⚠ ONE MAP PER VINTAGE, KEYED BY `template_key` (task 1004085 / BV2.R06).
186
+ // There used to be one flat map, which was right while there was one template.
187
+ // It cannot be flattened again: `why_had` and `fit` exist in BOTH vintages with
188
+ // DIFFERENT questions behind them, so a merged map would show one idea's author
189
+ // the words a different author was asked. The label is a quotation, and the
190
+ // thing it quotes is the template the subject was actually written against.
191
+ const SECTION_LABELS_BY_TEMPLATE = {
192
+ 'base-v1': {
193
+ purpose: 'Purpose of Idea',
194
+ big_picture: 'What does the Big Picture look like',
195
+ why_had: 'Why did you have the Idea',
196
+ why_matters: 'Why does the Idea Matter',
197
+ fit: 'How does the Idea Fit into the big picture of the project',
198
+ },
199
+ 'base-v2': {
200
+ why_had: 'What made you have the idea',
201
+ scope: 'Scope it as best as you can here',
202
+ build_context: 'What should an engineer or artist know prior to building',
203
+ fit: 'How is this long term justified in the project',
204
+ success: 'What does a successful implementation of the idea look like',
205
+ },
190
206
  };
191
207
 
208
+ // The vintage an item's sections should be read under.
209
+ //
210
+ // THE FALLBACK IS `base-v1`, AND IT IS NOT THE SERVER'S DEFAULT. An item with
211
+ // no template to read is an AMENDMENT (which has no Full Idea behind it at all,
212
+ // yet still needs a section picker, because the DB requires every objection to
213
+ // name one) or a full_idea whose subject row was deleted. Falling back to the
214
+ // current default would silently change what those items have always offered;
215
+ // falling back to v1 keeps them byte-identical to before this task. A vintage
216
+ // this shipped page has never heard of falls here too — see sectionsOf.
217
+ function templateOf(item) {
218
+ const key = item && item.subject && item.subject.template_key;
219
+ return SECTION_LABELS_BY_TEMPLATE[key] ? key : 'base-v1';
220
+ }
221
+
222
+ function labelsOf(item) {
223
+ return SECTION_LABELS_BY_TEMPLATE[templateOf(item)];
224
+ }
225
+
226
+ // DECODE-ONLY, and deliberately not used to render a form. A stored objection
227
+ // names a section that was legal when it was cast, which on an item whose
228
+ // vintage this page cannot resolve is a key no per-vintage map holds. Turning
229
+ // it into SOME readable words beats printing `why_matters` at a reader, and the
230
+ // ambiguity the per-vintage maps exist to avoid does not arise here: the item's
231
+ // own map is consulted first, and this is only the fallback behind it.
232
+ const ANY_SECTION_LABEL = Object.assign({}, ...Object.values(SECTION_LABELS_BY_TEMPLATE));
233
+
192
234
  // The idea's five sections, IN FRONT of the voter — the entire reason voting
193
235
  // happens in the hall rather than on a reaction. Rendered in template order.
194
236
  //
@@ -202,12 +244,20 @@
202
244
  function ideaSectionsHtml(item) {
203
245
  const fv = item.subject && item.subject.field_values;
204
246
  if (!fv) return '';
205
- const rows = Object.keys(SECTION_LABELS).map((key) => {
247
+ const labels = labelsOf(item);
248
+ // The item's own vintage first, in its declared order — then anything the
249
+ // author wrote that this page has no label for. That tail is normally empty
250
+ // and exists so a vintage shipped after this page cannot make an author's
251
+ // answers INVISIBLE to the board being asked to ratify them: an unlabelled
252
+ // section reads as its key, which is ugly and honest, where dropping it is
253
+ // neither. (The vote FORM deliberately does not do this — see voteFormHtml.)
254
+ const extra = Object.keys(fv).filter((k) => !(k in labels));
255
+ const rows = Object.keys(labels).concat(extra).map((key) => {
206
256
  // An unanswered section is dropped rather than rendered as a bare heading:
207
257
  // a graded Full Idea has all five, and a blank one is only noise.
208
258
  const body = String(fv[key] ?? '').trim();
209
259
  if (!body) return '';
210
- return `<h4 class="ov-prose__l">${escapeHtml(SECTION_LABELS[key])}</h4>
260
+ return `<h4 class="ov-prose__l">${escapeHtml(labels[key] || key)}</h4>
211
261
  <p class="ov-prose__p gov-idea__body">${escapeHtml(body)}</p>`;
212
262
  }).join('');
213
263
  return rows
@@ -268,8 +318,15 @@
268
318
  // discovered by clicking.
269
319
  const isOwnIdea = item.subject_type === 'full_idea'
270
320
  && item.subject && String(item.subject.author_id) === String(viewerId);
271
- const sections = Object.keys(SECTION_LABELS).map((key) => `
272
- <label class="gov-vote__section"><input type="radio" name="vote-section-${escapeHtml(item.id)}" value="${escapeHtml(key)}"> <span>${escapeHtml(SECTION_LABELS[key])}</span></label>`).join('');
321
+ // THE PICKER OFFERS THE ITEM'S OWN VINTAGE, AND ONLY KEYS THE SERVER ACCEPTS.
322
+ // Unlike the prose above it does NOT fall through to whatever the author
323
+ // wrote: government_003's CHECK (widened by government_017) is a closed
324
+ // vocabulary, so offering a section outside it would render a radio whose
325
+ // only outcome is a refused vote. A vintage this page cannot resolve keeps
326
+ // base-v1's five — see templateOf.
327
+ const labels = labelsOf(item);
328
+ const sections = Object.keys(labels).map((key) => `
329
+ <label class="gov-vote__section"><input type="radio" name="vote-section-${escapeHtml(item.id)}" value="${escapeHtml(key)}"> <span>${escapeHtml(labels[key])}</span></label>`).join('');
273
330
  const ownNote = isOwnIdea
274
331
  ? `<p class="gov-grid-note">Your own idea. You may vote, but it can’t be decided by your vote alone, and a vote on your own idea earns no karma.</p>`
275
332
  : '';
@@ -0,0 +1,37 @@
1
+ -- ideas_008_template_default_v2.sql — a NEW Full Idea is written against
2
+ -- `base-v2` (task 1004085 / BV2.R06, goal 1000095; spec decision 10 in
3
+ -- docs/specs/bongos-v2-ideator-surface.md).
4
+ --
5
+ -- WHAT THIS DOES NOT DO: it does not touch a single stored row. `template_key`
6
+ -- has been NOT NULL with a value on every row since ideas_001, and every writer
7
+ -- passes the key explicitly, so there is nothing to backfill and nothing to
8
+ -- reinterpret. The hundreds of ideas already filed keep saying 'base-v1' and keep
9
+ -- being rendered and scored against the questions they were actually asked —
10
+ -- which is the entire promise of versioning the template rather than editing it.
11
+ -- A backfill here would be the bug, not the missing half: `completeness_score`
12
+ -- feeds the ideator credit lane (R147), so re-scoring old thoughts against new
13
+ -- questions would quietly change what work already paid.
14
+ --
15
+ -- WHY MOVE THE COLUMN DEFAULT AT ALL, then. Belt and braces. The default is
16
+ -- unreachable in normal operation — modules/ideas/grades.js always names the key,
17
+ -- taking it from projection.js's DEFAULT_TEMPLATE_KEY, which this migration
18
+ -- mirrors. But a column default is a STATEMENT about what a row without an
19
+ -- opinion is, and leaving it at 'base-v1' would leave that statement false and
20
+ -- waiting: the first writer that ever omits the column would silently mint a new
21
+ -- idea wearing a retired vintage, validate its answers against the wrong five
22
+ -- questions, and score it against the wrong floors. Two statements of one fact
23
+ -- are only safe while they agree.
24
+ --
25
+ -- ALTER … SET DEFAULT is metadata only: no table rewrite, no lock held on the
26
+ -- rows, safe on a live instance of any size.
27
+ --
28
+ -- Additive and forward-safe. There is no down-migration, and the way back is not
29
+ -- this file run in reverse: 'base-v1' stays registered in modules/ideas/
30
+ -- templates.js forever, so reverting the default is a one-line code change with
31
+ -- no data consequence either way.
32
+
33
+ BEGIN;
34
+
35
+ ALTER TABLE ideas_grades ALTER COLUMN template_key SET DEFAULT 'base-v2';
36
+
37
+ COMMIT;
@@ -57,9 +57,22 @@ const VALID_GRADES = ['quick', 'spark', 'full'];
57
57
 
58
58
  // Which template `field_values` was written against. Stored per row so a project
59
59
  // that later defines its own shape (R156) does not retro-reinterpret ideas
60
- // written against the base one. R141 declares what 'base-v1' actually contains —
61
- // this module only records which template a row used.
62
- const DEFAULT_TEMPLATE_KEY = 'base-v1';
60
+ // written against the base one. `templates.js` declares what each key actually
61
+ // contains — this module only records which template a row used.
62
+ //
63
+ // THIS IS THE DEFAULT FOR A NEW FILING ONLY, NEVER A READING OF A STORED ROW.
64
+ // It moved 'base-v1' -> 'base-v2' in task 1004085 (BV2.R06, spec decision 10:
65
+ // the five questions are replaced and versioned), and that move is safe for
66
+ // exactly one reason — every graded row carries its own `template_key`, so the
67
+ // hundreds already stored keep resolving to the v1 declaration and keep being
68
+ // scored against the questions they were put. Anything that reaches for this
69
+ // constant while holding a row is a bug: read `row.template_key`.
70
+ //
71
+ // `ideas_008_template_default_v2.sql` moves the COLUMN default to match. The two
72
+ // are belt and braces — every writer passes the key explicitly — but a column
73
+ // default that still said 'base-v1' would be a false statement about what a new
74
+ // idea is, waiting for the first writer that forgets.
75
+ const DEFAULT_TEMPLATE_KEY = 'base-v2';
63
76
  const TEMPLATE_KEY_RE = /^[a-z][a-z0-9-]*$/;
64
77
  const TEMPLATE_KEY_MAX = 64;
65
78
 
@@ -1,11 +1,18 @@
1
1
  // modules/ideas/templates.js — the Full Idea template declarations (task 1002917,
2
2
  // BV1.R141, goal 1000062).
3
3
  //
4
- // THIS FILE IS WHAT 'base-v1' MEANS. ideas_001_grades.sql stores `field_values`
5
- // jsonb against a `template_key` and deliberately declares nothing about shape —
6
- // its header says "R141 declares what 'base-v1' means". This is that declaration:
7
- // the five base fields, in the owner's own wording from the Holy Workshop Trinity
8
- // brief, in the order they display.
4
+ // THIS FILE IS WHAT EVERY `template_key` MEANS. ideas_001_grades.sql stores
5
+ // `field_values` jsonb against a `template_key` and deliberately declares nothing
6
+ // about shape — its header says "R141 declares what 'base-v1' means". This is that
7
+ // declaration, and since task 1004085 (BV2.R06) there are TWO of them: `base-v2`,
8
+ // the questions a Full Idea is asked today, and `base-v1` beside it, the questions
9
+ // the ideas already in the table were actually asked. Each is the owner's own
10
+ // wording, in the order they display.
11
+ //
12
+ // A VINTAGE IS ADDITIVE AND FROZEN. A new question set is a new key in the
13
+ // registry and a new default; the old declaration is never edited and never
14
+ // removed, because the scorer walks whichever declaration a stored row names and
15
+ // `completeness_score` feeds credits. Re-asking is free; re-scoring is not.
9
16
  //
10
17
  // THE TEMPLATE IS DATA, READ BY BOTH VALIDATION AND SCORING. Capture (R143)
11
18
  // validates a submitted `field_values` against a template with
@@ -43,7 +50,7 @@ const { DEFAULT_TEMPLATE_KEY, FIELD_VALUES_MAX } = require('./inbox');
43
50
  // answered, not padded past" — declared here as data so the scorer stays
44
51
  // template-agnostic; validation deliberately does NOT enforce it (a thin answer
45
52
  // is a scoring problem, not a capture error — scoring never blocks capture).
46
- const BASE_TEMPLATE = Object.freeze({
53
+ const BASE_V1_TEMPLATE = Object.freeze({
47
54
  key: 'base-v1',
48
55
  label: 'Full Idea',
49
56
  fields: Object.freeze([
@@ -55,21 +62,81 @@ const BASE_TEMPLATE = Object.freeze({
55
62
  ]),
56
63
  });
57
64
 
58
- // The built-in registry. One entry today; a second built-in template would be
59
- // added here. Frozen so a consumer can never mutate the base declaration into
60
- // a de-facto fork — a different shape is a different registry (the seam above).
65
+ // THE QUESTIONS A FULL IDEA IS ASKED TODAY (task 1004085 / BV2.R06, goal 1000095;
66
+ // spec decision 10, `docs/specs/bongos-v2-ideator-surface.md`). The owner's own
67
+ // set, in the owner's own order.
68
+ //
69
+ // WHY A SECOND TEMPLATE RATHER THAN AN EDIT TO THE FIRST. Every graded row stores
70
+ // the `template_key` it was written against, and the scorer walks the declaration
71
+ // that key resolves to — so editing `base-v1` in place would re-score hundreds of
72
+ // already-filed ideas against questions nobody asked them, and `completeness_score`
73
+ // feeds the ideator credit lane (R147). A thought is scored on the questions it
74
+ // was actually put. `base-v1` above is therefore FROZEN HISTORY: it is never
75
+ // edited again, only read.
76
+ //
77
+ // TWO KEYS ARE REUSED DELIBERATELY. `why_had` survives outright ("what made you
78
+ // have the idea") and `fit` survives roughly (the long-term justification moved
79
+ // from "how does it fit" to "how is it justified long term"). Reusing the key is
80
+ // what makes this a registry entry instead of a data migration; `template_key` is
81
+ // what keeps the two readings apart, so a v1 row's `fit` is still rendered under
82
+ // the v1 label. `purpose`, `big_picture` and `why_matters` are NOT carried over —
83
+ // they belong to v1 and stay there.
84
+ //
85
+ // THE FLOORS ARE FLAT 40, MATCHING v1, AND THAT IS TEMPORARY. The new questions
86
+ // are not interchangeable ("scope it as best as you can" deserves more room than
87
+ // "what made you have the idea"), but the score feeds credits, so choosing the
88
+ // numbers is its own deliberate act — task 1004091 (BV2.R12) owns it. Shipping a
89
+ // guess here would have been an unreviewed change to what a thought pays.
90
+ const BASE_V2_TEMPLATE = Object.freeze({
91
+ key: 'base-v2',
92
+ label: 'Full Idea',
93
+ fields: Object.freeze([
94
+ Object.freeze({ key: 'why_had', label: 'What made you have the idea', required: true, substanceMin: 40 }),
95
+ Object.freeze({ key: 'scope', label: 'Scope it as best as you can here', required: true, substanceMin: 40 }),
96
+ Object.freeze({ key: 'build_context', label: 'What should an engineer or artist know prior to building', required: true, substanceMin: 40 }),
97
+ Object.freeze({ key: 'fit', label: 'How is this long term justified in the project', required: true, substanceMin: 40 }),
98
+ Object.freeze({ key: 'success', label: 'What does a successful implementation of the idea look like', required: true, substanceMin: 40 }),
99
+ ]),
100
+ });
101
+
102
+ // The template a NEW Full Idea is written against — an alias, not a sixth
103
+ // declaration. Consumers that mean "the current questions" (scripts/gds/spark.js
104
+ // prints them; the tests build a full payload from them) read this, so the alias
105
+ // is what makes flipping the default a one-line change here rather than a sweep.
106
+ // Anything that means a SPECIFIC vintage names `BASE_V1_TEMPLATE` /
107
+ // `BASE_V2_TEMPLATE` instead.
108
+ const BASE_TEMPLATE = BASE_V2_TEMPLATE;
109
+
110
+ // The built-in registry. Two entries: what ideas are asked now, and what they
111
+ // were asked before. Frozen so a consumer can never mutate a declaration into a
112
+ // de-facto fork — a different shape is a different registry (the seam above).
113
+ // AN ENTRY IS NEVER REMOVED FROM HERE while rows still name it: `templateFor`
114
+ // returns null for an unknown key, and a null template is how a stored idea
115
+ // loses its questions and its score at once.
61
116
  const BUILTIN_TEMPLATES = Object.freeze({
62
- [BASE_TEMPLATE.key]: BASE_TEMPLATE,
117
+ [BASE_V1_TEMPLATE.key]: BASE_V1_TEMPLATE,
118
+ [BASE_V2_TEMPLATE.key]: BASE_V2_TEMPLATE,
63
119
  });
64
120
 
65
121
  // Sanity pin, not a runtime branch: the store's default key and this file's
66
- // base declaration are two statements of the same fact, and requiring them to
122
+ // current declaration are two statements of the same fact, and requiring them to
67
123
  // agree at load time turns a rename-drift into a boot failure instead of a
68
124
  // silent "every new row validates against a template that does not exist".
69
125
  if (BASE_TEMPLATE.key !== DEFAULT_TEMPLATE_KEY) {
70
126
  throw new Error(`templates: BASE_TEMPLATE.key (${BASE_TEMPLATE.key}) !== inbox DEFAULT_TEMPLATE_KEY (${DEFAULT_TEMPLATE_KEY})`);
71
127
  }
72
128
 
129
+ // The second half of the same pin, and the one this task exists to hold: every
130
+ // vintage a row can still carry must stay resolvable. The default moving forward
131
+ // is a feature; a retired template silently dropping out of the registry is the
132
+ // failure mode — it would leave already-filed ideas with no questions to render
133
+ // and no floors to score against.
134
+ for (const t of [BASE_V1_TEMPLATE, BASE_V2_TEMPLATE]) {
135
+ if (!Object.hasOwn(BUILTIN_TEMPLATES, t.key)) {
136
+ throw new Error(`templates: ${t.key} is declared but not registered — an already-filed idea would lose its questions`);
137
+ }
138
+ }
139
+
73
140
  // Resolve a template by key. Null for an unknown key — the caller decides
74
141
  // whether that is a 400 (capture) or a render-as-raw fallback (display).
75
142
  function templateFor(key, registry = BUILTIN_TEMPLATES) {
@@ -144,6 +211,8 @@ function validateFieldValues(fieldValues, { templateKey = DEFAULT_TEMPLATE_KEY,
144
211
 
145
212
  module.exports = {
146
213
  BASE_TEMPLATE,
214
+ BASE_V1_TEMPLATE,
215
+ BASE_V2_TEMPLATE,
147
216
  BUILTIN_TEMPLATES,
148
217
  templateFor,
149
218
  fieldOrder,
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.933",
3
+ "version": "1.19.934",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.933",
9
+ "version": "1.19.934",
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.19.933",
3
+ "version": "1.19.934",
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",
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.933'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.934'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -138,8 +138,15 @@ test('the self-explaining copy is DELETED, not reworded', () => {
138
138
 
139
139
  test('the section a voter faults is shown by NAME, in the ballot and the picker', () => {
140
140
  const ballot = BOARD.slice(BOARD.indexOf('function ballotHtml'), BOARD.indexOf('// ---- the vote form'));
141
- assert.match(ballot, /SECTION_LABELS\[v\.section_key\]/,
141
+ // Since task 1004085 the label comes from the item's OWN vintage — `why_had`
142
+ // and `fit` exist in both templates asking different questions, so a single
143
+ // flat map would show the reader the wrong sentence. `ANY_SECTION_LABEL` is
144
+ // the decode-only fallback behind it, for a section whose vintage this page
145
+ // cannot resolve; the one thing that must never appear is the bare key.
146
+ assert.match(ballot, /labelsOf\(item\)\[v\.section_key\]/,
142
147
  '`why_matters` is a jsonb key; the objector picked a named section and the reader should see that name');
148
+ assert.match(ballot, /ANY_SECTION_LABEL\[v\.section_key\]/,
149
+ 'and an older vintage still decodes to words rather than to its key');
143
150
  });
144
151
 
145
152
  test('the objection still carries a reason AND one section (ADR 0175 §8)', () => {
@@ -147,8 +154,10 @@ test('the objection still carries a reason AND one section (ADR 0175 §8)', () =
147
154
  // hall instead of on a chat reaction.
148
155
  const form = BOARD.slice(BOARD.indexOf('function voteFormHtml'), BOARD.indexOf('async function castVoteFromRoom'));
149
156
  assert.match(form, /data-vote-reason/, 'the reason box survives');
150
- assert.match(form, /gov-vote__sections/, 'so does the five-way section picker');
151
- assert.match(form, /Object\.keys\(SECTION_LABELS\)\.map/, 'with one radio per declared section');
157
+ assert.match(form, /gov-vote__sections/, 'so does the section picker');
158
+ assert.match(form, /Object\.keys\(labels\)\.map/, 'with one radio per declared section');
159
+ assert.match(form, /const labels = labelsOf\(item\)/,
160
+ 'and the sections offered are the ones the subject was actually written against');
152
161
  });
153
162
 
154
163
  test('each room frames itself in one line, not a paragraph', () => {
@@ -29,19 +29,44 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
29
29
  const GOV = path.join(ROOT, 'modules', 'government');
30
30
  const board = require(path.join(GOV, 'board.js'));
31
31
  const db = require(path.join(GOV, 'db.js'));
32
- const { fieldOrder, templateFor } = require(path.join(ROOT, 'modules', 'ideas', 'templates.js'));
32
+ const { fieldOrder, templateFor, BUILTIN_TEMPLATES } = require(path.join(ROOT, 'modules', 'ideas', 'templates.js'));
33
33
 
34
34
  const { FULL_IDEA_SECTION_KEYS, VOTE_KARMA_DELTA, AUTHOR_RATIFY_KARMA_DELTA, awardAuthorRatifyKarma, validateVote, castVote } = board;
35
35
 
36
36
  // ── invariant 1: one list, three places ─────────────────────────────────────
37
37
 
38
+ // THE LIST IS THE UNION OF EVERY VINTAGE, since task 1004085 (BV2.R06) versioned
39
+ // the Full Idea questions. It was `base-v1`'s five, and pinning it to one
40
+ // template again would re-open the defect that task closed: an idea filed under
41
+ // a newer vintage reaches the board, and every section a voter could object to
42
+ // is refused `objection_needs_section` — leaving yes as the only castable vote.
43
+ const ALL_TEMPLATE_FIELD_KEYS = [...new Set(
44
+ Object.keys(BUILTIN_TEMPLATES).flatMap((key) => fieldOrder(templateFor(key))),
45
+ )];
46
+
38
47
  test('the objection section keys ARE the Full Idea template fields (no second list that drifts)', () => {
39
- assert.deepEqual([...FULL_IDEA_SECTION_KEYS], fieldOrder(templateFor('base-v1')),
40
- 'board.js must flag against the same five sections the template declares');
48
+ assert.deepEqual([...FULL_IDEA_SECTION_KEYS].sort(), [...ALL_TEMPLATE_FIELD_KEYS].sort(),
49
+ 'board.js must flag against exactly the sections the templates declare — every vintage, no extras');
50
+
51
+ // The CHECK that is actually in force is government_003's as widened by
52
+ // government_017; a key is legal if the LATEST statement of the constraint
53
+ // carries it, so that is the file this reads.
41
54
  const migration = fs.readFileSync(
42
- path.join(GOV, 'migrations', 'government_003_board_tables.sql'), 'utf8');
55
+ path.join(GOV, 'migrations', 'government_017_board_section_keys_v2.sql'), 'utf8');
43
56
  for (const key of FULL_IDEA_SECTION_KEYS) {
44
- assert.ok(migration.includes(`'${key}'`), `the government_003 CHECK must carry '${key}'`);
57
+ assert.ok(migration.includes(`'${key}'`), `the in-force CHECK must carry '${key}'`);
58
+ }
59
+
60
+ // NARROWING IS THE FAILURE MODE, so it gets its own assertion rather than
61
+ // riding on the set equality above: objections already cast name government_003's
62
+ // keys, and dropping one would orphan them and make the CHECK refuse rows that
63
+ // are already in the table.
64
+ const original = fs.readFileSync(
65
+ path.join(GOV, 'migrations', 'government_003_board_tables.sql'), 'utf8');
66
+ for (const key of fieldOrder(templateFor('base-v1'))) {
67
+ assert.ok(original.includes(`'${key}'`), `sanity: government_003 declared '${key}'`);
68
+ assert.ok(FULL_IDEA_SECTION_KEYS.includes(key), `'${key}' must never leave the list`);
69
+ assert.ok(migration.includes(`'${key}'`), `'${key}' must never leave the CHECK`);
45
70
  }
46
71
  });
47
72
 
@@ -64,6 +89,29 @@ test('a yes carries nothing; a no carries a reason AND exactly one section', ()
64
89
  }
65
90
  });
66
91
 
92
+ test('an objection can name a section of EITHER vintage — BV2.R06\'s actual defect', () => {
93
+ // The set-equality test above compares two lists. This one drives the real
94
+ // validator, one section at a time, because the list being right and the
95
+ // OBJECTION being accepted are different claims — and it is the second one
96
+ // that decides whether a new-vintage idea can be returned at all. Before task
97
+ // 1004085 every key on the base-v2 side of this loop answered
98
+ // `objection_needs_section`, leaving yes as the only castable vote on any idea
99
+ // filed after the questions changed.
100
+ for (const templateKey of Object.keys(BUILTIN_TEMPLATES)) {
101
+ for (const sectionKey of fieldOrder(templateFor(templateKey))) {
102
+ const v = validateVote({ direction: 'no', reasonMd: 'this section needs work', sectionKey });
103
+ assert.equal(v.ok, true, `${templateKey}'s '${sectionKey}' must be objectable`);
104
+ assert.equal(v.sectionKey, sectionKey);
105
+ }
106
+ }
107
+ // And the list is still CLOSED — widening it is not opening it. A plausible
108
+ // key that no template declares is refused exactly as 'vibes' is.
109
+ for (const notASection of ['scope_notes', 'why', 'build', 'purpose_v2']) {
110
+ assert.equal(validateVote({ direction: 'no', reasonMd: 'x', sectionKey: notASection }).reason,
111
+ 'objection_needs_section', `'${notASection}' is not a declared section`);
112
+ }
113
+ });
114
+
67
115
  // ── castVote against a stubbed db (membership logic REAL) ───────────────────
68
116
 
69
117
  const MONARCHY = { membership: 'rank:archon', pass_rule: 'first_ratifier', window_minutes: null, close_early_on_full_turnout: true };
@@ -21,21 +21,41 @@ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
21
21
  // with the room onto its own page. The name stays `CLIENT` because that is what
22
22
  // each assertion below reads — this file has exactly one source.
23
23
  const CLIENT = fs.readFileSync(path.join(ROOT, 'modules', 'hall-ui', 'public', 'board-room.js'), 'utf8');
24
- const { BASE_TEMPLATE } = require(path.join(ROOT, 'modules', 'ideas', 'templates.js'));
24
+ const { BASE_V1_TEMPLATE, BASE_V2_TEMPLATE } = require(path.join(ROOT, 'modules', 'ideas', 'templates.js'));
25
25
  const board = require(path.join(ROOT, 'modules', 'government', 'board.js'));
26
26
 
27
- test('the five section labels ARE the template declaration — key and label, in order', () => {
28
- const block = CLIENT.slice(CLIENT.indexOf('const SECTION_LABELS'), CLIENT.indexOf('function ideaSectionsHtml'));
27
+ test('the section labels ARE the template declarations — every vintage, key and label, in order', () => {
28
+ // Since task 1004085 (BV2.R06) there are TWO declarations, and the page holds
29
+ // one map per vintage. The order of the maps in the source is the order of the
30
+ // vintages here, so the flattened pair list is compared against the flattened
31
+ // declarations — which is what catches a label edited on one side only.
32
+ const block = CLIENT.slice(CLIENT.indexOf('const SECTION_LABELS_BY_TEMPLATE'), CLIENT.indexOf('function ideaSectionsHtml'));
29
33
  const declared = [...block.matchAll(/(\w+): '([^']+)'/g)].map((m) => [m[1], m[2]]);
30
- const template = BASE_TEMPLATE.fields.map((f) => [f.key, f.label]);
34
+ const template = [...BASE_V1_TEMPLATE.fields, ...BASE_V2_TEMPLATE.fields].map((f) => [f.key, f.label]);
31
35
  assert.deepEqual(declared, template,
32
- 'the voter reads the same five section names the author wrote under — held equal here because browser code cannot import the server module');
36
+ 'the voter reads the same section names the author wrote under — held equal here because browser code cannot import the server module');
37
+ });
38
+
39
+ test('the labels are keyed BY VINTAGE, never flattened into one map', () => {
40
+ // `why_had` and `fit` exist in both vintages carrying DIFFERENT questions, so a
41
+ // single merged key->label map would show one author the question a different
42
+ // author was asked. This is the assertion that forbids the tidy-up.
43
+ assert.match(CLIENT, /SECTION_LABELS_BY_TEMPLATE\s*=\s*\{[^]*'base-v1'\s*:[^]*'base-v2'\s*:/,
44
+ 'one map per template key');
45
+ assert.match(CLIENT, /item\.subject\.template_key/,
46
+ 'the vintage comes from the SUBJECT — never a page-level default');
47
+ // The shared keys really are shared and really do differ, so the test above is
48
+ // guarding something rather than restating a shape.
49
+ const v1 = new Map(BASE_V1_TEMPLATE.fields.map((f) => [f.key, f.label]));
50
+ for (const f of BASE_V2_TEMPLATE.fields) {
51
+ if (v1.has(f.key)) assert.notEqual(v1.get(f.key), f.label, `${f.key} asks differently per vintage`);
52
+ }
33
53
  });
34
54
 
35
55
  test('the idea is in front of the voter: sections render from the subject, in template order', () => {
36
56
  assert.match(CLIENT, /item\.subject\.field_values|subject && item\.subject\.field_values/,
37
57
  'the sections come from the board view payload, never a second fetch');
38
- assert.match(CLIENT, /Object\.keys\(SECTION_LABELS\)\.map/,
58
+ assert.match(CLIENT, /Object\.keys\(labels\)/,
39
59
  'rendered in the declared order — jsonb key order is never trusted');
40
60
  const card = CLIENT.slice(CLIENT.indexOf('function itemCard'), CLIENT.indexOf('async function loadBoard'));
41
61
  assert.match(card, /ideaSectionsHtml\(item\)/, 'the sections sit ON the item card, above the ballot');
@@ -41,6 +41,7 @@ const NON_GRANT_MIGRATIONS = new Set([
41
41
  'government_006_returned_items_index.sql', // R13 returned-item lookup index (task 1003026)
42
42
  'government_007_amendments.sql', // R14 amendment proposals table (task 1003027)
43
43
  'government_008_closed_items_index.sql', // R16 closed-history index (task 1003029)
44
+ 'government_017_board_section_keys_v2.sql', // BV2.R06 board section_key CHECK widening (task 1004085)
44
45
  ]);
45
46
 
46
47
  // Permission keys RENAMED after the grant migrations that first granted them.
package/tests/helpers.mjs CHANGED
@@ -976,3 +976,26 @@ export function bootSkyPage({
976
976
  },
977
977
  };
978
978
  }
979
+
980
+ // namesFieldKey — does this source USE `key` as a Full Idea field key, as
981
+ // opposed to merely containing the word? (task 1004085 / BV2.R06.)
982
+ //
983
+ // Two suites assert that a surface carries no copy of the template — the hall
984
+ // composer (`tests/idea_hall_composer.mjs`, R152) and the ideate-full skill
985
+ // (`tests/idea_ideate_full.mjs`). Both did it with `src.includes(key)`, which
986
+ // was sound while every key was a compound like `big_picture` and broke the day
987
+ // base-v2 introduced `scope`: ideas.js already said "scoped per version" and
988
+ // "the protected-scope wall", and the skill prose says it too. Both reported a
989
+ // hard-coded form that does not exist.
990
+ //
991
+ // The three shapes below are how a surface could really name a field — a string
992
+ // literal (or a markdown backtick span, which is the same characters), a
993
+ // property, or an unquoted HTML attribute value. Everything else is English.
994
+ // It lives here rather than in either suite because two copies of a predicate
995
+ // this fiddly are two chances for one to be loosened alone.
996
+ export function namesFieldKey(src, key) {
997
+ const k = String(key).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
998
+ return new RegExp(`(['"\`])${k}\\1`).test(src)
999
+ || new RegExp(`\\.${k}\\b`).test(src)
1000
+ || new RegExp(`=\\s*${k}\\b`).test(src);
1001
+ }