@bongos/core 1.19.535 → 1.19.537

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/.bongos-core.json +63 -33
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +8 -0
  4. package/clients/bongos-client/index.cjs +8 -0
  5. package/clients/bongos-client/index.d.ts +11 -0
  6. package/clients/bongos-client/index.mjs +8 -0
  7. package/docs/api/openapi.json +187 -4
  8. package/docs/api-reference.md +10 -2
  9. package/docs/copy-inventory.md +34 -15
  10. package/docs/copy-registry.json +233 -30
  11. package/docs/module-api-changelog.md +4 -0
  12. package/modules/dev-box/app/src/vendor/bongos-client.cjs +8 -0
  13. package/modules/government/catalog.js +1 -0
  14. package/modules/government/migrations/government_010_project_settings_manage.sql +58 -0
  15. package/modules/hall-ui/public/project-settings.css +90 -0
  16. package/modules/hall-ui/public/project-settings.html +68 -0
  17. package/modules/hall-ui/public/project-settings.js +249 -0
  18. package/modules/hall-ui/public/shell.js +3 -0
  19. package/modules/lifecycle/goal-category.js +82 -0
  20. package/modules/lifecycle/module.json +2 -1
  21. package/modules/lifecycle/routes/goals.js +16 -0
  22. package/modules/lifecycle/routes/project-settings.js +263 -0
  23. package/modules/provisioning/provisioning.js +33 -3
  24. package/modules/provisioning/routes/provisioning.js +15 -1
  25. package/modules/provisioning/tests/provisioning.mjs +29 -2
  26. package/package-lock.json +2 -2
  27. package/package.json +1 -1
  28. package/src/bongos/serve-internal.js +12 -1
  29. package/src/module-api.js +13 -1
  30. package/tests/hall_nav.mjs +3 -1
  31. package/tests/hall_page_gate_map.mjs +4 -0
  32. package/tests/project_settings_page.mjs +337 -0
  33. package/tests/provisioning_module_selection.mjs +114 -5
