@bongos/core 1.19.536 → 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.
package/.bongos-core.json CHANGED
@@ -2,11 +2,11 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.536",
6
- "core_contract": "1.19.536",
7
- "source_commit": "91d912719b37497561bd71e9aff21b0d07128ce7",
5
+ "core_version": "1.19.537",
6
+ "core_contract": "1.19.537",
7
+ "source_commit": "171115727a6362835dd549dbed353a8bc817e4ee",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-04T15:11:58.158Z",
9
+ "built_at": "2026-09-04T15:20:04.870Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 439,
@@ -17,7 +17,7 @@
17
17
  "gate": "passed"
18
18
  },
19
19
  "file_count": 2458,
20
- "tree_sha256": "d97db0f024e5887d1e765ab22ac86d124015c3a97cf3f44989b82c2d0f1493ac",
20
+ "tree_sha256": "2db763cfb805a63a9dd4a04cacedb717acd4aab6b9a8f3ce795a57f1bc49a0d4",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -1762,7 +1762,7 @@
1762
1762
  {
1763
1763
  "path": "docs/api/openapi.json",
1764
1764
  "mode": "0000644",
1765
- "sha256": "b2ce137944f23684f4ba805d4bda73ba1ab7477279e2a24fbcd5c068f7a25bad"
1765
+ "sha256": "8ae264d8c9983ff2841a8309c141b6fb57b395bb4b52894a4b49f58280d8fb1b"
1766
1766
  },
