@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,263 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/routes/project-settings.js — the five fundamentals of a project,
4
+ // in one read, each carrying WHERE its value came from (task 1003280, goal 1000072,
5
+ // criterion legibility-project-settings).
6
+ //
7
+ // WHY THIS ROUTE EXISTS. Goal 1000072's thesis is that this project is legible to
8
+ // Claude and illegible to a human: the system knows everything and exposes almost
9
+ // none of it in a shape a person can hold. The five values here — the rot timer, the
10
+ // category set, how credits are calculated, whether equity is real, and what share of
11
+ // upmarket revenue is paid on credits — were each answerable only by reading source.
12
+ // This route is the TRANSLATION LAYER, not a new capability: it invents no value and
13
+ // stores nothing of its own.
14
+ //
15
+ // THE ONE RULE THAT SHAPES EVERY ROW: a value is reported WITH ITS PROVENANCE. A
16
+ // number with no provenance is what let a 6h and a 24h staleness answer coexist
17
+ // unnoticed for months (ADR 0226), which is the exact failure this goal is named for.
18
+ // So every row says whether it is a live knob or committed config, and names the file
19
+ // (and ADR) it resolves from. A row the reader cannot type into must LOOK unwritable
20
+ // and say why.
21
+ //
22
+ // AND THE ONE THAT KEEPS IT HONEST: a fundamental with no knob behind it is reported
23
+ // as ABSENT, never as a plausible zero. `upmarket_percent` is that row today — the
24
+ // task asked for it, and no such knob exists anywhere in this codebase. Rendering it
25
+ // as "0%" would be a fabricated fact about the project's economics; saying "there is
26
+ // no such setting yet, and here is where it would live" is the legible answer.
27
+ //
28
+ // MODULE BOUNDARY: reaches core only through the published doorway
29
+ // `../../../src/module-api`; `../goal-category` is an intra-module sibling.
30
+
31
+ const express = require('express');
32
+ const api = require('../../../src/module-api');
33
+ const goalCategory = require('../goal-category');
34
+
35
+ const log = api.logger('lifecycle');
36
+
37
+ // --- the three rows that are READ, never written -----------------------------
38
+ //
39
+ // Each names the committed artefact it resolves from, because "you cannot edit this"
40
+ // is only half an answer; the other half is "and here is the file that decides it".
41
+
42
+ // The credit formula IN FORCE, asked of the economy module rather than restated here.
43
+ // ADR 0146 put the reward policy inside economy precisely so display surfaces ask
44
+ // instead of re-deriving it; a second copy of `round(cost × 1.20)` in a settings page
45
+ // is the drift this goal exists to end. When economy is off there is no formula to
46
+ // report, and saying so beats printing one that nothing applies.
47
+ function creditRow() {
48
+ const reward = api.resolveOptional('reward');
49
+ if (!reward || typeof reward.rewardConfig !== 'function') {
50
+ return {
51
+ key: 'credit_calculation',
52
+ title: 'Credit calculation',
53
+ editable: false,
54
+ state: 'unavailable',
55
+ sentence: 'The economy module is not enabled on this instance, so no credit formula is in force.',
56
+ source: 'module `economy` (absent)',
57
+ reads: { file: 'modules/economy/credits.js', adr: 'ADR 0054' },
58
+ };
59
+ }
60
+ let policy;
61
+ try {
62
+ policy = reward.rewardConfig();
63
+ } catch (err) {
64
+ log.error(`project-settings: rewardConfig() failed: ${err && err.message}`);
65
+ return {
66
+ key: 'credit_calculation',
67
+ title: 'Credit calculation',
68
+ editable: false,
69
+ state: 'unavailable',
70
+ sentence: 'The credit formula could not be read just now.',
71
+ source: 'module `economy`',
72
+ reads: { file: 'modules/economy/credits.js', adr: 'ADR 0054' },
73
+ };
74
+ }
75
+ const mult = (1 + Number(policy.marginPct) / 100).toFixed(2);
76
+ const peg = Number(policy.usdPeg);
77
+ const pegNote = peg !== 1 ? ` × ${peg} peg` : '';
78
+ // The per-task estimate is ADVERTISED but never paid when the mode is cost-plus-only
79
+ // (ADR 0146). A page that showed the estimate without that sentence would be telling
80
+ // a builder they will earn a number this instance has decided not to pay.
81
+ const estimate = policy.perTaskCredit
82
+ ? 'A task’s own credit estimate is also paid, scaled by its kind.'
83
+ : 'A task’s credit estimate is an advertised guide only — this instance pays cost-plus and nothing else.';
84
+ return {
85
+ key: 'credit_calculation',
86
+ title: 'Credit calculation',
87
+ editable: false,
88
+ state: 'live',
89
+ value: { marginPct: policy.marginPct, usdPeg: policy.usdPeg, mode: policy.mode, perTaskCredit: policy.perTaskCredit },
90
+ sentence: `A shipped session pays round(true cost in USD × ${mult}${pegNote}). ${estimate}`,
91
+ source: 'branding pack → `reward`',
92
+ reads: { file: 'config/branding.json → reward', adr: 'ADR 0054, ADR 0146' },
93
+ };
94
+ }
95
+
96
+ // Equity. THE OWNER DECIDED THIS (2026-08-25) and the decision is that it is NOT REAL:
97
+ // credits ARE the ownership stream today, and no legal equity is issued. The page says
98
+ // that plainly rather than implying a cap table exists — an owner reading their own
99
+ // settings page must not come away believing shares are being issued. If issuance ever
100
+ // becomes real it gets its own decision record; the Board Room spec already parks
101
+ // Howey / Subchapter T explicitly out of scope.
102
+ function equityRow() {
103
+ return {
104
+ key: 'equity_calculation',
105
+ title: 'Equity calculation',
106
+ editable: false,
107
+ state: 'not-yet-real',
108
+ sentence: 'No legal equity is issued. Credits are the ownership stream this project actually runs — '
109
+ + 'the owner’s framing in ADR 0054 is that a credit IS a unit of ownership. There is no cap table, '
110
+ + 'no share class and no issuance behind this row.',
111
+ source: 'owner decision, 2026-08-25',
112
+ reads: { file: 'docs/adr/0054-drachmae-token-reward.md', adr: 'ADR 0054' },
113
+ };
114
+ }
115
+
116
+ // The share of upmarket revenue paid out on credits. NO SUCH KNOB EXISTS — searched
117
+ // across code, config, migrations and docs on this task and found only prose in an
118
+ // unrelated competitive-analysis file. So this row reports ABSENCE and names where the
119
+ // setting would live if it became real, which is the actionable fact. A rendered "0%"
120
+ // would be a fabricated economic claim; an omitted row would leave the owner to
121
+ // rediscover the gap by grep.
122
+ function upmarketRow() {
123
+ return {
124
+ key: 'upmarket_percent',
125
+ title: 'Percent of upmarket revenue paid on credits',
126
+ editable: false,
127
+ state: 'absent',
128
+ sentence: 'This project has no such setting yet — nothing in the code, config or database '
129
+ + 'defines it, so there is no number to show. It would belong beside the other reward '
130
+ + 'terms in the branding pack’s `reward` block, and paying against it would need its own '
131
+ + 'decision record first.',
132
+ source: 'not configured anywhere',
133
+ reads: { file: 'config/branding.json → reward (where it would live)', adr: null },
134
+ };
135
+ }
136
+
137
+ module.exports = function buildProjectSettingsRouter() {
138
+ const router = express.Router();
139
+
140
+ // GET /project-settings — all five rows in ONE read.
141
+ //
142
+ // rank: project.settings.manage (metic+, government_010), the SAME floor as the page
143
+ // it serves (requireGovernmentPage). The first cut gated this any-builder on the
144
+ // reasoning that it is "the project's own published terms" — but the payload also
145
+ // carries reward-policy internals (marginPct, usdPeg, mode), a raw rejected
146
+ // project_settings value, and internal file/migration paths, and a xenos who cannot
147
+ // even load the page could still curl those. Matching the read floor to the page's
148
+ // (task 1003280 grade, hacker lens) closes that asymmetry; the two WRITES already
149
+ // gate on this same atom, so the whole surface is now one floor.
150
+ //
151
+ // One read, not five: the page draws five rows, so it asks for five rows. Fetching
152
+ // per-row would put the hall's own settings page in the shape goal 1000072's
153
+ // criterion names (hall-ui/CLAUDE.md, "a card asks the server for the rows it draws").
154
+ router.get('/project-settings', api.requireBuilder, api.requirePermission('project.settings.manage'), async (req, res) => {
155
+ try {
156
+ const timer = await api.resolveRotDays();
157
+ // Inactive included: the set's SHAPE is what this page edits, and a deactivated
158
+ // category the owner cannot see is one they cannot bring back.
159
+ const categories = await goalCategory.listCategories({ includeInactive: true });
160
+
161
+ // A settings read is a configuration comparison — a cached one is wrong the
162
+ // moment somebody turns a knob (the rot.js precedent).
163
+ res.set('Cache-Control', 'no-store');
164
+ res.json({
165
+ rows: [
166
+ {
167
+ key: 'rot_timer',
168
+ title: 'Stale timer',
169
+ editable: true,
170
+ state: 'live',
171
+ value: { days: timer.days, min: api.projectSettings.MIN_ROT_DAYS, max: api.projectSettings.MAX_ROT_DAYS },
172
+ sentence: `Work nobody has touched for ${timer.days} day${timer.days === 1 ? '' : 's'} `
173
+ + 'is raised as a needs-attention item. Raising it only ever ASKS a person; nothing is '
174
+ + 'released or reassigned automatically.',
175
+ source: timer.source,
176
+ describe: api.projectSettings.describeRotTimer(timer),
177
+ // Surfaced ONLY when a configured value could not be used. It belongs on the
178
+ // screen of the person who can fix it — the reporting-degrades half of ADR
179
+ // 0226's asymmetric fail posture.
180
+ invalid: timer.invalid,
181
+ reads: { file: 'project_settings[\'attention.rot_days\'] (migration core_231)', adr: 'ADR 0226' },
182
+ // THE ROW THAT PREVENTS THIS PAGE FROM LYING. There are two timers, and the
183
+ // OTHER one — the claim sweep — enforces rather than reports, is measured in
184
+ // hours, and is NOT editable here. Naming it inline is what stops a reader
185
+ // concluding they have just reconfigured the sweep that auto-releases claims.
186
+ sibling: {
187
+ title: 'The claim sweep is a different timer',
188
+ sentence: 'A CLAIM that goes silent is handled separately, in hours, and the sweep '
189
+ + 'RELEASES it without asking. That timer is committed configuration, not a knob on '
190
+ + 'this page.',
191
+ reads: { file: 'src/stale-timer.js', adr: 'ADR 0226' },
192
+ },
193
+ },
194
+ {
195
+ key: 'work_categories',
196
+ title: 'Category set',
197
+ editable: true,
198
+ state: 'live',
199
+ value: { categories, active: categories.filter((c) => c.active).length, total: categories.length },
200
+ sentence: 'The human categories a goal can carry. They orient a reader and steer Claude — '
201
+ + 'they gate nothing, route nothing and authorise nothing.',
202
+ source: 'work_categories table',
203
+ reads: { file: 'work_categories (migration core_226)', adr: 'ADR 0192' },
204
+ },
205
+ creditRow(),
206
+ equityRow(),
207
+ upmarketRow(),
208
+ ],
209
+ });
210
+ } catch (err) {
211
+ log.error(`GET /project-settings: ${err && err.message}`);
212
+ res.fail('project_settings_read_failed', { status: 500, message: 'internal error' });
213
+ }
214
+ });
215
+
216
+ // PATCH /project-settings/rot-days — body { days }. The one number this page writes.
217
+ //
218
+ // rank: project.settings.manage (metic+, government_010).
219
+ //
220
+ // Validated HERE against the resolver's own parser rather than a re-typed range, so
221
+ // the page and the reader of the value can never disagree about what is acceptable —
222
+ // the second copy of "1..365" would be the drift this file exists to prevent.
223
+ // rank: project.settings.manage (metic+, granted by government_010).
224
+ //
225
+ // THE ATOM IS A STRING LITERAL, not a named constant, and that is load-bearing: the
226
+ // route-rank auditor (src/bongos/route-rank-check.js) reads this file statically and
227
+ // pulls the atom out of `requirePermission(...)` with a QUOTED-STRING match. A
228
+ // `requirePermission(SETTINGS_ATOM)` therefore resolves to an EMPTY gate — rank
229
+ // 'unknown' — which is a hard ADR 0016 build failure, and the generated OpenAPI would
230
+ // have carried no `x-permissions` either. This route was written with a constant
231
+ // first and fitness.js caught it; that is why every gated route in the tree spells
232
+ // its atom inline. (Wrapping the middleware list across lines is fine — the sibling
233
+ // PATCH /work-categories/:id does exactly that and still classifies metic+archon.)
234
+ router.patch('/project-settings/rot-days', api.requireBuilder, api.requirePermission('project.settings.manage'), async (req, res) => {
235
+ // STRICT schema first (ADR 0118): it declares the only field this handler reads,
236
+ // so an unknown key is refused through the ADR 0116 envelope instead of being
237
+ // silently dropped. It settles the TYPE; parseDays below settles the RANGE, using
238
+ // the resolver's own parser so the form and the reader cannot disagree.
239
+ if (api.validateOrRespond(req, res, { days: { required: true, type: 'number' } })) return;
240
+ const parsed = api.projectSettings.parseDays(req.body.days);
241
+ if (parsed.days == null) {
242
+ // parsed.reason already carries the bound ("out of range (1..365)"), so this
243
+ // does not re-type it — the drift the page's own rule forbids (task 1003280 grade).
244
+ return res.fail('bad_rot_days', {
245
+ status: 400,
246
+ message: `days: ${parsed.reason}`,
247
+ });
248
+ }
249
+ try {
250
+ await api.projectSettings.set(api.projectSettings.KEYS.ROT_DAYS, parsed.days, req.builder.id);
251
+ // Re-RESOLVE rather than echoing what was written: the receipt must describe the
252
+ // value the rest of the system will now read, through the same precedence every
253
+ // other caller goes through.
254
+ const timer = await api.resolveRotDays();
255
+ res.json({ ok: true, rot_days: timer.days, source: timer.source, describe: api.projectSettings.describeRotTimer(timer) });
256
+ } catch (err) {
257
+ log.error(`PATCH /project-settings/rot-days: ${err && err.message}`);
258
+ res.fail('project_settings_write_failed', { status: 500, message: 'internal error' });
259
+ }
260
+ });
261
+
262
+ return router;
263
+ };
@@ -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.535",
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.535",
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.535",
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",
@@ -829,6 +829,17 @@ function mountInternalSurfaces(app) {
829
829
  // data never leaked (the route gate held), but the page was the one item in the
830
830
  // group without a wall. Gated here so the group is uniform (task 1002710).
831
831
  const SESSIONS_PAGE_RE = /^\/builders\/sessions(?:\.html)?\/?$/;
832
+ // /project-settings — the five fundamentals of the project (task 1003280, goal
833
+ // 1000072). Government tier, beside /watch and /sessions: it is a STEERING surface,
834
+ // and ADR 0157 put that floor at metic. Both writes behind it gate independently on
835
+ // `project.settings.manage` (defence in depth unchanged), so this gate decides only
836
+ // who receives the shell.
837
+ //
838
+ // NB the assignment must stay `const NAME_RE = /…/;` with nothing between the `=`
839
+ // and the literal — tests/hall_page_gate_map.mjs rebuilds the URL→gate map by
840
+ // parsing this file statically, and a comment in that gap drops the regex out of
841
+ // the map. Prose goes above the const, as here.
842
+ const PROJECT_SETTINGS_PAGE_RE = /^\/builders\/project-settings(?:\.html)?\/?$/;
832
843
  // The /government ranks-and-permissions tab (BV1.R113) sits one floor higher:
833
844
  // the `government.manage` atom itself (archon), the same key its data routes
834
845
  // check — requireGovernmentManagePage, not the metic-floored gate above.
@@ -861,7 +872,7 @@ function mountInternalSurfaces(app) {
861
872
  if (!req.path.startsWith('/builders')) return next();
862
873
  const norm = path.posix.normalize(req.path);
863
874
  if (WATCH_PAGE_RE.test(norm) || HARBOR_PAGE_RE.test(norm) || GATE_PAGE_RE.test(norm)
864
- || SESSIONS_PAGE_RE.test(norm)) {
875
+ || SESSIONS_PAGE_RE.test(norm) || PROJECT_SETTINGS_PAGE_RE.test(norm)) {
865
876
  return gdsAuth.requireGovernmentPage(req, res, next);
866
877
  }
867
878
  // R16 (goal 1000069) widened the government shell from the manage atom
package/src/module-api.js CHANGED
@@ -28,6 +28,9 @@ const {
28
28
  getSetting: getProjectSetting,
29
29
  setSetting: setProjectSetting,
30
30
  describeRotTimer,
31
+ parseDays: parseRotDays,
32
+ MIN_ROT_DAYS,
33
+ MAX_ROT_DAYS,
31
34
  } = require('./bongos/project-settings');
32
35
  const seams = require('./module-seams');
33
36
  const { buildInfo } = require('./build-info');
@@ -46,7 +49,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
46
49
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
47
50
  // the entry to that file. Look for a version's history there, not here.
48
51
  // ---------------------------------------------------------------------------
49
- const CORE_VERSION = '1.19.535'; // 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
50
53
 
51
54
  // A namespaced logger so a module's log lines are attributable + consistent.
52
55
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -288,6 +291,15 @@ module.exports = {
288
291
  get: getProjectSetting,
289
292
  set: setProjectSetting,
290
293
  describeRotTimer,
294
+ // The RANGE CHECK, shared rather than re-typed. The settings page validates a
295
+ // submitted timer with the very parser the resolver reads it back through, so the
296
+ // form and the reader can never disagree about what 1..365 means (task 1003280).
297
+ parseDays: parseRotDays,
298
+ // The bounds themselves, so a surface that must SHOW them (the settings page's rot
299
+ // row) reads the single source instead of re-typing 1/365 — the very drift the
300
+ // page's own anti-drift rule forbids, caught one layer up on task 1003280's grade.
301
+ MIN_ROT_DAYS,
302
+ MAX_ROT_DAYS,
291
303
  },
292
304
  readConfigFileSync: instanceConfig.readConfigFileSync,
293
305
  configHome: instanceConfig.configHome,
@@ -170,7 +170,9 @@ test('sidebar groups read by audience, in order', () => {
170
170
  assert.deepEqual(nav[3].items.map((i) => i.id),
171
171
  ['primer', 'ranks', 'economy', 'diagrams', 'atlas', 'modules', 'apidocs'], 'Docs = reference material');
172
172
  // 'government' (the Permissions tab, BV1.R113) is the group's one archon-gated item.
173
- assert.deepEqual(nav[4].items.map((i) => i.id), ['watch', 'harbor', 'gate', 'sessions', 'government']);
173
+ // 'project-settings' joins the Government group at the metic floor (task 1003280,
174
+ // goal 1000072) — it sits before 'government', which is the one archon-only item.
175
+ assert.deepEqual(nav[4].items.map((i) => i.id), ['watch', 'harbor', 'gate', 'sessions', 'project-settings', 'government']);
174
176
  });
175
177
 
176
178
  test('Economy lives in Docs and points at the reward-schedule page', () => {
@@ -68,6 +68,10 @@ const ORACLE = {
68
68
  '/builders/harbor': 'requireGovernmentPage',
69
69
  '/builders/gate': 'requireGovernmentPage',
70
70
  '/builders/sessions': 'requireGovernmentPage',
71
+ // The five fundamentals of the project (task 1003280, goal 1000072). Same tier as
72
+ // its neighbours: a steering surface, metic+ per ADR 0157. Its two WRITES gate
73
+ // independently on `project.settings.manage`, so this row is only about the shell.
74
+ '/builders/project-settings': 'requireGovernmentPage',
71
75
 
72
76
  // Metic+ via the `project.curate` atom — the exact permission GET /scouting
73
77
  // checks, so the page can never drift wider than the API it fronts. Its