@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.
- package/.bongos-core.json +54 -29
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +2 -0
- package/clients/bongos-client/index.cjs +2 -0
- package/clients/bongos-client/index.d.ts +3 -0
- package/clients/bongos-client/index.mjs +2 -0
- package/docs/api/openapi.json +56 -3
- package/docs/api-reference.md +3 -2
- package/docs/copy-inventory.md +22 -18
- package/docs/copy-registry.json +67 -21
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/collab-lib.js +31 -0
- package/modules/hall-ui/public/collab.css +44 -0
- package/modules/hall-ui/public/collab.html +39 -2
- package/modules/hall-ui/public/collab.js +68 -4
- package/modules/lifecycle/collab-settle.js +77 -0
- package/modules/lifecycle/help-requests.js +99 -3
- package/modules/lifecycle/migrations/lifecycle_012_collab_self_settling.sql +114 -0
- package/modules/lifecycle/routes/help-requests.js +80 -0
- package/modules/lifecycle/routes/recommendations.js +19 -0
- package/modules/lifecycle/task-recommendations.js +71 -4
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/src/module-api.js +1 -1
- package/tests/collab_self_settling.mjs +382 -0
- package/tests/hall_collab_world.mjs +18 -3
- package/tests/ideator_full_idea_shapes_space_proof.mjs +638 -0
- package/tests/ideator_large_dead_thought_proof.mjs +399 -0
|
@@ -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);
|