@@ -0,0 +1,337 @@
1
+ // tests/project_settings_page.mjs — the five fundamentals, and the rule that keeps
2
+ // them honest (task 1003280, goal 1000072, criterion legibility-project-settings).
3
+ //
4
+ // Three things are pinned here, and the third matters most.
5
+ //
6
+ // 1. The route works: one read returns all five rows, the timer round-trips through
7
+ // the shared parser, and the category set can be renamed, reordered and retired.
8
+ //
9
+ // 2. AN ABSENT FUNDAMENTAL IS REPORTED ABSENT. `upmarket_percent` has no knob behind
10
+ // it anywhere in this codebase, and equity is a settled owner decision that it is
11
+ // NOT real. Both must render as themselves. A plausible zero on either would be a
12
+ // fabricated claim about the project's economics on the page its owner trusts most
13
+ // — a worse failure than the illegibility this goal exists to fix.
14
+ //
15
+ // 3. THE PAGE RE-TYPES NOTHING. No formula, bound, default or file path is duplicated
16
+ // in the client. That is the whole reason this page can be believed: a settings
17
+ // surface carrying its own copy of `round(cost × 1.20)` or of "1..365" drifts from
18
+ // the code that actually decides, which is precisely ADR 0226's failure one release
19
+ // later. This is asserted against the real file, because a rule that lives only in
20
+ // a comment comes back.
21
+ //
22
+ // Run: node --test tests/project_settings_page.mjs
23
+ import { strict as assert } from 'node:assert';
24
+ import { test } from 'node:test';
25
+ import { createRequire } from 'node:module';
26
+ import fs from 'node:fs';
27
+ import path from 'node:path';
28
+ import { fileURLToPath } from 'node:url';
29
+
30
+ const require = createRequire(import.meta.url);
31
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
32
+ const read = (...p) => fs.readFileSync(path.join(ROOT, ...p), 'utf8');
33
+
34
+ // The anti-drift assertions below are about what the code DOES, so they read it with
35
+ // full-line comments removed. Without this they trip on the very prose that explains
36
+ // the rule — this file's own header quotes "1..365" as the thing not to re-type, and
37
+ // the grant migration names the pre-rename table to warn against copying it.
38
+ const stripLineComments = (src, marker) => src
39
+ .split('\n')
40
+ .filter((line) => !line.trimStart().startsWith(marker))
41
+ .join('\n');
42
+
43
+ const gc = require(path.join(ROOT, 'modules', 'lifecycle', 'goal-category.js'));
44
+ const api = require(path.join(ROOT, 'src', 'module-api.js'));
45
+
46
+ const fakeRes = () => ({
47
+ code: null, statusCode: 200, payload: null, headers: {},
48
+ fail(code, status, extra) { this.code = code; this.statusCode = typeof status === 'number' ? status : status?.status ?? 400; this.payload = { error: code, ...(extra || {}) }; return this; },
49
+ status(c) { this.statusCode = c; return this; },
50
+ set(k, v) { this.headers[k] = v; return this; },
51
+ json(p) { this.payload = p; return this; },
52
+ });
53
+
54
+ // ---- the route, executed for real -----------------------------------------
55
+ //
56
+ // The router is built against the REAL module-api and the REAL goal-category; only
57
+ // the two calls that would reach Postgres are swapped. Stubbing the row builders
58
+ // instead would prove nothing about the shape the page actually receives.
59
+ const WORKING_REWARD = { rewardConfig: () => ({ marginPct: 20, usdPeg: 1, mode: 'cost-plus-only', perTaskCredit: false, ideatorCredit: true }) };
60
+ function buildRouterWithStubs({ timer, categories, reward = WORKING_REWARD }) {
61
+ const saved = {
62
+ resolveRotDays: api.resolveRotDays,
63
+ resolveOptional: api.resolveOptional,
64
+ list: gc.listCategories,
65
+ set: api.projectSettings.set,
66
+ };
67
+ api.resolveRotDays = async () => timer;
68
+ // reward: WORKING_REWARD (default) | null (port absent) | 'throws' (rewardConfig raises).
69
+ const rewardPort = reward === 'throws'
70
+ ? { rewardConfig: () => { throw new Error('reward boom'); } }
71
+ : reward;
72
+ api.resolveOptional = (port) => (port === 'reward' ? rewardPort : null);
73
+ gc.listCategories = async () => categories;
74
+ api.projectSettings.set = async () => ({ key: 'attention.rot_days', value: '45' });
75
+
76
+ delete require.cache[require.resolve(path.join(ROOT, 'modules', 'lifecycle', 'routes', 'project-settings.js'))];
77
+ const router = require(path.join(ROOT, 'modules', 'lifecycle', 'routes', 'project-settings.js'))();
78
+ const restore = () => {
79
+ api.resolveRotDays = saved.resolveRotDays;
80
+ api.resolveOptional = saved.resolveOptional;
81
+ gc.listCategories = saved.list;
82
+ api.projectSettings.set = saved.set;
83
+ };
84
+ // The handler is the LAST layer on the matching route (requireBuilder and the
85
+ // permission gate sit in front); calling it directly is what keeps this a unit test
86
+ // of the payload rather than of the auth stack, which has its own tests.
87
+ const handlerFor = (method, routePath) => {
88
+ const layer = router.stack.find((l) => l.route && l.route.path === routePath && l.route.methods[method]);
89
+ assert.ok(layer, `no ${method.toUpperCase()} ${routePath}`);
90
+ const stack = layer.route.stack;
91
+ return stack[stack.length - 1].handle;
92
+ };
93
+ return { handlerFor, restore };
94
+ }
95
+
96
+ const TIMER_OK = { days: 30, source: 'default', invalid: null };
97
+ const CATS = [
98
+ { id: 1, slug: 'platform', label: 'Platform', sort_order: 10, active: true },
99
+ { id: 2, slug: 'retired-thing', label: 'Retired thing', sort_order: 20, active: false },
100
+ ];
101
+
102
+ test('one read returns all five fundamentals, each naming where its value came from', async () => {
103
+ const { handlerFor, restore } = buildRouterWithStubs({ timer: TIMER_OK, categories: CATS });
104
+ try {
105
+ const res = fakeRes();
106
+ await handlerFor('get', '/project-settings')({ query: {} }, res);
107
+ const rows = res.payload.rows;
108
+ assert.equal(rows.length, 5, 'the page draws five rows, so the read returns five');
109
+ assert.deepEqual(rows.map((r) => r.key),
110
+ ['rot_timer', 'work_categories', 'credit_calculation', 'equity_calculation', 'upmarket_percent']);
111
+ // Provenance is the point of the page: every row says where its value came from.
112
+ for (const r of rows) {
113
+ assert.ok(r.reads && r.reads.file, `${r.key} names no file`);
114
+ assert.ok(r.sentence && r.sentence.length > 20, `${r.key} has no readable sentence`);
115
+ }
116
+ // Exactly two knobs — the criterion's "change the two that are knobs".
117
+ assert.deepEqual(rows.filter((r) => r.editable).map((r) => r.key), ['rot_timer', 'work_categories']);
118
+ // A no-store receipt: a cached settings read is wrong the moment a knob turns.
119
+ assert.equal(res.headers['Cache-Control'], 'no-store');
120
+ } finally { restore(); }
121
+ });
122
+
123
+ test('an absent fundamental is reported absent, never as a plausible zero', async () => {
124
+ const { handlerFor, restore } = buildRouterWithStubs({ timer: TIMER_OK, categories: CATS });
125
+ try {
126
+ const res = fakeRes();
127
+ await handlerFor('get', '/project-settings')({ query: {} }, res);
128
+ const by = Object.fromEntries(res.payload.rows.map((r) => [r.key, r]));
129
+
130
+ assert.equal(by.upmarket_percent.state, 'absent');
131
+ assert.equal(by.upmarket_percent.value, undefined, 'an unconfigured knob must carry NO value');
132
+ assert.match(by.upmarket_percent.sentence, /no such setting yet/i);
133
+ assert.doesNotMatch(by.upmarket_percent.sentence, /\b0\s*%/, 'never render the absent knob as 0%');
134
+
135
+ assert.equal(by.equity_calculation.state, 'not-yet-real');
136
+ assert.match(by.equity_calculation.sentence, /no legal equity is issued/i);
137
+ assert.match(by.equity_calculation.sentence, /no cap table/i);
138
+ } finally { restore(); }
139
+ });
140
+
141
+ test('the credit row reports the formula in force, and says the estimate is not paid', async () => {
142
+ const { handlerFor, restore } = buildRouterWithStubs({ timer: TIMER_OK, categories: CATS });
143
+ try {
144
+ const res = fakeRes();
145
+ await handlerFor('get', '/project-settings')({ query: {} }, res);
146
+ const credit = res.payload.rows.find((r) => r.key === 'credit_calculation');
147
+ assert.equal(credit.editable, false);
148
+ assert.match(credit.sentence, /× 1\.20/, 'the multiplier comes from the resolved policy');
149
+ // cost-plus-only: the per-task estimate is advertised and never paid (ADR 0146).
150
+ assert.match(credit.sentence, /advertised guide only/i);
151
+ } finally { restore(); }
152
+ });
153
+
154
+ test('the credit row degrades named when the economy module is absent', async () => {
155
+ const { handlerFor, restore } = buildRouterWithStubs({ timer: TIMER_OK, categories: CATS, reward: null });
156
+ try {
157
+ const res = fakeRes();
158
+ await handlerFor('get', '/project-settings')({ query: {} }, res);
159
+ const credit = res.payload.rows.find((r) => r.key === 'credit_calculation');
160
+ assert.equal(credit.state, 'unavailable');
161
+ assert.equal(credit.value, undefined, 'no formula to report → no value');
162
+ assert.match(credit.sentence, /not enabled/i);
163
+ } finally { restore(); }
164
+ });
165
+
166
+ test('the credit row degrades named when rewardConfig() throws, rather than 500-ing the page', async () => {
167
+ const { handlerFor, restore } = buildRouterWithStubs({ timer: TIMER_OK, categories: CATS, reward: 'throws' });
168
+ try {
169
+ const res = fakeRes();
170
+ await handlerFor('get', '/project-settings')({ query: {} }, res);
171
+ // The whole read must still succeed — one bad lens degrades its own row, not the page.
172
+ assert.equal(res.code, null, 'the GET must not fail');
173
+ const credit = res.payload.rows.find((r) => r.key === 'credit_calculation');
174
+ assert.equal(credit.state, 'unavailable');
175
+ assert.equal(credit.value, undefined);
176
+ assert.match(credit.sentence, /could not be read/i);
177
+ } finally { restore(); }
178
+ });
179
+
180
+ test('the stale-timer row names the OTHER timer, so the page cannot be misread as configuring the sweep', async () => {
181
+ const { handlerFor, restore } = buildRouterWithStubs({ timer: TIMER_OK, categories: CATS });
182
+ try {
183
+ const res = fakeRes();
184
+ await handlerFor('get', '/project-settings')({ query: {} }, res);
185
+ const rot = res.payload.rows.find((r) => r.key === 'rot_timer');
186
+ assert.ok(rot.sibling, 'the claim sweep must be named on this row');
187
+ assert.equal(rot.sibling.reads.file, 'src/stale-timer.js');
188
+ assert.match(rot.sibling.sentence, /RELEASES it without asking/);
189
+ // The rot timer only ever ASKS — the reporting half of ADR 0226's asymmetry.
190
+ assert.match(rot.sentence, /ASKS a person/);
191
+ } finally { restore(); }
192
+ });
193
+
194
+ test('a configured-but-unusable timer is reported on screen, not thrown', async () => {
195
+ const invalid = { days: 30, source: 'default', invalid: { where: "project_settings['attention.rot_days']", raw: 'soon', reason: 'not a number' } };
196
+ const { handlerFor, restore } = buildRouterWithStubs({ timer: invalid, categories: CATS });
197
+ try {
198
+ const res = fakeRes();
199
+ await handlerFor('get', '/project-settings')({ query: {} }, res);
200
+ const rot = res.payload.rows.find((r) => r.key === 'rot_timer');
201
+ assert.equal(rot.invalid.raw, 'soon');
202
+ assert.equal(rot.value.days, 30, 'the fallback that is actually in force is still reported');
203
+ } finally { restore(); }
204
+ });
205
+
206
+ test('the timer write refuses a bad body in two layers, each with its own code', async () => {
207
+ const { handlerFor, restore } = buildRouterWithStubs({ timer: TIMER_OK, categories: CATS });
208
+ const patch = handlerFor('patch', '/project-settings/rot-days');
209
+ try {
210
+ // Layer 1 — the strict ADR 0118 schema settles the TYPE and rejects unknown keys.
211
+ for (const bad of [{ days: 'soon' }, { days: '' }, { days: null }, {}, { days: 30, sneaky: 1 }]) {
212
+ const res = fakeRes();
213
+ await patch({ body: bad, builder: { id: 7 } }, res);
214
+ assert.equal(res.code, 'validation_failed', `accepted body ${JSON.stringify(bad)}`);
215
+ assert.equal(res.statusCode, 400);
216
+ }
217
+ // Layer 2 — the resolver's OWN parser settles the RANGE, so the form and the reader
218
+ // of the value can never disagree about what is acceptable.
219
+ for (const bad of [0, -3, 1.5, 400]) {
220
+ const res = fakeRes();
221
+ await patch({ body: { days: bad }, builder: { id: 7 } }, res);
222
+ assert.equal(res.code, 'bad_rot_days', `accepted ${JSON.stringify(bad)}`);
223
+ assert.equal(res.statusCode, 400);
224
+ }
225
+ } finally { restore(); }
226
+ });
227
+
228
+ test('a good timer write echoes what the system will now READ, not what was sent', async () => {
229
+ const { handlerFor, restore } = buildRouterWithStubs({ timer: { days: 45, source: 'project_settings', invalid: null }, categories: CATS });
230
+ try {
231
+ const res = fakeRes();
232
+ await handlerFor('patch', '/project-settings/rot-days')({ body: { days: 45 }, builder: { id: 7 } }, res);
233
+ assert.equal(res.payload.ok, true);
234
+ assert.equal(res.payload.rot_days, 45);
235
+ assert.equal(res.payload.source, 'project_settings');
236
+ // The receipt carries provenance, never a bare number (ADR 0226).
237
+ assert.match(res.payload.describe, /45d \(project_settings\)/);
238
+ } finally { restore(); }
239
+ });
240
+
241
+ // ---- the category set's shape ----------------------------------------------
242
+
243
+ test('PATCH refuses a body that changes nothing rather than answering 200', async () => {
244
+ const handler = gc.categoryUpdateHandler({
245
+ update: async () => { throw new Error('must not be called'); },
246
+ validateOrRespond: () => false, LIMITS: { TITLE: 200 },
247
+ parseId: () => 1, log: null,
248
+ });
249
+ const res = fakeRes();
250
+ await handler({ params: { id: '1' }, body: {} }, res);
251
+ assert.equal(res.code, 'nothing_to_update');
252
+ });
253
+
254
+ test('PATCH refuses a fractional sort_order instead of silently rounding it', async () => {
255
+ const handler = gc.categoryUpdateHandler({
256
+ update: async () => { throw new Error('must not be called'); },
257
+ validateOrRespond: () => false, LIMITS: { TITLE: 200 },
258
+ parseId: () => 1, log: null,
259
+ });
260
+ const res = fakeRes();
261
+ await handler({ params: { id: '1' }, body: { sort_order: 2.5 } }, res);
262
+ assert.equal(res.code, 'bad_sort_order');
263
+ });
264
+
265
+ test('retiring a category is a flag, never a delete — the label stays resolvable', async () => {
266
+ let got = null;
267
+ const handler = gc.categoryUpdateHandler({
268
+ update: async (args) => { got = args; return { ...CATS[0], active: false }; },
269
+ validateOrRespond: () => false, LIMITS: { TITLE: 200 },
270
+ parseId: () => 1, log: null,
271
+ });
272
+ const res = fakeRes();
273
+ await handler({ params: { id: '1' }, body: { active: false } }, res);
274
+ assert.equal(res.payload.category.active, false);
275
+ // Only what was sent travels: a retire must not blank the label by omission.
276
+ assert.equal(got.label, undefined);
277
+ assert.equal(got.sortOrder, undefined);
278
+ assert.equal(got.active, false);
279
+ });
280
+
281
+ test('a missing category is a 404, and it costs ONE query — no separate pre-read', async () => {
282
+ // updateCategory returns null for a row that is not there; that IS the 404, so the
283
+ // handler no longer issues a second read just to produce it (task 1003280 grade).
284
+ let updateCalls = 0;
285
+ const handler = gc.categoryUpdateHandler({
286
+ update: async () => { updateCalls += 1; return null; },
287
+ validateOrRespond: () => false, LIMITS: { TITLE: 200 },
288
+ parseId: () => 999, log: null,
289
+ });
290
+ const res = fakeRes();
291
+ await handler({ params: { id: '999' }, body: { label: 'x' } }, res);
292
+ assert.equal(res.code, 'category_not_found');
293
+ assert.equal(res.statusCode, 404);
294
+ assert.equal(updateCalls, 1, 'exactly one PK query — the redundant pre-read is gone');
295
+ });
296
+
297
+ test('the slug is not patchable — it is the key goal prose refers to', () => {
298
+ const src = read('modules', 'lifecycle', 'goal-category.js');
299
+ const fn = src.slice(src.indexOf('async function updateCategory'));
300
+ const body = fn.slice(0, fn.indexOf('\n}'));
301
+ assert.doesNotMatch(body, /\bslug\s*=/, 'updateCategory must never assign slug');
302
+ });
303
+
304
+ // ---- the anti-drift rule (the one that makes this page believable) ----------
305
+
306
+ test('the ROUTE re-types no timer bound either — the drift the grade caught, pinned one layer up', () => {
307
+ // The major finding on this task's first grade: the page's anti-drift rule held on the
308
+ // CLIENT but the ROUTE re-typed `min: 1, max: 365` and the 400 message re-typed the
309
+ // range. The bounds live once, in src/bongos/project-settings.js. Assert the route
310
+ // carries neither literal in executable code.
311
+ const route = stripLineComments(read('modules', 'lifecycle', 'routes', 'project-settings.js'), '//');
312
+ assert.doesNotMatch(route, /\b365\b/, 'the route must read MAX_ROT_DAYS, not re-type 365');
313
+ assert.match(route, /api\.projectSettings\.(MIN|MAX)_ROT_DAYS/, 'the route reads the bounds from the single source');
314
+ });
315
+
316
+ test('the page re-types no formula, bound, default or file path from the server', () => {
317
+ const page = stripLineComments(read('modules', 'hall-ui', 'public', 'project-settings.js'), '//');
318
+ // The credit formula lives in economy and arrives as a sentence.
319
+ assert.doesNotMatch(page, /1\.2\b|\*\s*1\.20|marginPct\s*[/*]/, 'the client must not compute the reward');
320
+ // The rot bounds live in src/bongos/project-settings.js and arrive in row.value.
321
+ assert.doesNotMatch(page, /\b365\b/, 'the client must not re-type the timer ceiling');
322
+ // Provenance paths are server-supplied strings, never authored in the client.
323
+ assert.doesNotMatch(page, /src\/stale-timer\.js|core_226|core_231/, 'the client must not author provenance paths');
324
+ });
325
+
326
+ test('the page is gated, the atom exists, and a grant migration backs it', () => {
327
+ assert.match(read('tests', 'hall_page_gate_map.mjs'), /'\/builders\/project-settings': 'requireGovernmentPage'/);
328
+ assert.match(read('modules', 'government', 'catalog.js'), /key: 'project\.settings\.manage'/);
329
+ const grantSrc = read('modules', 'government', 'migrations', 'government_010_project_settings_manage.sql');
330
+ const grant = stripLineComments(grantSrc, '--');
331
+ // One block per rank — the drift guard scans for this literal shape.
332
+ assert.match(grant, /SELECT 'metic', unnest\(ARRAY\[\s*'project\.settings\.manage'/);
333
+ assert.match(grant, /SELECT 'archon', unnest\(ARRAY\[\s*'project\.settings\.manage'/);
334
+ // The renamed table, not the pre-government_001 one.
335
+ assert.match(grant, /INSERT INTO government_rank_permissions/);
336
+ assert.doesNotMatch(grant, /governance_role_permissions/);
337
+ });
@@ -169,15 +169,20 @@ provisioning.createInstance = async (_db, args) => {
169
169
  provisioning.enqueueIntent = async (_db, id, action) => ({ intent: { id: 9, action, state: 'pending' }, created: true });
170
170
  provisioning.getOpenIntent = async () => null;
171
171
  provisioning.recordEvent = async () => {};
172
- // Mirrors the real statement: `detail = detail || $n::jsonb` MERGES the
173
- // normalized answers onto what is already stored. The row the route then hands
174
- // `effectiveModules` is that merged one, so a stub that returned the pre-patch
175
- // row would hide exactly the staleness this test exists to catch.
172
+ // Mirrors the real statement: `detail = (detail || $n::jsonb) - $m::text[]` MERGES
173
+ // the patch's answers onto what is already stored and DROPS the keys it explicitly
174
+ // un-answered (task 1003577). The row the route then hands `effectiveModules` is
175
+ // that merged one, so a stub that returned the pre-patch row would hide exactly the
176
+ // staleness this test exists to catch — and one that only merged would hide the
177
+ // clear, which is the whole way back to "never answered".
176
178
  provisioning.updateInstanceDetail = async (_db, _id, patch) => {
177
179
  lastUpdatedRow = { ...detailRow };
178
180
  if (patch.description !== undefined) lastUpdatedRow.description = patch.description;
179
181
  if (patch.detail !== undefined) {
180
- lastUpdatedRow.detail = { ...(detailRow.detail || {}), ...provisioning.normalizeProjectDetail(patch.detail) };
182
+ const { set, clear } = provisioning.projectDetailPatch(patch.detail);
183
+ const next = { ...(detailRow.detail || {}), ...set };
184
+ for (const key of clear) delete next[key];
185
+ lastUpdatedRow.detail = next;
181
186
  }
182
187
  // `modules` REPLACES and is normalized on the way in — the real statement's
183
188
  // `modules = $n::jsonb` over normalizeModuleSelection (BV1.R23, task 1002342).
@@ -502,6 +507,110 @@ await ta('PATCH /detail: another builder’s project is a 404, not a module chan
502
507
  detailRow = null; lastUpdatedRow = null;
503
508
  });
504
509
 
510
+ // ── Un-answering the interview ─────────────────────────────────────────────
511
+ // Task 1003577. `effectiveModules` reads three states off a row and "nobody said"
512
+ // is one of them, but a MERGING write could only ever add an answer: `{}` merged to
513
+ // nothing and `null` was dropped by the vocabulary, so the first `team_shape` an
514
+ // owner ever sent was permanent and the bundle it moved could never move back. An
515
+ // explicit per-key `null` is now the un-answer (RFC 7396, and `description`'s own
516
+ // rule on this same route), and the read-back has to resolve as a never-answered row
517
+ // — not merely "close to one".
518
+
519
+ await ta('PATCH /detail: answer then CLEAR — the row reads back exactly as one that was never asked', async () => {
520
+ // Arrange — a game project that skipped the picker, so its modules resolve from
521
+ // the bundle and the interview answer is the only thing that can move them.
522
+ const virgin = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: null, detail: {} };
523
+ const neverAnswered = effectiveModules(virgin);
524
+ assert.equal(neverAnswered.source, 'bundle', 'the fixture really is an unanswered project');
525
+
526
+ detailRow = { ...virgin };
527
+ lastUpdatedRow = null;
528
+ const answered = await patch('/provisioning/instances/41/detail', { detail: { team_shape: 'solo' } });
529
+ assert.equal(answered.status, 200, answered.raw);
530
+ assert.deepEqual(answered.body.detail, { team_shape: 'solo' });
531
+ assert.notDeepEqual(answered.body.modules.selected, neverAnswered.selected,
532
+ 'the fixture is only meaningful if the answer really moved the bundle');
533
+
534
+ // Act — the owner takes it back
535
+ detailRow = lastUpdatedRow;
536
+ lastUpdatedRow = null;
537
+ const cleared = await patch('/provisioning/instances/41/detail', { detail: { team_shape: null } });
538
+
539
+ // Assert — the stored answer is GONE, not stored as null, and every derived read
540
+ // is the unanswered one again
541
+ assert.equal(cleared.status, 200, cleared.raw);
542
+ assert.ok(!('team_shape' in lastUpdatedRow.detail), 'the key left the jsonb entirely');
543
+ assert.deepEqual(cleared.body.detail, {}, 'and the read surface says the interview is unanswered');
544
+ assert.deepEqual(cleared.body.modules, neverAnswered, 'source, preset and adjustments are the never-answered ones');
545
+ assert.equal(cleared.body.modules.source, 'bundle');
546
+ assert.deepEqual(cleared.body.modules.selected, bundleForProjectType('game').bundle, 'the UNADJUSTED preset');
547
+ assert.deepEqual(cleared.body.modules.adjustments, [], 'no rule fired, so nothing claims to have');
548
+ assert.deepEqual(cleared.body.publish, answered.body.publish, 'clearing an answer is not a publish change');
549
+ detailRow = null; lastUpdatedRow = null;
550
+ });
551
+
552
+ await ta('PATCH /detail: clearing one key leaves every other stored key alone', async () => {
553
+ // The statement drops the named key rather than replacing the column, so an answer
554
+ // the patch never mentioned survives — including one stored before it left the
555
+ // vocabulary, which `projectDetail` filters on READ and must not silently delete.
556
+ detailRow = {
557
+ id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: null,
558
+ detail: { team_shape: 'community', legacy_answer: 'kept' },
559
+ };
560
+ lastUpdatedRow = null;
561
+
562
+ const res = await patch('/provisioning/instances/41/detail', { detail: { team_shape: null } });
563
+
564
+ assert.equal(res.status, 200, res.raw);
565
+ assert.deepEqual(lastUpdatedRow.detail, { legacy_answer: 'kept' }, 'only the nulled key was dropped');
566
+ assert.deepEqual(res.body.detail, {}, 'and the read still filters the survivor through the vocabulary');
567
+ detailRow = null; lastUpdatedRow = null;
568
+ });
569
+
570
+ await ta('PATCH /detail: an off-vocabulary value is still REFUSED, never mistaken for a clear', async () => {
571
+ // Only null un-answers. A typo, an empty string or any other sentinel is the
572
+ // loud-validation case badProjectDetail was written for — silently treating one as
573
+ // "clear" would erase an answer the caller was trying to set.
574
+ detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: null, detail: { team_shape: 'solo' } };
575
+ for (const value of ['enormous', '', 0, false]) {
576
+ lastUpdatedRow = null;
577
+ const res = await patch('/provisioning/instances/41/detail', { detail: { team_shape: value } });
578
+ assert.equal(res.status, 400, `${JSON.stringify(value)} → ${res.raw}`);
579
+ assert.equal(res.body.error.code, 'bad_detail');
580
+ assert.match(res.body.error.message, /solo/, 'the message names the answers it does take');
581
+ assert.equal(lastUpdatedRow, null, 'a refused write touches nothing');
582
+ }
583
+ detailRow = null; lastUpdatedRow = null;
584
+ });
585
+
586
+ await ta('POST /instances: a null answer at CREATION is accepted and stores nothing — there is nothing to un-answer yet', async () => {
587
+ // badProjectDetail is shared with the create route, so the un-answer verb must not
588
+ // become a 400 there. Nothing to clear at creation, so it simply reads as no answer.
589
+ lastCreateArgs = null; existingRow = null;
590
+
591
+ const res = await post('/provisioning/instances', {
592
+ slug: 'mercury', hosting_shape: 'standalone', type: 'game', detail: { team_shape: null },
593
+ });
594
+
595
+ assert.equal(res.status, 201, `expected 201, got ${res.status} ${res.raw}`);
596
+ assert.deepEqual(res.body.instance.detail, {}, 'the row is born unanswered, as it would be with no detail at all');
597
+ });
598
+
599
+ await ta('PATCH /detail: an EMPTY detail patch still changes nothing — a skipped form is not an erase', async () => {
600
+ // RFC 7396's own rule, and the one ADR 0240 leans on: `{}` is the shape a form
601
+ // sends when it collected no answers, so making it erase would un-answer the
602
+ // interview every time an owner saved the step without touching it.
603
+ detailRow = { id: 41, owner_builder_id: 7, slug: 'mercury', status: 'active', type: 'game', modules: null, detail: { team_shape: 'solo' } };
604
+ lastUpdatedRow = null;
605
+
606
+ const res = await patch('/provisioning/instances/41/detail', { detail: {} });
607
+
608
+ assert.equal(res.status, 200, res.raw);
609
+ assert.deepEqual(lastUpdatedRow.detail, { team_shape: 'solo' }, 'the stored answer survives an empty patch');
610
+ assert.deepEqual(res.body.detail, { team_shape: 'solo' });
611
+ detailRow = null; lastUpdatedRow = null;
612
+ });
613
+
505
614
  server.close();
506
615
  console.log(`\n${passed} passed, ${failed} failed`);
507
616
  process.exit(failed === 0 ? 0 : 1);