@bongos/core 1.19.732 → 1.19.734

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 (37) hide show
  1. package/.bongos-core.json +68 -38
  2. package/config/branding.neutral.json +3 -2
  3. package/docs/adr/0161-publish-on-merge.md +3 -0
  4. package/docs/adr/0282-a-module-that-spends-for-its-callers-declares-a-payer-and-the-core-refuses-it-without-one.md +141 -0
  5. package/docs/adr/README.md +1 -0
  6. package/docs/module-api-changelog.md +4 -0
  7. package/docs/modules-contract.md +22 -0
  8. package/modules/agents/lib/spend-ceiling.js +170 -0
  9. package/modules/agents/migrations/agents_002_payer.sql +87 -0
  10. package/modules/agents/module.json +2 -1
  11. package/modules/agents/routes/agents.js +62 -1
  12. package/modules/agents/spawn.js +21 -3
  13. package/modules/grading/dead-feature-claim.js +121 -0
  14. package/modules/grading/grader-score.js +32 -2
  15. package/modules/grading/grader-workers/worker-verdict.js +12 -2
  16. package/modules/provisioning/provisioning.js +17 -1
  17. package/modules/provisioning/tests/provisioning.mjs +8 -8
  18. package/modules/ui-design/kit/serve.js +1 -0
  19. package/package-lock.json +2 -2
  20. package/package.json +1 -1
  21. package/scripts/gds/bump-version.js +14 -5
  22. package/scripts/gds/exec-path-guard.js +3 -3
  23. package/src/branding.js +13 -0
  24. package/src/module-api.js +1 -1
  25. package/src/module-loader/loader.js +6 -0
  26. package/src/module-loader/manifest-schema.js +30 -0
  27. package/src/modules.js +107 -2
  28. package/tests/agents_spend_guard.mjs +272 -0
  29. package/tests/agents_write_routes.mjs +51 -1
  30. package/tests/bump_version.mjs +78 -1
  31. package/tests/currency_label.mjs +9 -4
  32. package/tests/dead_feature_claim.mjs +176 -0
  33. package/tests/grade_server_authoritative.mjs +57 -0
  34. package/tests/provision_settings_apply.mjs +11 -5
  35. package/tests/provisioning_settings.mjs +11 -7
  36. package/tests/provisioning_settings_apply.mjs +4 -4
  37. package/tests/provisioning_settings_env.mjs +5 -1
@@ -478,6 +478,63 @@ await test('applyGrade on a server-downgraded (passed=false) grade awards no cre
478
478
  assert.ok(!queries.some((q) => /INSERT INTO credit_log/.test(q.sql)), 'no credit_log insert');
479
479
  });
480
480
 
