@bongos/core 1.19.535 → 1.19.536

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,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
+ };
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.536",
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.536",
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.536",
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.536'; // 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