@bongos/core 1.19.1005 → 1.19.1007

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.
@@ -0,0 +1,638 @@
1
+ // tests/ideator_full_idea_shapes_space_proof.mjs
2
+ //
3
+ // THE PROOF for criterion `ideator-full-idea-shapes-the-space` (goal 1000095,
4
+ // BONGOS-V2), written under task 1004104 (BV2.R25, spec R25). Sibling of
5
+ // tests/ideator_traceable_proof.mjs (BV2.R21) and tests/ideator_fork_pays_proof.mjs
6
+ // (BV2.R23), and it keeps their standard: a proof whose only possible outcome is
7
+ // "confirmed" is not a proof. Every claim below was probed by breaking the real
8
+ // code and watching this file go red — see the proof record at
9
+ // docs/governance/bongos-v2-ideator-full-idea-shapes-the-space-proof.md.
10
+ //
11
+ // THE CLAIM BEING PROVED, in the criterion's own words: an ideator's best work
12
+ // becomes the structure other people's thinking lands in. Concretely — a Full
13
+ // Idea written through the five questions, carried by the Board Room, ratified as
14
+ // a GOAL, produces a goal that CARRIES ITS FIVE ANSWERS. Scope comes from "scope
15
+ // it as best as you can here"; done-when comes from "what does a successful
16
+ // implementation of the idea look like".
17
+ //
18
+ // WHAT MAKES THIS DIFFERENT FROM tests/idea_goal_ratification.mjs, which already
19
+ // covers this route well. That suite asserts what was BOUND to the INSERT
20
+ // statements — the correct question for the route. It cannot see the hop that
21
+ // matters to THIS criterion: what a builder who later OPENS the goal actually
22
+ // gets back. A composer could render all five answers into a parameter that the
23
+ // read path then never returns, and every assertion over the bound parameters
24
+ // would stay green. So this file WRITES and then READS BACK, through the real
25
+ // read functions (`getGoal`, `listCriteriaForGoal`), and asserts over the rows
26
+ // those hand a reader. "The goal carries the answers" is a statement about a
27
+ // read, so it is tested as one.
28
+ //
29
+ // THE STORE IS SMALL, REAL AND STRICT. It is a set of in-memory tables, not a
30
+ // recorded query log: the chain has to be written and then read back, since the
31
+ // ratification INSERTs the rows the goal read later returns, and a log replaying
32
+ // canned answers would assert the fixture rather than the code. AND IT THROWS ON
33
+ // ANY SQL IT DOES NOT RECOGNISE. That is the whole design — a store answering an
34
+ // unmatched query with `{ rows: [] }` would let a renamed column or a new join
35
+ // turn this proof into a vacuous green, which is the failure mode
36
+ // docs/governance/bongos-v2-ideator-fork-proof.md exists to warn about.
37
+ //
38
+ // EVERY HOP IS REAL CODE:
39
+ // - the five answers are validated by the real `validateFieldValues` and
40
+ // written by the real `setIdeaGrade` (so the proof cannot smuggle in a shape
41
+ // capture would have refused);
42
+ // - the board gate is the real `government.boardPassForIdea`, reading the real
43
+ // `getLatestPassedItem` SQL against the store — NOT a stub returning `true`;
44
+ // - the goal is created by the real `lifecycle.createRatifiedGoal`, so the
45
+ // version gate and both rank gates are exercised;
46
+ // - the route is the real POST /inbox/:id/ratify-goal handler;
47
+ // - the read-back is the real `getGoal` + `listCriteriaForGoal`.
48
+ //
49
+ // DB-free (no DATABASE_URL on a builder box): pool.query and pool.connect are
50
+ // patched on the doorway before the modules load, so the transaction is driven
51
+ // rather than skipped.
52
+ //
53
+ // Run: node tests/ideator_full_idea_shapes_space_proof.mjs
54
+
55
+ import { strict as assert } from 'node:assert';
56
+ import { createRequire } from 'node:module';
57
+
58
+ const require = createRequire(import.meta.url);
59
+ const api = require('../src/module-api.js');
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // The store: in-memory tables, written and read back, strict about its SQL.
63
+ // ---------------------------------------------------------------------------
64
+
65
+ const db = {
66
+ ideas: new Map(), // id -> idea_inbox row
67
+ grades: new Map(), // idea_id -> ideas_grades row
68
+ boardItems: [], // government_board_items rows
69
+ versions: new Map(), // id -> versions row
70
+ goals: new Map(), // id -> goals row
71
+ criteria: [], // done_when_criteria rows
72
+ goalMembers: [], // goal_members rows
73
+ admissions: [], // lifecycle_goal_version_admissions rows
74
+ ratifications: [], // ideas_goal_ratifications rows
75
+ };
76
+ let nextId = 5000;
77
+ const mint = () => String(++nextId);
78
+
79
+ // Statements the chain legitimately runs that write nothing this proof reads —
80
+ // listed rather than swallowed by a catch-all, so a NEW statement still throws.
81
+ const IGNORED = [
82
+ /^BEGIN$/i, /^COMMIT$/i, /^ROLLBACK$/i,
83
+ ];
84
+
85
+ let queries = [];
86
+
87
+ function answer(sql, params) {
88
+ const s = sql.replace(/\s+/g, ' ').trim();
89
+
90
+ for (const re of IGNORED) if (re.test(s)) return { rows: [] };
91
+
92
+ // --- ideas_grades: the five answers being written (real setIdeaGrade) -----
93
+ if (/^INSERT INTO ideas_grades/i.test(s)) {
94
+ const [ideaId, grade, templateKey, fieldValuesJson, completeness, recommendation] = params;
95
+ const row = {
96
+ idea_id: String(ideaId), grade, template_key: templateKey,
97
+ // Stored as jsonb would store it: the object, NOT the caller's key order.
98
+ // `jsonbOrder` below is what makes this a real hazard rather than a prop.
99
+ field_values: jsonbOrder(JSON.parse(fieldValuesJson)),
100
+ completeness_score: completeness, recommendation: recommendation ?? null,
101
+ created_at: 'T0', updated_at: 'T0',
102
+ };
103
+ db.grades.set(row.idea_id, row);
104
+ return { rows: [row] };
105
+ }
106
+
107
+ // --- the board's decision (real getLatestPassedItem) ----------------------
108
+ if (/FROM government_board_items WHERE subject_type = \$1 AND subject_id = \$2 AND outcome = 'passed'/i.test(s)) {
109
+ const [subjectType, subjectId] = params;
110
+ const hits = db.boardItems
111
+ .filter((r) => r.subject_type === subjectType
112
+ && String(r.subject_id) === String(subjectId)
113
+ && r.outcome === 'passed')
114
+ .sort((a, b) => String(b.closed_at).localeCompare(String(a.closed_at)));
115
+ return { rows: hits.slice(0, 1).map((r) => ({
116
+ item_id: r.id, closed_at: r.closed_at, opened_at: r.opened_at, decided_under: r.constitution,
117
+ })) };
118
+ }
119
+
120
+ // --- the idea + its grade, locked (real ratifyFullIdeaAsGoal step a) ------
121
+ if (/FROM idea_inbox i LEFT JOIN ideas_grades g ON g\.idea_id = i\.id/i.test(s)) {
122
+ const idea = db.ideas.get(String(params[0]));
123
+ if (!idea) return { rows: [] };
124
+ const g = db.grades.get(String(params[0])) || null;
125
+ return { rows: [{
126
+ id: idea.id, title: idea.title, kind: idea.kind, status: idea.status,
127
+ captured_by: idea.captured_by,
128
+ grade: g ? g.grade : 'quick',
129
+ template_key: g ? g.template_key : null,
130
+ field_values: g ? g.field_values : null,
131
+ recommendation: g ? g.recommendation : null,
132
+ }] };
133
+ }
134
+
135
+ // --- the version, locked (real getVersion, forUpdate) --------------------
136
+ if (/FROM versions WHERE id = \$1/i.test(s)) {
137
+ const v = db.versions.get(String(params[0]));
138
+ return { rows: v ? [v] : [] };
139
+ }
140
+
141
+ // --- createGoal: one statement that writes three tables ------------------
142
+ if (/INSERT INTO goals \(version_id, title, subtitle, description/i.test(s)) {
143
+ const [versionId, title, description, scopeModules, status, createdBy, subtitle,
144
+ categoryId, admittedBy, admitReason] = params;
145
+ const order = [...db.goals.values()].filter((g) => g.version_id === versionId).length;
146
+ const goal = {
147
+ id: mint(), version_id: versionId, title, subtitle: subtitle ?? null,
148
+ description: description ?? null, scope_modules: scopeModules || [], status,
149
+ visibility: 'public', accepting_requests: true, created_by: createdBy ?? null,
150
+ succeeded_by_goal_id: null, sort_order: order, category_id: categoryId ?? null,
151
+ is_maintenance: false, created_at: 'T1', updated_at: 'T1',
152
+ };
153
+ db.goals.set(goal.id, goal);
154
+ // The two CTE side-writes, modelled because they are what makes `created_by`
155
+ // a grant of authority — claim 8 reads them.
156
+ if (createdBy != null) db.goalMembers.push({ goal_id: goal.id, builder_id: String(createdBy), membership_kind: 'lead' });
157
+ if (admittedBy != null) db.admissions.push({ goal_id: goal.id, version_id: versionId, admitted_by: String(admittedBy), reason: admitReason });
158
+ return { rows: [goal] };
159
+ }
160
+
161
+ // --- createCriterion: next sort_order, then the insert -------------------
162
+ if (/SELECT COALESCE\(MAX\(sort_order\), 0\) \+ 1 AS next FROM done_when_criteria/i.test(s)) {
163
+ const mine = db.criteria.filter((c) => c.version_id === String(params[0]));
164
+ return { rows: [{ next: mine.length + 1 }] };
165
+ }
166
+ if (/^INSERT INTO done_when_criteria/i.test(s)) {
167
+ const [versionId, criterionId, criterionMd, goalId, sortOrder] = params;
168
+ if (db.criteria.some((c) => c.version_id === versionId && c.criterion_id === criterionId)) {
169
+ const err = new Error('duplicate key value violates unique constraint');
170
+ err.code = '23505';
171
+ throw err;
172
+ }
173
+ const row = {
174
+ id: mint(), version_id: versionId, criterion_id: criterionId, criterion_md: criterionMd,
175
+ goal_id: goalId == null ? null : String(goalId), sort_order: sortOrder, satisfied: false,
176
+ };
177
+ db.criteria.push(row);
178
+ return { rows: [row] };
179
+ }
180
+
181
+ // --- the idea marked promoted -------------------------------------------
182
+ if (/^UPDATE idea_inbox SET status = 'promoted'/i.test(s)) {
183
+ const idea = db.ideas.get(String(params[0]));
184
+ if (idea) { idea.status = 'promoted'; idea.promoted_to_task_id = null; idea.reviewed_at = 'T2'; }
185
+ return { rows: [] };
186
+ }
187
+
188
+ // --- the audit row -------------------------------------------------------
189
+ if (/^INSERT INTO ideas_goal_ratifications/i.test(s)) {
190
+ const [ideaId, goalId, criterionId, boardItemId, targetVersion, scopeModules,
191
+ recommendationAtFiling, proposedBy, ratifiedBy, notesMd] = params;
192
+ if (db.ratifications.some((r) => r.idea_id === String(ideaId))) {
193
+ const err = new Error('duplicate key value violates unique constraint');
194
+ err.code = '23505';
195
+ throw err;
196
+ }
197
+ const row = {
198
+ id: mint(), idea_id: String(ideaId), goal_id: String(goalId), criterion_id: String(criterionId),
199
+ board_item_id: boardItemId == null ? null : String(boardItemId), target_version: targetVersion,
200
+ scope_modules: scopeModules, recommendation_at_filing: recommendationAtFiling ?? null,
201
+ proposed_by: proposedBy == null ? null : String(proposedBy),
202
+ ratified_by: String(ratifiedBy), notes_md: notesMd, ratified_at: 'T3',
203
+ };
204
+ db.ratifications.push(row);
205
+ return { rows: [{ id: row.id, ratified_at: row.ratified_at }] };
206
+ }
207
+
208
+ // --- THE READ BACK: what a builder opening the goal actually gets ---------
209
+ if (/FROM goals WHERE id = \$1/i.test(s)) {
210
+ const g = db.goals.get(String(params[0]));
211
+ // PROJECTED to the columns the read actually SELECTS, never handed back
212
+ // whole. This is load-bearing rather than tidy, and it was added because a
213
+ // probe caught its absence: the claim under proof is that a READER gets the
214
+ // five answers, and with the whole row echoed back, deleting `description`
215
+ // from the goal read's column list left all twelve cases GREEN. The store
216
+ // was answering a question the read had stopped asking.
217
+ return { rows: g ? [project(g, s)] : [] };
218
+ }
219
+ if (/WITH crit AS \( SELECT id, version_id, criterion_id, criterion_md, satisfied, sort_order, goal_id FROM done_when_criteria WHERE goal_id = \$1/i.test(s)) {
220
+ const rows = db.criteria
221
+ .filter((c) => c.goal_id === String(params[0]))
222
+ .sort((a, b) => a.sort_order - b.sort_order || String(a.id).localeCompare(String(b.id)))
223
+ .map((c) => ({ ...c, linked_tasks: 0, unshipped_tasks: 0 }));
224
+ return { rows };
225
+ }
226
+
227
+ // THE POINT OF THE STORE. An unmatched statement is a renamed column, a new
228
+ // join, or a hop that moved — every one of which would otherwise turn this
229
+ // proof green by answering nothing.
230
+ throw new Error(`the store does not recognise this SQL — a hop moved:\n ${s.slice(0, 220)}`);
231
+ }
232
+
233
+ // Narrow a stored row to the columns a SELECT actually names — the difference
234
+ // between proving what the code WROTE and proving what a reader can READ. An
235
+ // unrecognised column name throws, for the store's own reason: a column that
236
+ // exists in the SQL and not in the row is a schema that moved.
237
+ function project(row, sql) {
238
+ const list = /^SELECT (.+?) FROM goals/i.exec(sql);
239
+ if (!list) throw new Error(`cannot read the column list off: ${sql.slice(0, 120)}`);
240
+ const cols = list[1].split(',').map((c) => c.trim());
241
+ const out = {};
242
+ for (const c of cols) {
243
+ if (!Object.hasOwn(row, c)) throw new Error(`the goals read selects a column the store has never heard of: ${c}`);
244
+ out[c] = row[c];
245
+ }
246
+ return out;
247
+ }
248
+
249
+ // jsonb does not preserve key insertion order, and the composer is forbidden from
250
+ // reading order off the stored row (modules/ideas/templates.js says so in its own
251
+ // header). The store models that hazard rather than assuming it away: everything
252
+ // written comes back in reverse-declared order.
253
+ function jsonbOrder(obj) {
254
+ return Object.fromEntries([...Object.entries(obj)].reverse());
255
+ }
256
+
257
+ const record = async (sql, params = []) => {
258
+ queries.push({ sql: sql.replace(/\s+/g, ' ').trim(), params });
259
+ return answer(sql, params);
260
+ };
261
+
262
+ api.pool.query = record;
263
+ api.pool.connect = async () => ({ query: record, release() {} });
264
+
265
+ // ---------------------------------------------------------------------------
266
+ // The ports — BOTH real. The board read is the real government function over the
267
+ // store, not a stub answering `true`: a stubbed board would let claim 5 pass
268
+ // while the gate it names had been deleted.
269
+ // ---------------------------------------------------------------------------
270
+
271
+ const goalFromIdea = require('../modules/lifecycle/goal-from-idea.js');
272
+ const governmentBoard = require('../modules/government/board.js');
273
+ if (!api.hasProvider('lifecycle')) {
274
+ api.registerProvider('lifecycle', { createRatifiedGoal: goalFromIdea.createRatifiedGoal });
275
+ }
276
+ if (!api.hasProvider('government')) {
277
+ api.registerProvider('government', { boardPassForIdea: governmentBoard.boardPassForIdea });
278
+ }
279
+
280
+ const { BASE_V2_TEMPLATE, fieldOrder, validateFieldValues } = require('../modules/ideas/templates.js');
281
+ const grades = require('../modules/ideas/grades.js');
282
+ const lifecycleDb = require('../modules/lifecycle/db.js');
283
+ require('../modules/ideas/inbox.js'); // registerSeams() side effect
284
+ require('../modules/ideas/routes/inbox.js')({});
285
+ const routesFactory = require('../modules/ideas/routes/ratify-goal.js');
286
+
287
+ const router = routesFactory({});
288
+ function handlerFor(path, method) {
289
+ const layer = router.stack.find((l) => l.route && l.route.path === path && l.route.methods[method]);
290
+ assert.ok(layer, `route ${method.toUpperCase()} ${path} exists`);
291
+ const stack = layer.route.stack.map((s) => s.handle);
292
+ return stack[stack.length - 1];
293
+ }
294
+ const postRatifyGoal = handlerFor('/inbox/:id/ratify-goal', 'post');
295
+
296
+ function drive(handler, { body = {}, params = {}, builderId = '9', rank = 'metic' } = {}) {
297
+ return new Promise((resolve, reject) => {
298
+ const req = { body, params, query: {}, builder: { id: builderId, rank } };
299
+ const res = {
300
+ statusCode: 200,
301
+ status(c) { this.statusCode = c; return this; },
302
+ json(payload) { resolve({ status: this.statusCode, payload }); },
303
+ fail(code, status, extra) {
304
+ const st = typeof status === 'number' ? status : (status && status.status) || 500;
305
+ resolve({ status: st, payload: { error: code, ...(extra || {}) } });
306
+ },
307
+ };
308
+ Promise.resolve(handler(req, res)).catch(reject);
309
+ });
310
+ }
311
+
312
+ // ---------------------------------------------------------------------------
313
+ // One Full Idea, written once, used by every case. The answers are distinctive
314
+ // prose rather than markers, so an assertion that the goal "carries the answer"
315
+ // is an assertion about the author's actual words.
316
+ // ---------------------------------------------------------------------------
317
+
318
+ const AUTHOR = '42';
319
+ const RATIFIER = '9';
320
+ const IDEA_ID = '77';
321
+
322
+ const ANSWERS = Object.freeze({
323
+ why_had: 'Watching a builder re-derive a decision that an idea had already settled two weeks earlier.',
324
+ scope: 'The ideas module and the lifecycle goal tables only. No hall route changes, no migrations beyond the ratification audit row, and nothing that touches the task board.',
325
+ build_context: 'Ratification runs inside one transaction and the lifecycle port owns every goal-creation gate.',
326
+ fit: 'Long term it is what makes an ideator worth having: their thinking becomes the container other work lands in.',
327
+ success: 'A Full Idea the board carries becomes a goal whose done-when is the author’s own success answer, and whose description carries all five.',
328
+ });
329
+
330
+ function seedIdea() {
331
+ db.ideas.set(IDEA_ID, {
332
+ id: IDEA_ID, title: 'An idea should be able to become the shape of the work',
333
+ kind: 'feature', status: 'open', captured_by: AUTHOR,
334
+ });
335
+ }
336
+ function seedBoardPass() {
337
+ db.boardItems.push({
338
+ id: '900', subject_type: 'full_idea', subject_id: IDEA_ID, outcome: 'passed',
339
+ opened_at: 'A', closed_at: 'B', constitution: {},
340
+ });
341
+ }
342
+
343
+ // Writes the five answers through the REAL capture validator and the REAL grade
344
+ // writer. A shape capture would refuse can therefore never reach the ratifier.
345
+ async function writeFullIdea({ recommendation = 'goal', answers = ANSWERS } = {}) {
346
+ const check = validateFieldValues(answers, { templateKey: 'base-v2' });
347
+ assert.ok(check.ok, `the five answers are what capture accepts: ${JSON.stringify(check.errors)}`);
348
+ await grades.setIdeaGrade(IDEA_ID, {
349
+ grade: 'full', templateKey: 'base-v2', fieldValues: answers,
350
+ completenessScore: 90, recommendation,
351
+ });
352
+ }
353
+
354
+ function reset() {
355
+ queries = [];
356
+ db.ideas.clear(); db.grades.clear(); db.goals.clear(); db.versions.clear();
357
+ db.boardItems.length = 0; db.criteria.length = 0; db.goalMembers.length = 0;
358
+ db.admissions.length = 0; db.ratifications.length = 0;
359
+ nextId = 5000;
360
+ db.versions.set('TEST-V1', { id: 'TEST-V1', name: 'Test', status: 'planning', scope_doc_path: null, started_at: null, shipped_at: null, done_when: null });
361
+ seedIdea();
362
+ }
363
+
364
+ const OK_BODY = { target_version: 'TEST-V1', scope_modules: ['ideas'] };
365
+
366
+ let passed = 0;
367
+ let failed = 0;
368
+ async function test(name, fn) {
369
+ reset();
370
+ try { await fn(); passed++; console.log(`PASS ${name}`); }
371
+ catch (err) { failed++; console.error(`FAIL ${name}\n ${err.message}`); }
372
+ }
373
+
374
+ // The label a field is declared under — what a reader sees as a heading.
375
+ const labelOf = (key) => BASE_V2_TEMPLATE.fields.find((f) => f.key === key).label;
376
+
377
+ // ---------------------------------------------------------------------------
378
+ // claim 1 — the goal, AS READ BACK, carries all five answers under their own
379
+ // questions. Not "the INSERT was given them": the read is the claim.
380
+ // ---------------------------------------------------------------------------
381
+
382
+ await test('the goal a reader opens carries all five answers, each under the question that asked for it', async () => {
383
+ seedBoardPass();
384
+ await writeFullIdea();
385
+ const r = await drive(postRatifyGoal, { params: { id: IDEA_ID }, body: OK_BODY });
386
+ assert.equal(r.status, 201, JSON.stringify(r.payload));
387
+
388
+ // The read a builder opening the goal actually performs.
389
+ const goal = await lifecycleDb.getGoal(r.payload.goal.id);
390
+ assert.ok(goal, 'the goal is readable after the ratification committed');
391
+
392
+ for (const key of fieldOrder(BASE_V2_TEMPLATE)) {
393
+ assert.ok(
394
+ goal.description.includes(ANSWERS[key]),
395
+ `the goal a reader opens carries the "${key}" answer in full`,
396
+ );
397
+ assert.ok(
398
+ goal.description.includes(`### ${labelOf(key)}`),
399
+ `the "${key}" answer sits under its own question, not as loose prose`,
400
+ );
401
+ }
402
+ assert.equal(goal.title, db.ideas.get(IDEA_ID).title, 'and it is the idea’s own title');
403
+ });
404
+
405
+ // ---------------------------------------------------------------------------
406
+ // claim 2 — the answers come back in the ORDER THEY WERE ASKED, not the order
407
+ // the jsonb column happens to hold. The store returns them reversed; if the
408
+ // composer walked Object.keys(field_values) this reads backwards.
409
+ // ---------------------------------------------------------------------------
410
+
411
+ await test('the five answers read in the order they were asked, not the order the column stored them', async () => {
412
+ seedBoardPass();
413
+ await writeFullIdea();
414
+ const stored = Object.keys(db.grades.get(IDEA_ID).field_values);
415
+ const declared = fieldOrder(BASE_V2_TEMPLATE);
416
+ assert.notDeepEqual(stored, declared, 'precondition: the store hands the answers back out of order');
417
+
418
+ const r = await drive(postRatifyGoal, { params: { id: IDEA_ID }, body: OK_BODY });
419
+ const goal = await lifecycleDb.getGoal(r.payload.goal.id);
420
+
421
+ const seen = declared
422
+ .map((k) => ({ key: k, at: goal.description.indexOf(`### ${labelOf(k)}`) }))
423
+ .sort((a, b) => a.at - b.at)
424
+ .map((x) => x.key);
425
+ assert.deepEqual(seen, declared, 'the declared order is what a reader sees');
426
+ });
427
+
428
+ // ---------------------------------------------------------------------------
429
+ // claim 3 — the two mappings the criterion names, read back from the rows they
430
+ // land in: done-when from "success", scope from "scope it as best as you can".
431
+ // ---------------------------------------------------------------------------
432
+
433
+ await test('done-when IS the success answer, read off the criterion row; the scope answer is in the goal', async () => {
434
+ seedBoardPass();
435
+ await writeFullIdea();
436
+ const r = await drive(postRatifyGoal, { params: { id: IDEA_ID }, body: OK_BODY });
437
+
438
+ const criteria = await lifecycleDb.listCriteriaForGoal(r.payload.goal.id);
439
+ assert.equal(criteria.length, 1, 'the goal is born with exactly one done-when criterion');
440
+ assert.equal(criteria[0].criterion_md, ANSWERS.success,
441
+ 'the criterion a version-close reads IS the author’s success answer, verbatim');
442
+ assert.equal(criteria[0].satisfied, false, 'and it is open — a goal is not born achieved');
443
+ assert.equal(r.payload.done_when_from, 'success', 'the route says which answer became the gate');
444
+
445
+ const goal = await lifecycleDb.getGoal(r.payload.goal.id);
446
+ assert.ok(goal.description.includes(ANSWERS.scope),
447
+ 'the scope answer is in the goal a builder reads');
448
+ // The success answer lands in BOTH places, deliberately (ratify.js says so):
449
+ // dropping it from the description to avoid duplication would take the
450
+ // clearest statement of purpose out of the one place someone looks.
451
+ assert.ok(goal.description.includes(ANSWERS.success),
452
+ 'and the success answer is in the description too, not only in the criterion');
453
+ });
454
+
455
+ // ---------------------------------------------------------------------------
456
+ // claim 4 — the goal names where it came from, by link, so a reader can go and
457
+ // read the whole thought without asking anyone.
458
+ // ---------------------------------------------------------------------------
459
+
460
+ await test('the goal names the idea it came from, by link, and the machine record agrees', async () => {
461
+ seedBoardPass();
462
+ await writeFullIdea();
463
+ const r = await drive(postRatifyGoal, { params: { id: IDEA_ID }, body: OK_BODY });
464
+ const goal = await lifecycleDb.getGoal(r.payload.goal.id);
465
+
466
+ assert.ok(goal.description.includes(`/idea/${IDEA_ID}`),
467
+ 'the provenance is a LINK a reader can follow, not a bare number');
468
+ assert.ok(goal.description.indexOf(`/idea/${IDEA_ID}`) < goal.description.indexOf('### '),
469
+ 'and it is the first thing on the goal, above the answers');
470
+
471
+ // The durable machine record — the prose line is for humans, this is for code.
472
+ const audit = db.ratifications.find((x) => x.goal_id === String(goal.id));
473
+ assert.ok(audit, 'the ratification is on the record');
474
+ assert.equal(audit.idea_id, IDEA_ID);
475
+ assert.equal(audit.proposed_by, AUTHOR, 'the author keeps their claim on the thought');
476
+ assert.equal(audit.ratified_by, RATIFIER);
477
+ assert.equal(audit.board_item_id, '900', 'under the decision that carried it');
478
+ assert.equal(db.ideas.get(IDEA_ID).status, 'promoted', 'and the idea is no longer open');
479
+ });
480
+
481
+ // ---------------------------------------------------------------------------
482
+ // claim 5 — the board DECIDES. Without a passing sitting nothing is written
483
+ // anywhere, and the real board read is what says so.
484
+ // ---------------------------------------------------------------------------
485
+
486
+ await test('with no sitting that passed, nothing is created — the board decides, the ideator recommends', async () => {
487
+ await writeFullIdea(); // deliberately NO board pass seeded
488
+ const r = await drive(postRatifyGoal, { params: { id: IDEA_ID }, body: OK_BODY });
489
+ assert.equal(r.status, 409, JSON.stringify(r.payload));
490
+ assert.equal(r.payload.error, 'BOARD_HAS_NOT_PASSED');
491
+
492
+ assert.equal(db.goals.size, 0, 'no goal');
493
+ assert.equal(db.criteria.length, 0, 'no criterion');
494
+ assert.equal(db.ratifications.length, 0, 'no audit row');
495
+ assert.equal(db.ideas.get(IDEA_ID).status, 'open', 'and the idea is still open for the board');
496
+ assert.ok(queries.some((q) => /^ROLLBACK$/i.test(q.sql)), 'the transaction rolled back');
497
+ });
498
+
499
+ await test('an item the board OPENED but never closed passed is not a mandate either', async () => {
500
+ db.boardItems.push({
501
+ id: '901', subject_type: 'full_idea', subject_id: IDEA_ID, outcome: 'open',
502
+ opened_at: 'A', closed_at: null, constitution: {},
503
+ });
504
+ await writeFullIdea();
505
+ const r = await drive(postRatifyGoal, { params: { id: IDEA_ID }, body: OK_BODY });
506
+ assert.equal(r.payload.error, 'BOARD_HAS_NOT_PASSED', JSON.stringify(r.payload));
507
+ assert.equal(db.goals.size, 0, 'and still no goal');
508
+ });
509
+
510
+ // ---------------------------------------------------------------------------
511
+ // claim 6 — the author's recommendation is RECORDED and never BRANCHED ON. The
512
+ // board may decide the thought was bigger than its author thought.
513
+ // ---------------------------------------------------------------------------
514
+
515
+ await test('an author who recommended a criterion still gets a goal when the board chose one, with their recommendation kept', async () => {
516
+ seedBoardPass();
517
+ await writeFullIdea({ recommendation: 'criterion' });
518
+ const r = await drive(postRatifyGoal, { params: { id: IDEA_ID }, body: OK_BODY });
519
+ assert.equal(r.status, 201, JSON.stringify(r.payload));
520
+
521
+ const goal = await lifecycleDb.getGoal(r.payload.goal.id);
522
+ assert.ok(goal, 'the board’s decision stands over the recommendation');
523
+ assert.ok(goal.description.includes(ANSWERS.success), 'and the goal still carries the answers');
524
+
525
+ const audit = db.ratifications.find((x) => x.goal_id === String(goal.id));
526
+ assert.equal(audit.recommendation_at_filing, 'criterion',
527
+ 'what the author asked for is on the record beside what the board did');
528
+ assert.equal(r.payload.recommendation_at_filing, 'criterion');
529
+ });
530
+
531
+ // ---------------------------------------------------------------------------
532
+ // claim 7 — the same thought cannot become two goals. Ratifying twice refuses
533
+ // and leaves the first goal exactly as it was.
534
+ // ---------------------------------------------------------------------------
535
+
536
+ await test('one thought, one goal — a second ratification refuses and changes nothing', async () => {
537
+ seedBoardPass();
538
+ await writeFullIdea();
539
+ const first = await drive(postRatifyGoal, { params: { id: IDEA_ID }, body: OK_BODY });
540
+ assert.equal(first.status, 201);
541
+ const before = await lifecycleDb.getGoal(first.payload.goal.id);
542
+
543
+ const second = await drive(postRatifyGoal, { params: { id: IDEA_ID }, body: OK_BODY });
544
+ assert.ok(second.status >= 400, `the second is refused, got ${second.status}`);
545
+ assert.equal(db.goals.size, 1, 'still exactly one goal');
546
+ assert.equal(db.criteria.length, 1, 'and one criterion');
547
+ assert.equal(db.ratifications.length, 1, 'and one ratification on the record');
548
+
549
+ const after = await lifecycleDb.getGoal(first.payload.goal.id);
550
+ assert.deepEqual(after, before, 'the first goal is untouched');
551
+ });
552
+
553
+ // ---------------------------------------------------------------------------
554
+ // claim 8 — the thought is the author's; the AUTHORITY is the ratifier's. Filing
555
+ // an idea is open to every rank, so owning the goal your idea became would turn
556
+ // a liked thought into a grant of authority nobody reviewed.
557
+ // ---------------------------------------------------------------------------
558
+
559
+ await test('the goal is owned by the ratifier, not the author — while the author keeps the thought', async () => {
560
+ seedBoardPass();
561
+ await writeFullIdea();
562
+ const r = await drive(postRatifyGoal, { params: { id: IDEA_ID }, body: OK_BODY });
563
+ const goal = await lifecycleDb.getGoal(r.payload.goal.id);
564
+
565
+ assert.equal(String(goal.created_by), RATIFIER, 'the ratifier owns the goal');
566
+ const leads = db.goalMembers.filter((m) => m.goal_id === String(goal.id) && m.membership_kind === 'lead');
567
+ assert.deepEqual(leads.map((m) => m.builder_id), [RATIFIER],
568
+ 'and the lead membership — the authority to author tasks inside the wall — is theirs alone');
569
+ assert.ok(!leads.some((m) => m.builder_id === AUTHOR),
570
+ 'filing an idea the board liked did not make its author a goal lead');
571
+
572
+ // What the author DOES keep.
573
+ assert.equal(r.payload.proposer_id, AUTHOR);
574
+ assert.ok(goal.description.includes(`/idea/${IDEA_ID}`), 'the goal still points at their thought');
575
+ });
576
+
577
+ // ---------------------------------------------------------------------------
578
+ // claim 9 — the gates POST /goals applies are applied here too. `idea.ratify`'s
579
+ // floor is metic, so a path that skipped them would be a Metic-reachable way
580
+ // around all of them, with the gates still in the code and no longer in the way.
581
+ // ---------------------------------------------------------------------------
582
+
583
+ await test('ratification is not a way around the goal-creation gates a Metic cannot otherwise pass', async () => {
584
+ seedBoardPass();
585
+ await writeFullIdea();
586
+ // A protected scope is Archon-only wherever a goal is created.
587
+ const protectedScope = api.moduleScopeMap.moduleKeys()
588
+ .find((k) => api.moduleScopeMap.scopeIncludesProtected([k]));
589
+ assert.ok(protectedScope, 'precondition: this instance declares at least one protected module');
590
+
591
+ const r = await drive(postRatifyGoal, {
592
+ params: { id: IDEA_ID },
593
+ body: { target_version: 'TEST-V1', scope_modules: [protectedScope] },
594
+ rank: 'metic',
595
+ });
596
+ assert.equal(r.status, 403, JSON.stringify(r.payload));
597
+ assert.equal(db.goals.size, 0, 'and no goal was created on the way to the refusal');
598
+ assert.equal(db.ideas.get(IDEA_ID).status, 'open', 'the idea is untouched');
599
+ });
600
+
601
+ await test('a version that will not take a new goal refuses the ratification too', async () => {
602
+ seedBoardPass();
603
+ await writeFullIdea();
604
+ db.versions.set('TEST-V1', { id: 'TEST-V1', name: 'Test', status: 'building', scope_doc_path: null, started_at: null, shipped_at: null, done_when: null });
605
+
606
+ const r = await drive(postRatifyGoal, { params: { id: IDEA_ID }, body: OK_BODY, rank: 'metic' });
607
+ assert.ok(r.status >= 400, `a building version's goal set is closed to a Metic, got ${r.status}`);
608
+ assert.equal(db.goals.size, 0, 'no goal');
609
+ assert.equal(db.criteria.length, 0, 'no criterion');
610
+ });
611
+
612
+ // ---------------------------------------------------------------------------
613
+ // claim 10 — a goal is never born without its gate. The pair is one act, so a
614
+ // composer that produced a description but no done-when writes neither row.
615
+ // ---------------------------------------------------------------------------
616
+
617
+ await test('a Full Idea missing the success answer produces NO goal, not a goal with no gate', async () => {
618
+ seedBoardPass();
619
+ await writeFullIdea();
620
+ // Reach past capture: a stored row whose success answer has gone blank is the
621
+ // only way this reaches the composer, and the composer must still refuse —
622
+ // ADR 0263's close gate reads done-when criteria, so a goal born with none
623
+ // holds its version open forever.
624
+ const g = db.grades.get(IDEA_ID);
625
+ g.field_values = jsonbOrder({ ...ANSWERS, success: ' ' });
626
+
627
+ const r = await drive(postRatifyGoal, { params: { id: IDEA_ID }, body: OK_BODY });
628
+ assert.equal(r.status, 400, JSON.stringify(r.payload));
629
+ assert.equal(r.payload.error, 'CANNOT_COMPOSE_GOAL');
630
+ assert.equal(r.payload.field, 'success', 'and it names the answer that was missing');
631
+ assert.equal(db.goals.size, 0, 'no goal was created');
632
+ assert.equal(db.criteria.length, 0, 'and no criterion');
633
+ });
634
+
635
+ // ---------------------------------------------------------------------------
636
+
637
+ console.log(`\n${passed} passed, ${failed} failed`);
638
+ process.exit(failed === 0 ? 0 : 1);