481
+ // ========================================================================
482
+ // Guard 2b — a finding whose own words say the change does not run (task 1002665).
483
+ //
484
+ // The 1002648 regression, reconstructed: the Quality worker wrote "the feature is
485
+ // entirely non-operational", filed it at `nit`, the rubric scores were high, and
486
+ // the grade PASSed straight to a merge. Note WHY a severity floor alone could not
487
+ // have stopped it — `passed` is computed from the scores, so even at `major` the
488
+ // grade still passes. This guard is the vote severity never had.
489
+ // ========================================================================
490
+
491
+ await test('a nit-severity finding that says the change does not run downgrades a PASS', () => {
492
+ const g = grader.serverSideGuards(
493
+ clientGrade({ issues: [{ severity: 'nit', file: 'src/bongos/db.js', summary: 'TASK_SETTER_COLUMNS lacks needs_migration, so the feature is entirely non-operational' }] }),
494
+ { committedFiles: ['src/bongos/db.js'], builderRank: 'metic', diffStats: { files_changed: 2, lines_added: 40, lines_removed: 3 } }
495
+ );
496
+ assert.equal(g.passed, false, 'the 1002648 grade must no longer pass');
497
+ assert.equal(g.signals.server_guard.downgraded_to, 'concerns',
498
+ 'a prose heuristic downgrades to manual-confirm, not a hard FAIL');
499
+ assert.ok(g.signals.server_guard.tripped.some((t) => t.guard === 'runtime_dead_finding'),
500
+ 'the guard names itself in the audit trail');
501
+ assert.ok(g.issues.some((i) => i.worker === 'server-runtime-dead'),
502
+ 'the finding is surfaced so the builder sees why');
503
+ });
504
+
505
+ await test('high rubric scores do NOT rescue it — that is the hole severity alone left open', () => {
506
+ const g = grader.serverSideGuards(
507
+ clientGrade({ ff: 10, cq: 10, issues: [{ severity: 'nit', file: 'x.js', summary: 'the new handler always throws' }] }),
508
+ { committedFiles: ['x.js'], builderRank: 'metic', diffStats: { files_changed: 1, lines_added: 20, lines_removed: 0 } }
509
+ );
510
+ assert.equal(g.passed, false, 'a 10/10 grade over a self-reported dead feature must still stop');
511
+ });
512
+
513
+ await test('an ordinary conditional finding is untouched — no false block', () => {
514
+ const g = grader.serverSideGuards(
515
+ clientGrade({ issues: [{ severity: 'nit', file: 'x.js', summary: 'this would throw if the id were null' }] }),
516
+ { committedFiles: ['x.js'], builderRank: 'metic', diffStats: { files_changed: 1, lines_added: 20, lines_removed: 0 } }
517
+ );
518
+ assert.equal(g.passed, true, 'a conditional defect is ordinary review prose, not a dead-feature claim');
519
+ assert.equal(g.signals.server_guard.ok, true);
520
+ });
521
+
522
+ await test('praise for deleted dead code is untouched — no false block', () => {
523
+ const g = grader.serverSideGuards(
524
+ clientGrade({ issues: [{ severity: 'nit', file: 'x.js', summary: 'the old fallback used to always throw; this removes it' }] }),
525
+ { committedFiles: ['x.js'], builderRank: 'metic', diffStats: { files_changed: 1, lines_added: 5, lines_removed: 30 } }
526
+ );
527
+ assert.equal(g.passed, true, 'describing what the diff CURED must not be read as a dead feature');
528
+ });
529
+
530
+ await test('a grade that was ALREADY failing is not resurrected by the guard', () => {
531
+ const g = grader.serverSideGuards(
532
+ clientGrade({ ff: 2, cq: 2, issues: [{ severity: 'nit', file: 'x.js', summary: 'always throws' }] }),
533
+ { committedFiles: ['x.js'], builderRank: 'metic', diffStats: { files_changed: 1, lines_added: 20, lines_removed: 0 } }
534
+ );
535
+ assert.equal(g.passed, false);
536
+ });
537
+
481
538
  // ------------------------------------------------------------------------