1767
1767
  {
1768
1768
  "path": "docs/architecture.md",
@@ -2637,7 +2637,7 @@
2637
2637
  {
2638
2638
  "path": "docs/module-api-changelog.md",
2639
2639
  "mode": "0000644",
2640
- "sha256": "1107b8275d73b7e9e3410a6153bee4107718272c3bc7c2471241ea9f5c076fc0"
2640
+ "sha256": "1b719e47366fff0090cc4f3a18743953191d612e150e59a6c68e5d27c7b71624"
2641
2641
  },
2642
2642
  {
2643
2643
  "path": "docs/modules-contract.md",
@@ -6112,7 +6112,7 @@
6112
6112
  {
6113
6113
  "path": "modules/provisioning/provisioning.js",
6114
6114
  "mode": "0000644",
6115
- "sha256": "d3d376a0aafc52dda55d9d2f2dcdc3d5baf9370a766cd4328cd00b314e897c99"
6115
+ "sha256": "b073e5c362797e221f5e0f9ad322ee8e1c3206ea44ed98c527b3c1c7b696879c"
6116
6116
  },
6117
6117
  {
6118
6118
  "path": "modules/provisioning/rate-limit.js",
@@ -6132,7 +6132,7 @@
6132
6132
  {
6133
6133
  "path": "modules/provisioning/routes/provisioning.js",
6134
6134
  "mode": "0000644",
6135
- "sha256": "3528d699cb9c0f396eeda2e9fc26eb9b89c02e0b0ca34beeb20fd84c832f5241"
6135
+ "sha256": "f9231eb18211a2586d2ee1919ff817ac5fa14f1ea5ad1906ad5c58437f6238f6"
6136
6136
  },
6137
6137
  {
6138
6138
  "path": "modules/provisioning/starter-bundles.js",
@@ -6147,7 +6147,7 @@
6147
6147
  {
6148
6148
  "path": "modules/provisioning/tests/provisioning.mjs",
6149
6149
  "mode": "0000644",
6150
- "sha256": "4dceb82f4dbeb39128c66d666df0642aacb38988f7c6d01c55f6515bcd8ad798"
6150
+ "sha256": "aac930ed566f9b6fb7ec9fa4e240f42025e854369a94f6a8c63aa20b5d1995a8"
6151
6151
  },
6152
6152
  {
6153
6153
  "path": "modules/provisioning/ui/.gitkeep",
@@ -7392,12 +7392,12 @@
7392
7392
  {
7393
7393
  "path": "package-lock.json",
7394
7394
  "mode": "0000644",
7395
- "sha256": "76834cd1d1173b36db4f414f60b9457e8eeb1682ca54f25f662e29254ea22bd9"
7395
+ "sha256": "b52e9fc284a5e725c64604313074cc5b72653d3158a38ed20556a7a79d6649e8"
7396
7396
  },
7397
7397
  {
7398
7398
  "path": "package.json",
7399
7399
  "mode": "0000644",
7400
- "sha256": "c1ecc7233b18bd5b058b6af3757d7e2ae3a64fcef67ef6de2d743a72013c8602"
7400
+ "sha256": "a035e8e7a14e9ebce98dada6fd2bad96f3f9f0028707412394714c9edd538b77"
7401
7401
  },
7402
7402
  {
7403
7403
  "path": "public-docs/index.html",
@@ -9082,7 +9082,7 @@
9082
9082
  {
9083
9083
  "path": "src/module-api.js",
9084
9084
  "mode": "0000644",
9085
- "sha256": "aff15cb73c3ed3851d94ded69d4a75f9cf1e38dbe28146e51d6cd25c320f630e"
9085
+ "sha256": "bbd37b696a4b36e1e5a572719a92e7a187b8653fcf360a658de83c43ef4e8331"
9086
9086
  },
9087
9087
  {
9088
9088
  "path": "src/module-loader/catalog.js",
@@ -11407,7 +11407,7 @@
11407
11407
  {
11408
11408
  "path": "tests/provisioning_module_selection.mjs",
11409
11409
  "mode": "0000644",
11410
- "sha256": "a8cb68c1f21a7c6547ef382b4641b80c1c9e46a2aad415f75dbfde36224079fd"
11410
+ "sha256": "92db65b8da5b849379bab03e70287239deab8b9a2b2e47b4574f12b8acac013e"
11411
11411
  },
11412
11412
  {
11413
11413
  "path": "tests/provisioning_onboard_mode.mjs",
@@ -11555,7 +11555,7 @@
11555
11555
  "provisioning"
11556
11556
  ],
11557
11557
  "summary": "PATCH /provisioning/instances/:id/detail",
11558
- "description": "── The optional project detail (task 1002334, goal 1000046) ────────────── PATCH /provisioning/instances/:id/detail — answer (or re-answer) the wizard's OPTIONAL creation steps after creation. Body: any subset of { description, detail, modules }. rank: any authenticated builder (own resource; archon may change any — same gate as settings). THIS ROUTE IS WHAT MAKES THE STEP SKIPPABLE. Skipping at creation leaves the project dark-matter — off the map and un-joinable (ADR 0182 D4) — and that is a supported default only because there is a way back. Without this route \"optional\" would mean \"permanently unpublishable\", i.e. a dead end, which is precisely what criterion `proj-detail-interview` refuses. Deliberately NOT a settings-apply: nothing here is a policy the running instance obeys, so there is no web.env to patch and no restart to queue. The answers live on the platform row until a catalog row materializes (the provisioning_007 `type` precedent), and R16 (task 1002335) is the surface that reads `description` to compute publishability and name what is still missing. `modules` rides the SAME route for the same reason (BV1.R23, task 1002342, ADR 0245): step 4 of the wizard is skippable exactly as step 3 is, so it needs the same way back, and it lands on the same platform row under the same ownership gate. Only an ARRAY is a change — an omitted or null key leaves the column alone, so the \"never asked\" NULL (ADR 0240 §2) can never be written by a later edit. The always-on core stays unremovable by construction: badModuleSelection refuses its keys and `effectiveModules` re-adds it on read.\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
11558
+ "description": "── The optional project detail (task 1002334, goal 1000046) ────────────── PATCH /provisioning/instances/:id/detail — answer (or re-answer) the wizard's OPTIONAL creation steps after creation. Body: any subset of { description, detail, modules }. rank: any authenticated builder (own resource; archon may change any — same gate as settings). THIS ROUTE IS WHAT MAKES THE STEP SKIPPABLE. Skipping at creation leaves the project dark-matter — off the map and un-joinable (ADR 0182 D4) — and that is a supported default only because there is a way back. Without this route \"optional\" would mean \"permanently unpublishable\", i.e. a dead end, which is precisely what criterion `proj-detail-interview` refuses. Deliberately NOT a settings-apply: nothing here is a policy the running instance obeys, so there is no web.env to patch and no restart to queue. The answers live on the platform row until a catalog row materializes (the provisioning_007 `type` precedent), and R16 (task 1002335) is the surface that reads `description` to compute publishability and name what is still missing. `modules` rides the SAME route for the same reason (BV1.R23, task 1002342, ADR 0245): step 4 of the wizard is skippable exactly as step 3 is, so it needs the same way back, and it lands on the same platform row under the same ownership gate. Only an ARRAY is a change — an omitted or null key leaves the column alone, so the \"never asked\" NULL (ADR 0240 §2) can never be written by a later edit. The always-on core stays unremovable by construction: badModuleSelection refuses its keys and `effectiveModules` re-adds it on read. AN ANSWER CAN BE TAKEN BACK (task 1003577). `detail` merges, so before this the first `team_shape` an owner sent was permanent — and \"never answered\" is a state `effectiveModules` reads (ADR 0240), not merely the absence of one. Sending `detail: { <key>: null }` UN-ANSWERS that question: the key is dropped from the jsonb, so the row reads back exactly as one that was never asked and the bundle the answer moved moves back. Only null does it — an empty string or any other value is still refused as off-vocabulary, and `detail: {}` still changes nothing, because a form that collected no answers is not asking for any to be erased.\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
11559
11559
  "x-rank": "any-builder",
11560
11560
  "x-source": "modules/provisioning/routes/provisioning.js",
11561
11561
  "parameters": [
@@ -1521,5 +1521,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
1521
1521
  landed since 1.19.534 with no explicit bump. run 33886686350. (task 1002620)
1522
1522
  1.19.536 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1523
1523
  landed since 1.19.535 with no explicit bump. run 33888048777. (task 1002620)
1524
+ 1.19.537 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1525
+ landed since 1.19.536 with no explicit bump. run 33888812605. (task 1002620)
1524
1526
  ---------------------------------------------------------------------------
1525
1527
  ```
@@ -492,6 +492,26 @@ function normalizeProjectDetail(input) {
492
492
  return out;
493
493
  }
494
494
 
495
+ // A detail PATCH split into what it SETS and what it CLEARS (task 1003577). An
496
+ // explicit `null` for a known question means UN-ANSWER it — the JSON-merge-patch
497
+ // convention (RFC 7396) this repo already uses for `description` on the same route
498
+ // and for every builder-settings knob. It exists because a merging write can add an
499
+ // answer but never take one back, which made "never answered" — the third value
500
+ // ADR 0240 and `effectiveModules` both read — a state a project could leave once and
501
+ // never return to. Everything else is unchanged: an off-vocabulary value is still
502
+ // dropped rather than treated as a clear, and an empty patch still changes nothing.
503
+ // PURE.
504
+ function projectDetailPatch(input) {
505
+ const given = input && typeof input === 'object' && !Array.isArray(input) ? input : {};
506
+ const set = {};
507
+ const clear = [];
508
+ for (const [key, vocab] of Object.entries(PROJECT_DETAIL_VOCAB)) {
509
+ if (given[key] === null) clear.push(key);
510
+ else if (vocab.includes(given[key])) set[key] = given[key];
511
+ }
512
+ return { set, clear };
513
+ }
514
+
495
515
  // What a row's detail answers READ as — the stored object filtered through the
496
516
  // same vocabulary, so a value that was valid when written and has since left the
497
517
  // vocabulary never leaks out of the read surface. Mirrors effectiveSettings, minus
@@ -661,6 +681,13 @@ async function updateInstanceSettings(db, id, patch) {
661
681
  // is the honest consequence of deleting half the identity gate, not a bug.
662
682
  // Undefined leaves the column alone.
663
683
  //
684
+ // A merge alone could only ever ADD an answer, so `detail` also takes a PER-KEY null
685
+ // as "un-answer this question" (task 1003577, RFC 7396's rule and `description`'s own
686
+ // beside it): the key is DROPPED from the jsonb rather than stored as null, so the row
687
+ // reads back exactly as one that was never asked — which is the state ADR 0240 and
688
+ // `effectiveModules` treat as a distinct third value. `detail: {}` still changes
689
+ // nothing, since a form that sends no answers is not asking for any to be erased.
690
+ //
664
691
  // `modules` is the SAME way back for the wizard's other optional step (BV1.R23,
665
692
  // task 1002342, ADR 0245). It REPLACES like `description` and unlike `detail`: a
666
693
  // module selection is one whole list, so merging would make removing a module
@@ -675,8 +702,11 @@ async function updateInstanceDetail(db, id, { description, detail, modules } = {
675
702
  sets.push(`description = $${params.length}`);
676
703
  }
677
704
  if (detail !== undefined) {
678
- params.push(JSON.stringify(normalizeProjectDetail(detail)));
679
- sets.push(`detail = detail || $${params.length}::jsonb`);
705
+ const { set, clear } = projectDetailPatch(detail);
706
+ params.push(JSON.stringify(set));
707
+ const merged = params.length;
708
+ params.push(clear);
709
+ sets.push(`detail = (detail || $${merged}::jsonb) - $${params.length}::text[]`);
680
710
  }
681
711
  if (modules !== undefined) {
682
712
  const picked = normalizeModuleSelection(modules);
@@ -1211,7 +1241,7 @@ module.exports = {
1211
1241
  // pure
1212
1242
  isValidSlug, costEstimateUsd, publicInstance, normalizeHostname,
1213
1243
  defaultDomainForSlug, isReservedHostname, effectiveSettings, planetChoice,
1214
- normalizeDescription, normalizeProjectDetail, projectDetail,
1244
+ normalizeDescription, normalizeProjectDetail, projectDetailPatch, projectDetail,
1215
1245
  // pure — GitHub App Manifest flow (task 2080)
1216
1246
  githubAppName, buildGithubAppManifest, manifestPostUrl, publicGithubApp,
1217
1247
  // data access
@@ -113,8 +113,13 @@ function badProjectDetail(detail) {
113
113
  if (!Object.prototype.hasOwnProperty.call(vocab, key)) {
114
114
  return { code: 'bad_detail', message: `Unknown detail answer "${key}" — the interview asks: ${Object.keys(vocab).join(', ')}.` };
115
115
  }
116
+ // An explicit null is the UN-ANSWER, not an off-vocabulary value (task 1003577):
117
+ // the merge-patch way back to "never answered". Only null — an empty string or any
118
+ // other sentinel stays a typo, so a caller trying to SET one is never silently
119
+ // read as having erased their answer.
120
+ if (value === null) continue;
116
121
  if (!vocab[key].includes(value)) {
117
- return { code: 'bad_detail', message: `"${key}" must be one of: ${vocab[key].join(', ')}.` };
122
+ return { code: 'bad_detail', message: `"${key}" must be one of: ${vocab[key].join(', ')} — or null to un-answer it.` };
118
123
  }
119
124
  }
120
125
  return null;
@@ -682,6 +687,15 @@ module.exports = function provisioningRoutes() {
682
687
  // column alone, so the "never asked" NULL (ADR 0240 §2) can never be written by
683
688
  // a later edit. The always-on core stays unremovable by construction:
684
689
  // badModuleSelection refuses its keys and `effectiveModules` re-adds it on read.
690
+ //
691
+ // AN ANSWER CAN BE TAKEN BACK (task 1003577). `detail` merges, so before this the
692
+ // first `team_shape` an owner sent was permanent — and "never answered" is a state
693
+ // `effectiveModules` reads (ADR 0240), not merely the absence of one. Sending
694
+ // `detail: { <key>: null }` UN-ANSWERS that question: the key is dropped from the
695
+ // jsonb, so the row reads back exactly as one that was never asked and the bundle
696
+ // the answer moved moves back. Only null does it — an empty string or any other
697
+ // value is still refused as off-vocabulary, and `detail: {}` still changes nothing,
698
+ // because a form that collected no answers is not asking for any to be erased.
685
699
  router.patch('/provisioning/instances/:id/detail', auth.requireBuilder, async (req, res) => {
686
700
  const id = parseId(req, res); // (req, res) — parseId sends 400 bad_id + returns null on failure
687
701
  if (!id) return;
@@ -323,12 +323,32 @@ t('normalizeProjectDetail: on-vocabulary keys only — an unknown key or value i
323
323
  assert.deepEqual(P.normalizeProjectDetail({ team_shape: 'community' }), { team_shape: 'community' });
324
324
  assert.deepEqual(P.normalizeProjectDetail({ team_shape: 'enormous' }), {}, 'an off-vocabulary answer is not stored');
325
325
  assert.deepEqual(P.normalizeProjectDetail({ favourite_colour: 'blue' }), {}, 'an unknown question is not stored');
326
+ assert.deepEqual(P.normalizeProjectDetail({ team_shape: null }), {},
327
+ 'the un-answer (task 1003577) is a PATCH verb — at creation there is nothing to clear, so it just stores nothing');
326
328
  // skipping is the supported default, so every not-an-object shape reads as {}
327
329
  for (const skipped of [undefined, null, 'solo', ['solo'], 7]) {
328
330
  assert.deepEqual(P.normalizeProjectDetail(skipped), {}, 'a skipped interview stores {}');
329
331
  }
330
332
  });
331
333
 
334
+ t('projectDetailPatch: an explicit null CLEARS the answer; an off-vocabulary value is still dropped (task 1003577)', () => {
335
+ // The way back to "never answered" — the third value effectiveModules reads
336
+ // (ADR 0240) and that a merging write could never reach again once written.
337
+ assert.deepEqual(P.projectDetailPatch({ team_shape: 'community' }), { set: { team_shape: 'community' }, clear: [] });
338
+ assert.deepEqual(P.projectDetailPatch({ team_shape: null }), { set: {}, clear: ['team_shape'] },
339
+ 'null is the un-answer, not an off-vocabulary value');
340
+ assert.deepEqual(P.projectDetailPatch({ team_shape: 'enormous' }), { set: {}, clear: [] },
341
+ 'an off-vocabulary answer neither stores nor clears — normalizeProjectDetail\'s rule is unchanged');
342
+ assert.deepEqual(P.projectDetailPatch({ team_shape: '' }), { set: {}, clear: [] },
343
+ 'only null clears; an empty string is just another value the vocabulary refuses');
344
+ assert.deepEqual(P.projectDetailPatch({ favourite_colour: null }), { set: {}, clear: [] },
345
+ 'an unknown question has no answer to clear');
346
+ // `{}` stays a no-op, which is RFC 7396's own rule and what a skipped step sends
347
+ for (const nothing of [{}, undefined, null, 'solo', ['solo'], 7]) {
348
+ assert.deepEqual(P.projectDetailPatch(nothing), { set: {}, clear: [] }, 'an empty patch changes nothing');
349
+ }
350
+ });
351
+
332
352
  t('projectDetail: a stored value that has since LEFT the vocabulary never leaks out of the read surface', () => {
333
353
  assert.deepEqual(P.projectDetail({ detail: { team_shape: 'solo' } }), { team_shape: 'solo' });
334
354
  assert.deepEqual(P.projectDetail({ detail: { team_shape: 'guild' } }), {});
@@ -408,9 +428,16 @@ await ta('updateInstanceDetail: detail MERGES, description REPLACES, and neither
408
428
  const db = { query: async (q, p) => { sql = q; params = p; return { rows: [{ id: 18 }] }; } };
409
429
 
410
430
  await P.updateInstanceDetail(db, 18, { detail: { team_shape: 'small-team' } });
411
- assert.ok(sql.includes('detail = detail || $2::jsonb'), 'one answer does not erase the others');
431
+ assert.ok(sql.includes('detail = (detail || $2::jsonb) - $3::text[]'), 'one answer does not erase the others');
412
432
  assert.ok(!sql.includes('description ='), 'an unsent description is left alone, never nulled');
413
- assert.deepEqual(params, [18, JSON.stringify({ team_shape: 'small-team' })]);
433
+ assert.deepEqual(params, [18, JSON.stringify({ team_shape: 'small-team' }), []],
434
+ 'nothing to clear, so the key-drop list is empty');
435
+
436
+ // The way back (task 1003577): an explicit null UN-ANSWERS the question, which a
437
+ // merging write alone could never do — `{}` merges to nothing and an off-vocabulary
438
+ // sentinel is dropped, so before this the first answer was permanent.
439
+ await P.updateInstanceDetail(db, 18, { detail: { team_shape: null } });
440
+ assert.deepEqual(params, [18, '{}', ['team_shape']], 'the key is DROPPED from the jsonb, not stored as null');
414
441
 
415
442
  await P.updateInstanceDetail(db, 18, { description: 'Filled in later.' });
416
443
  assert.ok(sql.includes('description = $2'), 'the way back for a skipped step');
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.536",
3
+ "version": "1.19.537",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.536",
9
+ "version": "1.19.537",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.536",
3
+ "version": "1.19.537",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
package/src/module-api.js CHANGED
@@ -49,7 +49,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
49
49
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
50
50
  // the entry to that file. Look for a version's history there, not here.
51
51
  // ---------------------------------------------------------------------------
52
- const CORE_VERSION = '1.19.536'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
52
+ const CORE_VERSION = '1.19.537'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
53
53
 
54
54
  // A namespaced logger so a module's log lines are attributable + consistent.
55
55
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -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);