482
539
  process.on('exit', () => {
483
540
  console.log(`\ngrade_server_authoritative: ${passed} passed, ${failed} failed`);
@@ -62,6 +62,7 @@ ${NEUTRAL_PREFIX}_JOINABILITY=apply
62
62
  ${NEUTRAL_PREFIX}_VISIBILITY=public
63
63
  ${NEUTRAL_PREFIX}_JOIN_GRANT=full
64
64
  ${NEUTRAL_PREFIX}_ARTIST_GATE=strict
65
+ ${NEUTRAL_PREFIX}_SPEND_PAYER=none
65
66
  ${NEUTRAL_PREFIX}_ARTIST_GATE_SINCE=
66
67
  `;
67
68
 
@@ -120,7 +121,12 @@ t('settingsEnvVarsFor: the runner pushes the SAME pair webEnvBody writes at stan
120
121
  [`${NEUTRAL_PREFIX}_VISIBILITY`]: 'public', [`${NEUTRAL_PREFIX}_JOIN_GRANT`]: 'full',
121
122
  // the artist gate + its non-retroactivity stamp (task 1003575, ADR 0241) ride
122
123
  // the same transport; the stamp is empty until the gate is actually RAISED
123
- [`${NEUTRAL_PREFIX}_ARTIST_GATE`]: 'strict', [`${NEUTRAL_PREFIX}_ARTIST_GATE_SINCE`]: '',
124
+ [`${NEUTRAL_PREFIX}_ARTIST_GATE`]: 'strict',
125
+ // who pays for metered module spend (task 1003884, ADR 0282) rides it too —
126
+ // it has to, because src/modules.js reads it at CONFIG LOAD, so the instance
127
+ // must already hold it in web.env before its first require
128
+ [`${NEUTRAL_PREFIX}_SPEND_PAYER`]: 'none',
129
+ [`${NEUTRAL_PREFIX}_ARTIST_GATE_SINCE`]: '',
124
130
  });
125
131
  const body = P.webEnvBody(inst, {});
126
132
  for (const [k, v] of Object.entries(vars)) assert.match(body, new RegExp(`^${k}=${v}$`, 'm'), `${k} at standup`);
@@ -132,10 +138,10 @@ const GATED = { settings: { platform_visibility: 'gated', joinability: 'invite_o
132
138
  const manifestWith = (project) => JSON.stringify({ schema: 'instance-manifest/v1', brand: project === undefined ? {} : { project } });
133
139
 
134
140
  t('true when every pushed key matches brand.project (camelCase, the ENV_OVERRIDES targets)', () => {
135
- assert.equal(P.settingsConsumed(manifestWith({ platformVisibility: 'gated', joinability: 'invite_only', visibility: 'public', joinGrant: 'full', artistGate: 'strict' }), GATED), true);
141
+ assert.equal(P.settingsConsumed(manifestWith({ platformVisibility: 'gated', joinability: 'invite_only', visibility: 'public', joinGrant: 'full', artistGate: 'strict', spendPayer: 'none' }), GATED), true);
136
142
  });
137
143
  t('false when the instance still reports a different value', () => {
138
- assert.equal(P.settingsConsumed(manifestWith({ platformVisibility: 'public', joinability: 'invite_only', visibility: 'public', joinGrant: 'full', artistGate: 'strict' }), GATED), false);
144
+ assert.equal(P.settingsConsumed(manifestWith({ platformVisibility: 'public', joinability: 'invite_only', visibility: 'public', joinGrant: 'full', artistGate: 'strict', spendPayer: 'none' }), GATED), false);
139
145
  assert.equal(P.settingsConsumed(manifestWith({ joinability: 'invite_only' }), GATED), false, 'a missing key is a mismatch, not a pass');
140
146
  });
141
147
  t('null (unknown) when the manifest carries no project block — a core that predates task 1003139 — or does not parse', () => {
@@ -153,7 +159,7 @@ t('instanceManifestCmd asks the instance on loopback, like healthzCmd', () => {
153
159
  function recorder({ apply = false, envBody = LIVE_ENV, readFails = false, healthzFails = false, privileged = false,
154
160
  manifest = 'match', restartThrows = false, writeThrows = false } = {}) {
155
161
  const cmds = [], writes = [], events = [], probes = [], cleared = [], logs = [];
156
- const manifestOut = manifest === 'match' ? manifestWith({ platformVisibility: 'gated', joinability: 'invite_only', visibility: 'public', joinGrant: 'full', artistGate: 'strict' })
162
+ const manifestOut = manifest === 'match' ? manifestWith({ platformVisibility: 'gated', joinability: 'invite_only', visibility: 'public', joinGrant: 'full', artistGate: 'strict', spendPayer: 'none' })
157
163
  : manifest === 'mismatch' ? manifestWith({ platformVisibility: 'public', joinability: 'apply', visibility: 'public' })
158
164
  : manifest === 'none' ? manifestWith(undefined) : 'nope';
159
165
  const exec = (cmd, opts = {}) => {
@@ -222,7 +228,7 @@ await ta('dry-run PLANS the patch: reads nothing real, writes NOTHING (no synthe
222
228
  assert.equal(r.ok, true);
223
229
  assert.equal(r.healthy, null, 'nothing ran, nothing observed');
224
230
  assert.equal(writes.length, 0, 'a dry-run must not plan a two-line overwrite of web.env');
225
- assert.ok(logs.some((l) => /would patch .*_PLATFORM_VISIBILITY, .*_JOINABILITY, .*_VISIBILITY, .*_JOIN_GRANT, .*_ARTIST_GATE, .*_ARTIST_GATE_SINCE in \/etc\/spike\/web\.env in place/.test(l)), logs.join('\n'));
231
+ assert.ok(logs.some((l) => /would patch .*_PLATFORM_VISIBILITY, .*_JOINABILITY, .*_VISIBILITY, .*_JOIN_GRANT, .*_ARTIST_GATE, .*_SPEND_PAYER, .*_ARTIST_GATE_SINCE in \/etc\/spike\/web\.env in place/.test(l)), logs.join('\n'));
226
232
  assert.ok(cmds.some((c) => /restart spike/.test(c)), 'the restart is still in the plan');
227
233
  assert.ok(logs.some((l) => /dry-run — nothing ran/.test(l)), 'the closing line does not claim a restart happened');
228
234
  });
@@ -164,7 +164,7 @@ await ta('owner reads DEFAULTS (public / apply) on a row with empty settings —
164
164
  reset();
165
165
  const r = await get('/provisioning/instances/18/settings');
166
166
  assert.equal(r.status, 200, r.raw);
167
- assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict' });
167
+ assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' });
168
168
  // the planet rides beside the settings (task 1003347): no default — null = the platform's own pick
169
169
  assert.deepEqual(r.body.planet, { planet_template: null, planet_accent: null, card_backdrop: null });
170
170
  assert.deepEqual(r.body.options, {
@@ -179,6 +179,10 @@ await ta('owner reads DEFAULTS (public / apply) on a row with empty settings —
179
179
  // offered to the form like the rest; what makes it different is only its
180
180
  // default, which is the owner's ruling rather than today's behaviour.
181
181
  artist_gate: ['off', 'advisory', 'strict'],
182
+ // who pays for metered module spend (task 1003884, ADR 0282) — the sixth
183
+ // policy axis, offered to the form like the rest. What makes it different is
184
+ // its CONSEQUENCE: it is the only setting that can refuse a module outright.
185
+ spend_payer: ['none', 'project', 'builder'],
182
186
  planet_template: [...provisioning.PLANET_TEMPLATES], planet_accent: ['sea', 'terracotta', 'gold-soft', 'sun-lit', 'accent'],
183
187
  // the card's backdrop (task 1003352) is the same kind of key: platform-drawn,
184
188
  // closed list, no default, no push — so it rides the same vocab
@@ -191,14 +195,14 @@ await ta('a stored planet reads back; a name that left the vocabulary reads null
191
195
  const r = await get('/provisioning/instances/18/settings');
192
196
  assert.equal(r.status, 200, r.raw);
193
197
  assert.deepEqual(r.body.planet, { planet_template: 'bead-obsidian-tint', planet_accent: null, card_backdrop: null });
194
- assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict' }, 'the planet is not a setting: the policy read is untouched');
198
+ assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' }, 'the planet is not a setting: the policy read is untouched');
195
199
  });
196
200
 
197
201
  await ta('stored keys overlay the defaults', async () => {
198
202
  reset({ inst: { owner_builder_id: 7, slug: 'spike', status: 'active', settings: { joinability: 'invite_only' } } });
199
203
  const r = await get('/provisioning/instances/18/settings');
200
204
  assert.equal(r.status, 200, r.raw);
201
- assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'invite_only', visibility: 'public', join_grant: 'full', artist_gate: 'strict' });
205
+ assert.deepEqual(r.body.settings, { platform_visibility: 'public', joinability: 'invite_only', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' });
202
206
  });
203
207
 
204
208
  await ta('ANOTHER builder\'s GET → 404 (no existence leak)', async () => {
@@ -226,7 +230,7 @@ await ta('owner changes one key — merged, returned effective, audited api:self
226
230
  reset();
227
231
  const r = await patch('/provisioning/instances/18/settings', { joinability: 'open' });
228
232
  assert.equal(r.status, 200, r.raw);
229
- assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'open', visibility: 'public', join_grant: 'full', artist_gate: 'strict' }, 'the SAVED value — the response never claims the instance runs it (task 1003140)');
233
+ assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'open', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' }, 'the SAVED value — the response never claims the instance runs it (task 1003140)');
230
234
  assert.equal(r.body.push.queued, true, 'an active co-tenant gets the live push (task 1003140)');
231
235
  assert.deepEqual(updateCalls, [{ id: 18, patch: { joinability: 'open' } }], 'exactly the sent key, nothing else');
232
236
  assert.equal(events.length, 1);
@@ -239,7 +243,7 @@ await ta('owner changes both keys in one call', async () => {
239
243
  reset();
240
244
  const r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated', joinability: 'invite_only' });
241
245
  assert.equal(r.status, 200, r.raw);
242
- assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'invite_only', visibility: 'public', join_grant: 'full', artist_gate: 'strict' });
246
+ assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'invite_only', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' });
243
247
  });
244
248
 
245
249
  await ta('the OWN-SCOPE WALL: another builder\'s PATCH → 404 and the write path is never reached', async () => {
@@ -278,7 +282,7 @@ await ta('the owner sets the door to stealth — stored, echoed effective, audit
278
282
  reset();
279
283
  const r = await patch('/provisioning/instances/18/settings', { visibility: 'stealth' });
280
284
  assert.equal(r.status, 200, r.raw);
281
- assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply', visibility: 'stealth', join_grant: 'full', artist_gate: 'strict' });
285
+ assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply', visibility: 'stealth', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' });
282
286
  assert.deepEqual(updateCalls, [{ id: 18, patch: { visibility: 'stealth' } }], 'exactly the sent key, nothing else');
283
287
  assert.equal(events[0].actor, 'api:self');
284
288
  assert.match(events[0].detail, /visibility=stealth/);
@@ -338,7 +342,7 @@ await ta('the owner picks a planet: saved, echoed as `planet`, audited — and N
338
342
  const r = await patch('/provisioning/instances/18/settings', { planet_template: 'bead-obsidian-tint', planet_accent: 'sea' });
339
343
  assert.equal(r.status, 200, r.raw);
340
344
  assert.deepEqual(r.body.planet, { planet_template: 'bead-obsidian-tint', planet_accent: 'sea', card_backdrop: null });
341
- assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict' }, 'the policy half is untouched');
345
+ assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' }, 'the policy half is untouched');
342
346
  assert.deepEqual(updateCalls, [{ id: 18, patch: { planet_template: 'bead-obsidian-tint', planet_accent: 'sea' } }]);
343
347
  assert.equal(r.body.push.queued, false);
344
348
  assert.equal(r.body.push.restart, false, 'an active co-tenant would get a push for a POLICY change — never for the planet');
@@ -225,7 +225,7 @@ await ta('ACTIVE standalone: the row is written FIRST, then a settings-apply int
225
225
  reset();
226
226
  const r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated' });
227
227
  assert.equal(r.status, 200, r.raw);
228
- assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict' }, 'the SAVED (requested) value — never `settings`, which a form would render as live');
228
+ assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'apply', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' }, 'the SAVED (requested) value — never `settings`, which a form would render as live');
229
229
  assert.equal(r.body.settings, undefined);
230
230
  assert.deepEqual(updateCalls.map((c) => c.patch), [{ platform_visibility: 'gated' }]);
231
231
  assert.equal(updateCalls[0].enqueuedSoFar, 0, 'the runner reads the row — the write must precede the intent');
@@ -274,7 +274,7 @@ await ta('an enqueue that THROWS still answers 200 "saved" — never a 500 that
274
274
  enqueueThrows = new Error('db down');
275
275
  const r = await patch('/provisioning/instances/18/settings', { joinability: 'open' });
276
276
  assert.equal(r.status, 200, r.raw);
277
- assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'open', visibility: 'public', join_grant: 'full', artist_gate: 'strict' });
277
+ assert.deepEqual(r.body.saved, { platform_visibility: 'public', joinability: 'open', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' });
278
278
  assert.equal(updateCalls.length, 1, 'the write landed');
279
279
  receiptShape(r.body.push, { queued: false, restart: true, reachable: true });
280
280
  assert.match(r.body.push.message, /Saved, but the restart could not be queued/);
@@ -287,7 +287,7 @@ await ta('same values as already stored → no write, no event, no intent, "No c
287
287
  reset({ inst: { ...ACTIVE, settings: { platform_visibility: 'gated', joinability: 'open' } } });
288
288
  const r = await patch('/provisioning/instances/18/settings', { platform_visibility: 'gated', joinability: 'open' });
289
289
  assert.equal(r.status, 200, r.raw);
290
- assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'open', visibility: 'public', join_grant: 'full', artist_gate: 'strict' });
290
+ assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'open', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' });
291
291
  assert.equal(updateCalls.length, 0, 'nothing written (no updated_at reshuffle)');
292
292
  assert.equal(events.length, 0, 'no "settings changed" event for a change that is not one');
293
293
  assert.equal(enqueueCalls.length, 0, 'a whole-form save must not bounce a live project');
@@ -404,7 +404,7 @@ await ta('save A queues → save B 409s while A RUNS → A settles → the UNCHA
404
404
  assert.equal(enqueueCalls.length, enqueuedBefore + 1, 'exactly one new intent');
405
405
  assert.equal(updateCalls.length, writesBefore, 'nothing re-written — the value was already stored in step 2');
406
406
  assert.deepEqual(owedCalls[owedCalls.length - 1], { id: 18, owed: false }, 'and the debt is settled');
407
- assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'open', visibility: 'public', join_grant: 'full', artist_gate: 'strict' },
407
+ assert.deepEqual(r.body.saved, { platform_visibility: 'gated', joinability: 'open', visibility: 'public', join_grant: 'full', artist_gate: 'strict', spend_payer: 'none' },
408
408
  'the value that finally ships is the one the owner saved');
409
409
  });
410
410
 
@@ -117,13 +117,17 @@ t('settingsEnvVars is the single source both legs derive from — same keys, sam
117
117
  assert.deepEqual(vars, {
118
118
  CLOUDBONGOS_PLATFORM_VISIBILITY: 'gated', CLOUDBONGOS_JOINABILITY: 'open', CLOUDBONGOS_VISIBILITY: 'stealth',
119
119
  CLOUDBONGOS_JOIN_GRANT: 'view', CLOUDBONGOS_ARTIST_GATE: 'advisory', CLOUDBONGOS_ARTIST_GATE_SINCE: '',
120
+ // Who pays for metered module spend (task 1003884) — it rides the same
121
+ // transport because src/modules.js reads it at CONFIG LOAD, which means the
122
+ // instance must already have it in web.env before the first require.
123
+ CLOUDBONGOS_SPEND_PAYER: 'none',
120
124
  });
121
125
  // varsOf splits on '=', so an empty value reads as '' on both sides — the point
122
126
  // of the comparison is that the standup lines carry exactly the same key set.
123
127
  assert.deepEqual(varsOf(provisioning.settingsEnvLines(row, 'CLOUDBONGOS')), vars, 'the standup lines ARE the vars');
124
128
  assert.deepEqual(provisioning.settingsEnvVars({}, 'X'), {
125
129
  X_PLATFORM_VISIBILITY: 'public', X_JOINABILITY: 'apply', X_VISIBILITY: 'public', X_JOIN_GRANT: 'full',
126
- X_ARTIST_GATE: 'strict', X_ARTIST_GATE_SINCE: '',
130
+ X_ARTIST_GATE: 'strict', X_ARTIST_GATE_SINCE: '', X_SPEND_PAYER: 'none',
127
131
  }, 'defaults, explicitly');
128
132
  });
129
133