@bongos/core 1.24.22 → 1.24.24

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 (88) hide show
  1. package/.bongos-core.json +218 -63
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +42 -0
  4. package/clients/bongos-client/index.cjs +42 -0
  5. package/clients/bongos-client/index.d.ts +55 -1
  6. package/clients/bongos-client/index.mjs +42 -0
  7. package/docs/api/openapi.json +1181 -207
  8. package/docs/api-reference.md +30 -5
  9. package/docs/architecture.md +42 -0
  10. package/docs/copy-inventory.md +18 -8
  11. package/docs/copy-registry.json +99 -9
  12. package/docs/module-api-changelog.md +4 -0
  13. package/docs/page-readings.json +64 -25
  14. package/migrations/core_291_payout_nda_tax.sql +75 -0
  15. package/migrations/core_292_module_sale_payouts.sql +170 -0
  16. package/migrations/core_293_cash_outs.sql +184 -0
  17. package/migrations/core_294_cash_out_attempts_sent_by_time.sql +21 -0
  18. package/modules/economy/ledger-check.js +360 -0
  19. package/modules/economy/migrations/economy_003_user_balances.sql +116 -0
  20. package/modules/economy/migrations/economy_004_module_share_payouts.sql +22 -0
  21. package/modules/economy/migrations/economy_005_project_account_holds_index.sql +20 -0
  22. package/modules/economy/migrations/economy_006_ledger_checks.sql +68 -0
  23. package/modules/economy/module.json +1 -1
  24. package/modules/economy/outflow.js +209 -0
  25. package/modules/economy/pollers/ledger-check.js +87 -0
  26. package/modules/economy/project-account.js +92 -7
  27. package/modules/economy/routes/balances.js +79 -0
  28. package/modules/economy/routes/project-account.js +40 -2
  29. package/modules/economy/user-balances.js +308 -0
  30. package/modules/government/decision-rules.js +1 -0
  31. package/modules/hall-ui/public/project-settings.css +18 -0
  32. package/modules/hall-ui/public/project-settings.html +15 -2
  33. package/modules/hall-ui/public/project-settings.js +142 -0
  34. package/modules/ideas/module.json +2 -1
  35. package/modules/ideas/routes/blockers.js +26 -0
  36. package/modules/lifecycle/db-claims.js +22 -0
  37. package/modules/lifecycle/db-ship.js +5 -5
  38. package/modules/lifecycle/funding-wall.js +253 -0
  39. package/modules/lifecycle/module.json +2 -1
  40. package/modules/lifecycle/routes/claims.js +9 -0
  41. package/modules/lifecycle/routes/pay-type-gates.js +7 -0
  42. package/modules/lifecycle/routes/project-settings.js +59 -2
  43. package/modules/onboarding/founding-birth.js +5 -2
  44. package/modules/onboarding/founding-mode.js +10 -0
  45. package/modules/onboarding/genesis-home.js +3 -0
  46. package/modules/provisioning/birth.js +11 -2
  47. package/package-lock.json +2 -2
  48. package/package.json +1 -1
  49. package/release-notes.json +44 -0
  50. package/scripts/bongos/government-walk.js +1 -0
  51. package/scripts/bongos/plain-cards.js +18 -0
  52. package/scripts/bongos/run-unit-tests.js +28 -0
  53. package/src/bongos/builder-payouts.js +6 -1
  54. package/src/bongos/cash-outs.js +399 -0
  55. package/src/bongos/module-payouts.js +432 -0
  56. package/src/bongos/payout-approvals.js +5 -2
  57. package/src/bongos/payout-gates.js +44 -7
  58. package/src/bongos/project-settings.js +2 -0
  59. package/src/bongos/routes/module-payouts.js +114 -0
  60. package/src/bongos/routes/my-cash-outs.js +103 -0
  61. package/src/bongos/routes/payout-gates.js +42 -1
  62. package/src/bongos/routes.js +6 -0
  63. package/src/branding.js +3 -0
  64. package/src/module-api.js +1 -1
  65. package/tests/builder_payouts_db.mjs +1 -0
  66. package/tests/cash_outs.mjs +96 -0
  67. package/tests/cash_outs_db.mjs +205 -0
  68. package/tests/founding_birth.mjs +52 -12
  69. package/tests/founding_mode.mjs +5 -5
  70. package/tests/funded_project_pays_proof_db.mjs +181 -0
  71. package/tests/funding_wall.mjs +205 -0
  72. package/tests/funding_wall_db.mjs +174 -0
  73. package/tests/genesis_home.mjs +1 -1
  74. package/tests/government_act_alone_buildings.mjs +1 -0
  75. package/tests/ledger_check.mjs +189 -0
  76. package/tests/ledger_check_db.mjs +119 -0
  77. package/tests/module_payouts.mjs +295 -0
  78. package/tests/module_payouts_db.mjs +140 -0
  79. package/tests/own_scope_guard.mjs +5 -0
  80. package/tests/payout_gates.mjs +67 -6
  81. package/tests/payout_gates_db.mjs +23 -1
  82. package/tests/project_account_db.mjs +5 -2
  83. package/tests/project_outflow.mjs +103 -0
  84. package/tests/project_outflow_db.mjs +100 -0
  85. package/tests/provision_settings_apply.mjs +2 -1
  86. package/tests/provisioning_settings_env.mjs +7 -3
  87. package/tests/user_balances.mjs +241 -0
  88. package/tests/user_balances_db.mjs +137 -0
@@ -19,6 +19,7 @@ const { THETES_GRADUATION_THRESHOLD, maybeAutoGraduateToThetes, foldWorktreeName
19
19
  const { idKey, mostRecentClaimHolderId, sealSessionLog, staleClaimHourPredicate } = require('./db-shared.js');
20
20
  const { LIVE_STATUSES } = require('./dead-deps.js');
21
21
  const { isPageTweak } = require('./page-tweak-hold.js');
22
+ const { fundingWallOnResolve } = require('./funding-wall.js'); // task 1003185 / ADR 0386
22
23
  const pageTweakArtist = require('./page-tweak-artist.js');
23
24
 
24
25
  // shouldRefuseClaimResolve — may THIS caller resolve THIS claim? (task 1003767)
@@ -202,16 +203,15 @@ async function resolveClaim({ claimId, builderId, outcome, notesMd, valueSummary
202
203
  WHERE id = $1`,
203
204
  [claim.task_id, newTaskStatus, valueSummary ?? null]
204
205
  );
206
+ await fundingWallOnResolve(client, claim.task_id, outcome); // task 1003185 / ADR 0386
205
207
 
206
208
  // touches[] cleanup [7/8] (task 882): backfill touches[] from the REAL
207
209
  // committed diff at ship. The planning-time touches[] is now optional (a
208
210
  // prediction nobody has to get right up front); on a successful ship we
209
211
  // replace it with what git actually reports, so the declared set becomes
210
- // reality. This is the write path that makes the touches[]-keyed profile
211
- // counters (art_ships / pms_ships, see achievements + db leaderboard) and
212
- // the historical record diff-accurate — closing the loop left open in #878.
213
- // Only on a 'shipped' resolve with a non-empty list (never wipe touches[]
214
- // with an empty diff on a DB-only / no-op ship).
212
+ // reality. This is the write path that makes the touches[]-keyed profile counters (art_ships /
213
+ // pms_ships, see achievements + db leaderboard) and the record diff-accurate (loop left open in #878).
214
+ // Only on a 'shipped' resolve with a non-empty list (never wipe touches[] with an empty diff on a DB-only / no-op ship).
215
215
  if (outcome === 'shipped' && Array.isArray(committedFiles) && committedFiles.length > 0) {
216
216
  const cleaned = Array.from(
217
217
  new Set(committedFiles.map((p) => String(p || '').trim()).filter(Boolean))
@@ -0,0 +1,253 @@
1
+ // modules/lifecycle/funding-wall.js — AN UNDERFUNDED PROJECT CANNOT ACCRUE PAYABLE WORK
2
+ // (task 1003185, goal 1000092 M06; ADR 0386).
3
+ //
4
+ // WHY. A builder who finishes a task and only then learns the project cannot pay has
5
+ // carried the loss alone. ADR 0386 makes the rule the PROJECT's choice, with three
6
+ // options, and Bongos sets none of its own:
7
+ //
8
+ // hold_at_claim (the default) a claim is refused when the project's available
9
+ // balance cannot cover the task's cash pay; otherwise that pay is
10
+ // HELD against the balance while the task is worked, so several
11
+ // claims at once cannot over-commit one balance
12
+ // refuse_at_completion the task can be claimed and worked; declaring it done is
13
+ // refused while the balance is short
14
+ // carry_on nothing is refused; an unpaid amount is the project's own debt
15
+ //
16
+ // WHAT THE WALL READS. A task's price is its CASH pay terms (pay-terms.js; the task's own,
17
+ // else the project's standing terms), and the balance is the project account's record
18
+ // (the economy module's, through the `economy.account` port). A task with no cash term is
19
+ // never walled: credits, equity and the other pay types have no dollar balance here
20
+ // (ADR 0386: a non-cash wall reads the project's own terms for that pay type), and a
21
+ // self-managed term is money Bongos never sees. That is also why switching this on
22
+ // changes nothing for a project that pays no cash.
23
+ //
24
+ // EVERY REFUSAL STATES THE REASON AND THE SHORTFALL. "Insufficient balance" with no number
25
+ // is a dead end for the owner who has to act on it, so the refusal names the task's
26
+ // price, the balance, what is already held, and exactly how far short it is. A cash term
27
+ // whose amount is not a dollar figure is refused as unreadable, saying what it read: a
28
+ // guess at a number would be Bongos setting the pay.
29
+ //
30
+ // FAIL CLOSED. A cash-paying task under a rule that needs the account, with the account
31
+ // record switched off, is refused: the wall cannot be checked, and an unchecked wall is
32
+ // the loss this exists to prevent. A setting that cannot be read falls to the default.
33
+ //
34
+ // The check is the server's (ADR 0016). claimTask, claimTasksBatch and validateClaimBatch
35
+ // call fundingWallFailures (the taskNeedsFailures shape); resolveClaim calls
36
+ // fundingWallOnResolve. A hold is let go by STATE: the account counts it while its task is
37
+ // being worked or has shipped and is not yet paid, and the next claim or resolve writes the
38
+ // hold_release row (economy project-account.js), so no ship or pay path needs a hook.
39
+
40
+ 'use strict';
41
+
42
+ const api = require('../../src/module-api');
43
+ const { readPayTerms } = require('./pay-terms');
44
+
45
+ const log = api.logger('lifecycle');
46
+
47
+ const FUNDING_WALL_MODES = Object.freeze(['hold_at_claim', 'refuse_at_completion', 'carry_on']);
48
+ const DEFAULT_FUNDING_WALL = 'hold_at_claim';
49
+ const MODE_WORDS = Object.freeze({
50
+ hold_at_claim: 'Hold at claim: a task that pays cash can be claimed only when the project account can cover it, and its pay is set aside until the builder is paid or the task is let go.',
51
+ refuse_at_completion: 'Refuse at completion: a task can be claimed and worked, but it cannot be marked done while the project account cannot cover its cash pay.',
52
+ carry_on: 'Carry on: work is never stopped for money. Any unpaid amount is the project’s own debt; Bongos never pays it.',
53
+ });
54
+ const MICRO_PER_DOLLAR = 1000000;
55
+
56
+ // ── pure half ────────────────────────────────────────────────────────────────
57
+
58
+ function parseFundingWall(raw) {
59
+ if (raw == null || String(raw).trim() === '') return { reason: 'empty' };
60
+ const v = String(raw).trim().toLowerCase();
61
+ if (!FUNDING_WALL_MODES.includes(v)) return { reason: `must be one of ${FUNDING_WALL_MODES.join(' | ')}` };
62
+ return { mode: v };
63
+ }
64
+
65
+ // A cash amount as dollars, or null. Only a plain figure is read ("250", "$1,250.50",
66
+ // "USD 40"); a unit, when given, must say dollars. Anything else is not guessed at.
67
+ const DOLLAR_UNIT = /^(usd|us\$|\$|dollars?)$/i;
68
+ function dollarsMicro(amount, unit) {
69
+ if (unit != null && !DOLLAR_UNIT.test(String(unit).trim())) return null;
70
+ const s = String(amount == null ? '' : amount).trim().replace(/^(usd|us\$|\$)\s*/i, '').replace(/\s*(usd)$/i, '');
71
+ if (!/^\d{1,3}(,\d{3})*(\.\d{1,2})?$|^\d+(\.\d{1,2})?$/.test(s)) return null;
72
+ const [whole, cents = ''] = s.replace(/,/g, '').split('.');
73
+ const micro = Number(whole) * MICRO_PER_DOLLAR + Number((cents + '00').slice(0, 2)) * (MICRO_PER_DOLLAR / 100);
74
+ return Number.isSafeInteger(micro) ? micro : null;
75
+ }
76
+
77
+ // The task's cash price from its pay-terms view → { cash: false } | { cash: true, micro }
78
+ // | { cash: true, unreadable: '<what the project wrote>' }.
79
+ function cashPrice(view) {
80
+ const terms = ((view && view.terms) || []).filter((t) => t.pay_type === 'cash');
81
+ if (!terms.length) return { cash: false };
82
+ let micro = 0;
83
+ for (const t of terms) {
84
+ const m = dollarsMicro(t.amount, t.unit);
85
+ if (m == null) return { cash: true, unreadable: [t.amount, t.unit].filter(Boolean).join(' ') || 'no amount' };
86
+ micro += m;
87
+ }
88
+ if (!Number.isSafeInteger(micro)) return { cash: true, unreadable: 'a total too large to read' };
89
+ return { cash: true, micro };
90
+ }
91
+
92
+ function usd(micro) {
93
+ const cents = Math.round(Number(micro) / (MICRO_PER_DOLLAR / 100));
94
+ const sign = cents < 0 ? '-' : '';
95
+ const abs = Math.abs(cents);
96
+ return `${sign}$${Math.floor(abs / 100).toLocaleString('en-US')}.${String(abs % 100).padStart(2, '0')}`;
97
+ }
98
+
99
+ function shortfallRefusal(code, { taskId, priceMicro, account, mode }) {
100
+ const shortfall = priceMicro - account.available_micro;
101
+ const at = mode === 'refuse_at_completion' ? 'marked done' : 'claimed';
102
+ return {
103
+ code,
104
+ task_id: Number(taskId),
105
+ funding_rule: mode,
106
+ price_micro: priceMicro,
107
+ balance_micro: account.balance_micro,
108
+ held_micro: account.held_micro,
109
+ available_micro: account.available_micro,
110
+ shortfall_micro: shortfall,
111
+ message: `Task ${taskId} cannot be ${at} yet: the project account cannot cover its pay. `
112
+ + `The task pays ${usd(priceMicro)} in cash; the project has ${usd(account.available_micro)} available `
113
+ + `(${usd(account.balance_micro)} on record, ${usd(account.held_micro)} already held for work in progress). `
114
+ + `It is ${usd(shortfall)} short.`,
115
+ hint: 'The project owner can add money to the project account, or change the project’s funding rule on the settings page.',
116
+ };
117
+ }
118
+
119
+ function unreadableRefusal(taskId, wrote, mode) {
120
+ return {
121
+ code: 'FUNDING_PRICE_UNREADABLE',
122
+ task_id: Number(taskId),
123
+ funding_rule: mode,
124
+ message: `Task ${taskId} pays cash, stated as "${wrote}", which is not a dollar figure. `
125
+ + 'The project’s funding rule checks a task’s cash pay against the project account, so it cannot be checked.',
126
+ hint: 'Whoever can edit the task can state its cash pay as a dollar amount (for example 250 USD), or the project owner can change the funding rule.',
127
+ };
128
+ }
129
+
130
+ function noAccountRefusal(taskId, mode) {
131
+ return {
132
+ code: 'FUNDING_NO_ACCOUNT',
133
+ task_id: Number(taskId),
134
+ funding_rule: mode,
135
+ message: `Task ${taskId} pays cash, and the project account record is switched off on this instance, so the funding rule cannot be checked.`,
136
+ hint: 'The project owner can turn the economy module back on, or change the project’s funding rule.',
137
+ };
138
+ }
139
+
140
+ // ── read half ────────────────────────────────────────────────────────────────
141
+
142
+ // resolveFundingWall() → { mode, source, chosen, invalid } (resolveShipMode's shape). A
143
+ // database that cannot be read answers the default, which is the protective rule.
144
+ async function resolveFundingWall(deps = {}) {
145
+ const key = api.projectSettings.KEYS.FUNDING_WALL;
146
+ let row = null;
147
+ try {
148
+ row = await (deps.getSetting || api.projectSettings.get)(key);
149
+ } catch (err) {
150
+ log.error(`funding wall: project_settings read failed, using the default: ${err && err.message}`);
151
+ return { mode: DEFAULT_FUNDING_WALL, source: 'default', chosen: false, invalid: null };
152
+ }
153
+ if (row && row.value != null && row.value !== '') {
154
+ const got = parseFundingWall(row.value);
155
+ if (got.mode) return { mode: got.mode, source: 'project_settings', chosen: true, invalid: null };
156
+ return { mode: DEFAULT_FUNDING_WALL, source: 'default', chosen: false, invalid: { where: `project_settings['${key}']`, raw: String(row.value), reason: got.reason } };
157
+ }
158
+ return { mode: DEFAULT_FUNDING_WALL, source: 'default', chosen: false, invalid: null };
159
+ }
160
+
161
+ function describeFundingWall(resolved) {
162
+ const r = resolved || {};
163
+ return `${MODE_WORDS[r.mode] || MODE_WORDS[DEFAULT_FUNDING_WALL]}${r.chosen ? '' : ' (the default — the project has not chosen)'}`;
164
+ }
165
+
166
+ function accountPort(deps) {
167
+ return deps.account !== undefined ? deps.account : api.resolveOptional('economy.account');
168
+ }
169
+
170
+ // fundingWallFailures — the claim-time wall, called from all three claim surfaces.
171
+ // `client` is the claim transaction (claimTask, claimTasksBatch) or the pool (the
172
+ // validateClaimBatch dry run, `place: false`). With `place`, it takes the account lock,
173
+ // lets go of settled holds, and HOLDS each task's price when every task passes; the
174
+ // caller's rollback undoes the holds if a later gate refuses. Tasks are judged in the
175
+ // order given, each against what the ones before it would hold. Returns
176
+ // [{ task_id, refusal }] — empty for any task that pays no cash, without reading the
177
+ // rule or the account at all.
178
+ async function fundingWallFailures({ client, tasks, place = false, deps = {} }) {
179
+ const ids = (tasks || []).map((t) => String(t.id)).filter((s) => /^\d+$/.test(s));
180
+ if (!ids.length) return [];
181
+ const views = await (deps.readPayTerms || readPayTerms)(ids, { db: client });
182
+ const priced = ids.map((id) => ({ id, price: cashPrice(views.get(id)) })).filter((p) => p.price.cash);
183
+ if (!priced.length) return [];
184
+
185
+ const { mode } = await (deps.resolveFundingWall || resolveFundingWall)();
186
+ if (mode !== 'hold_at_claim') return [];
187
+
188
+ const account = accountPort(deps);
189
+ const failures = [];
190
+ if (!account) {
191
+ for (const p of priced) failures.push({ task_id: Number(p.id), refusal: noAccountRefusal(p.id, mode) });
192
+ return failures;
193
+ }
194
+ if (place) {
195
+ await account.lockAccount(client);
196
+ await account.releaseSettledHolds(client);
197
+ }
198
+ const snapshot = await account.readAvailable(client);
199
+ let committed = 0;
200
+ const holds = [];
201
+ for (const p of priced) {
202
+ if (p.price.unreadable) { failures.push({ task_id: Number(p.id), refusal: unreadableRefusal(p.id, p.price.unreadable, mode) }); continue; }
203
+ const now = { ...snapshot, held_micro: snapshot.held_micro + committed, available_micro: snapshot.available_micro - committed };
204
+ if (p.price.micro > now.available_micro) {
205
+ failures.push({ task_id: Number(p.id), refusal: shortfallRefusal('FUNDING_SHORTFALL', { taskId: p.id, priceMicro: p.price.micro, account: now, mode }) });
206
+ continue;
207
+ }
208
+ committed += p.price.micro;
209
+ holds.push(p);
210
+ }
211
+ if (place && !failures.length) {
212
+ for (const p of holds) {
213
+ await account.recordMovement(client, {
214
+ kind: 'hold', amountMicro: p.price.micro, taskId: p.id,
215
+ note: `Held at claim for task ${p.id}'s cash pay (${usd(p.price.micro)}), under the project's funding rule.`,
216
+ });
217
+ }
218
+ }
219
+ return failures;
220
+ }
221
+
222
+ // fundingWallOnResolve — inside resolveClaim's transaction, after the claim and task rows
223
+ // are updated. A task declared done under refuse_at_completion is refused (throws) when the
224
+ // account cannot cover it; its own hold, if it has one from an earlier rule, counts for
225
+ // it. A task let go (any other outcome) has its hold released now rather than at the next
226
+ // claim, so the owner's view of what is held is current.
227
+ async function fundingWallOnResolve(client, taskId, outcome, deps = {}) {
228
+ const account = accountPort(deps);
229
+ if (outcome !== 'shipped') {
230
+ if (account) await account.releaseSettledHolds(client);
231
+ return;
232
+ }
233
+ const views = await (deps.readPayTerms || readPayTerms)([String(taskId)], { db: client });
234
+ const price = cashPrice(views.get(String(taskId)));
235
+ if (!price.cash) return;
236
+ const { mode } = await (deps.resolveFundingWall || resolveFundingWall)();
237
+ if (mode !== 'refuse_at_completion') return;
238
+ if (!account) throw noAccountRefusal(taskId, mode);
239
+ if (price.unreadable) throw unreadableRefusal(taskId, price.unreadable, mode);
240
+ await account.lockAccount(client);
241
+ await account.releaseSettledHolds(client); // as at claim: the record is current before it is read
242
+ const snapshot = await account.readAvailable(client, { exceptTaskId: taskId });
243
+ if (price.micro > snapshot.available_micro) {
244
+ throw shortfallRefusal('FUNDING_SHORTFALL_AT_COMPLETION', { taskId, priceMicro: price.micro, account: snapshot, mode });
245
+ }
246
+ }
247
+
248
+ module.exports = {
249
+ FUNDING_WALL_MODES, DEFAULT_FUNDING_WALL,
250
+ parseFundingWall, dollarsMicro, cashPrice, usd,
251
+ resolveFundingWall, describeFundingWall,
252
+ fundingWallFailures, fundingWallOnResolve,
253
+ };
@@ -47,7 +47,8 @@
47
47
  "lifecycle.kickoffSeed",
48
48
  "lifecycle.removals",
49
49
  "lifecycle.workflowDispatch",
50
- "lifecycle.traffic"
50
+ "lifecycle.traffic",
51
+ "lifecycle.payTypeGates"
51
52
  ],
52
53
  "consumes": []
53
54
  }
@@ -398,6 +398,11 @@ module.exports = function buildClaimsRouter() {
398
398
  err.code === 'AUTO_PICKUP_OFF' ? 409 :
399
399
  err.code === 'AUTO_PICKUP_CAP_REACHED' ? 409 :
400
400
  err.code === 'AUTO_PICKUP_ROUND_LIMIT' ? 409 :
401
+ // task 1003185 / ADR 0386: the project's funding wall. The body names the
402
+ // price, the balance, what is held and the shortfall (funding-wall.js).
403
+ err.code === 'FUNDING_SHORTFALL' ? 409 :
404
+ err.code === 'FUNDING_PRICE_UNREADABLE' ? 409 :
405
+ err.code === 'FUNDING_NO_ACCOUNT' ? 409 :
401
406
  500;
402
407
  // V4.R14 (#952): a TASK_NOT_READY 409 names the way forward instead of
403
408
  // leaving the caller to discover the promote route by spelunking (#506).
@@ -645,6 +650,10 @@ module.exports = function buildClaimsRouter() {
645
650
  err.code === 'NOT_YOUR_CLAIM' ? 403 :
646
651
  err.code === 'CLAIM_SESSION_MISMATCH' ? 403 :
647
652
  err.code === 'ALREADY_RESOLVED' ? 409 :
653
+ // task 1003185 / ADR 0386: a project that refuses completion while it is short.
654
+ err.code === 'FUNDING_SHORTFALL_AT_COMPLETION' ? 409 :
655
+ err.code === 'FUNDING_PRICE_UNREADABLE' ? 409 :
656
+ err.code === 'FUNDING_NO_ACCOUNT' ? 409 :
648
657
  500;
649
658
  return res.fail(err.code, status, { ...err });
650
659
  }
@@ -21,6 +21,13 @@ const log = api.logger('lifecycle');
21
21
 
22
22
  module.exports = function buildPayTypeGatesRouter() {
23
23
  const router = express.Router();
24
+ // The `lifecycle.payTypeGates` port (task 1004910): core's module-sale payouts ask it
25
+ // whether module_share is cleared (licence, takedown contact, payee onboarding) before
26
+ // a rule, a sale or a share moves. hasProvider-guarded so a rebuilt router never
27
+ // registers it twice.
28
+ if (!api.hasProvider('lifecycle.payTypeGates')) {
29
+ api.registerProvider('lifecycle.payTypeGates', { refuseUnclearedGates: gates.refuseUnclearedGates });
30
+ }
24
31
 
25
32
  // rank: any-builder for met/missing (the same pay information `GET /pay-terms` shows, no person's
26
33
  // money); the evidence references and who cleared each requirement are the owner's records, so
@@ -36,6 +36,7 @@ const goalCategory = require('../goal-category');
36
36
  const autopickup = require('../autopickup');
37
37
  const payTermsLib = require('../pay-terms'); // task 1004990
38
38
  const payTypeGates = require('../pay-type-gates'); // task 1004991
39
+ const fundingWall = require('../funding-wall'); // task 1003185
39
40
 
40
41
  const log = api.logger('lifecycle');
41
42
 
@@ -530,8 +531,10 @@ module.exports = function buildProjectSettingsRouter() {
530
531
  return res.fail('bad_task_ids', { status: 400, message: 'task_ids is a comma list of 1 to 50 task ids.' });
531
532
  }
532
533
  try {
533
- const map = await payTermsLib.readPayTerms(ids);
534
- res.json({ pay_terms: Object.fromEntries(map) });
534
+ const [map, wall] = await Promise.all([payTermsLib.readPayTerms(ids), fundingWall.resolveFundingWall()]);
535
+ // task 1003185 / ADR 0386: the project's funding rule rides beside the pay, so a
536
+ // builder sees what happens when the project runs short BEFORE claiming.
537
+ res.json({ pay_terms: Object.fromEntries(map), funding_wall: { mode: wall.mode, chosen: wall.chosen, sentence: fundingWall.describeFundingWall(wall) } });
535
538
  } catch (err) {
536
539
  log.error(`GET /pay-terms: ${err && err.message}`);
537
540
  res.fail('pay_terms_read_failed', { status: 500, message: 'internal error' });
@@ -564,6 +567,60 @@ module.exports = function buildProjectSettingsRouter() {
564
567
  }
565
568
  });
566
569
 
570
+ // --- the funding wall (task 1003185, ADR 0386) -------------------------------
571
+ //
572
+ // GET /project-settings/funding-wall — the project's rule for running short, in one
573
+ // plain sentence, plus the three choices. rank: any signed-in builder — a builder is
574
+ // owed this before claiming (ADR 0386), and it carries no balance or amount.
575
+ function fundingWallBody(resolved) {
576
+ return {
577
+ mode: resolved.mode, chosen: resolved.chosen, source: resolved.source, invalid: resolved.invalid,
578
+ sentence: fundingWall.describeFundingWall(resolved),
579
+ choices: fundingWall.FUNDING_WALL_MODES.map((mode) => ({ mode, sentence: fundingWall.describeFundingWall({ mode, chosen: true }) })),
580
+ };
581
+ }
582
+ router.get('/project-settings/funding-wall', api.requireBuilder, async (req, res) => {
583
+ try {
584
+ res.set('Cache-Control', 'no-store');
585
+ res.json(fundingWallBody(await fundingWall.resolveFundingWall()));
586
+ } catch (err) {
587
+ log.error(`GET /project-settings/funding-wall: ${err && err.message}`);
588
+ res.fail('project_settings_read_failed', { status: 500, message: 'internal error' });
589
+ }
590
+ });
591
+
592
+ // PATCH /project-settings/funding-wall — body { mode: 'hold_at_claim' | 'refuse_at_completion' | 'carry_on' }.
593
+ //
594
+ // rank: project.settings.manage (metic+, government_010), the same atom as the pay-terms
595
+ // write: the funding rule is part of the project's pay policy (ADR 0385 amendment), so
596
+ // every change also lands in the audit log (who, when, from what to what).
597
+ router.patch('/project-settings/funding-wall', api.requireBuilder, api.requirePermission('project.settings.manage'), api.requireActAlone('rules'), async (req, res) => {
598
+ if (api.validateOrRespond(req, res, { mode: { required: true, type: 'string' } })) return;
599
+ const parsed = fundingWall.parseFundingWall(req.body.mode);
600
+ if (!parsed.mode) return res.fail('bad_funding_wall', { status: 400, message: `mode: ${parsed.reason}` });
601
+ try {
602
+ const before = await fundingWall.resolveFundingWall();
603
+ await api.projectSettings.set(api.projectSettings.KEYS.FUNDING_WALL, parsed.mode, req.builder.id);
604
+ try {
605
+ await api.insertAuditLog({
606
+ builderId: req.builder.id,
607
+ route: 'project-settings:funding-wall',
608
+ method: 'PATCH',
609
+ requestBodyRedacted: JSON.stringify({ from: before.mode, to: parsed.mode }),
610
+ responseStatus: 200,
611
+ ip: req.ip || null,
612
+ userAgent: req.get ? req.get('user-agent') : null,
613
+ });
614
+ } catch (auditErr) {
615
+ log.error(`AUDIT GAP: funding wall set to ${parsed.mode} but its audit row failed: ${auditErr && auditErr.message}`);
616
+ }
617
+ res.json({ ok: true, ...fundingWallBody(await fundingWall.resolveFundingWall()) });
618
+ } catch (err) {
619
+ log.error(`PATCH /project-settings/funding-wall: ${err && err.message}`);
620
+ res.fail('project_settings_write_failed', { status: 500, message: 'internal error' });
621
+ }
622
+ });
623
+
567
624
  // --- tweak auto-pickup (task 1004677, ADR 0371 D3/D5) ----------------------
568
625
  //
569
626
  // GET /project-settings/tweak-autopickup — the switch, the monthly cap, and how
@@ -50,11 +50,13 @@ const { DEMO_PEOPLE, DEMO_HOURS, intIn } = genesis;
50
50
  function birthMarker(founding) {
51
51
  const f = founding && typeof founding === 'object' ? founding : {};
52
52
  const born = typeof f.birth === 'string' && Number.isFinite(new Date(f.birth).getTime());
53
- if (!born) return { born: false, demo: null };
53
+ if (!born) return { born: false, demo: null, genesis: false };
54
54
  const people = Number(f.demoPeople);
55
55
  const hours = Number(f.demoHours);
56
56
  const demo = intIn(people, DEMO_PEOPLE[0], DEMO_PEOPLE[1]) && intIn(hours, DEMO_HOURS[0], DEMO_HOURS[1]) ? { people, hours_each: hours } : null;
57
- return { born: true, demo };
57
+ // Genesis is paused (owner, 2026-11-01): only a birth stamped with the genesis companion starts it, so a
58
+ // project born before the pause, carrying a marker alone, never does.
59
+ return { born: true, demo, genesis: f.genesis === 'on' };
58
60
  }
59
61
 
60
62
  function markerFrom(deps) {
@@ -71,6 +73,7 @@ function markerFrom(deps) {
71
73
  async function bornFounding(deps = {}) {
72
74
  const marker = markerFrom(deps);
73
75
  if (!marker.born) return { ran: false, reason: 'no_marker' };
76
+ if (!marker.genesis) return { ran: false, reason: 'genesis_paused' };
74
77
  const get = deps.getSetting || require('../../src/module-api').projectSettings.get;
75
78
  const [started, ended] = await Promise.all([get(fm.KEYS.STARTED_AT), get(fm.KEYS.ENDED_AT)]);
76
79
  if (ended && ended.value) return { ran: false, reason: 'already_alive' };
@@ -50,6 +50,11 @@ const KEYS = Object.freeze({
50
50
  // When the planet came alive (founding switched off). With STARTED_AT it bounds the
51
51
  // window a builder must have joined in to carry the founding mark (task 1004424).
52
52
  ENDED_AT: 'founding.ended_at',
53
+ // Genesis is OFF unless a project turns it on (owner, 2026-11-01: not polished enough, so every
54
+ // project opens as the full hall). Read as ordinary 'true'; absent or anything else is OFF, which is
55
+ // also what pauses a project that was already mid-founding. A demo, or a project born with genesis,
56
+ // turns it on once at birth (founding-birth.js); the owner's own switch can follow it.
57
+ ENABLED: 'genesis.enabled',
53
58
  });
54
59
 
55
60
  // The key that records when a stage first became current, so a builder's join time
@@ -121,6 +126,11 @@ async function foundingModeFor(deps = {}) {
121
126
  const get = deps.getSetting || require('../../src/module-api').projectSettings.get;
122
127
  let shaped;
123
128
  try {
129
+ // Paused unless the project turned genesis on: the hall opens full, whatever founding.active says.
130
+ // Asked FIRST and alone, because this is the answer for nearly every project and every /me, so the
131
+ // common case is one read, not four.
132
+ const enabled = await get(KEYS.ENABLED);
133
+ if (!parseActive(enabled && enabled.value)) return null;
124
134
  const [active, stage, startedAt] = await Promise.all([
125
135
  get(KEYS.ACTIVE), get(KEYS.STAGE), get(KEYS.STARTED_AT),
126
136
  ]);
@@ -264,6 +264,9 @@ async function startFounding(builderId, deps = {}) {
264
264
  const s = seams(deps);
265
265
  const ended = await s.get(fm.KEYS.ENDED_AT);
266
266
  if (ended && ended.value) return { ok: false, reason: 'already_alive' };
267
+ // Genesis is off unless a project turns it on (task 1005035): starting it IS turning it on, whether the
268
+ // birth does it or the owner's own start route does, so the mode read below never answers null for it.
269
+ await s.set(fm.KEYS.ENABLED, 'true', builderId);
267
270
  const mode = await fm.setFoundingMode({ active: true }, builderId, modeDeps(s));
268
271
  return { ok: true, mode };
269
272
  }
@@ -21,6 +21,9 @@
21
21
  // that came and went would leave a stale line behind.
22
22
 
23
23
  const BIRTH_KEY = 'founding_birth';
24
+ // Genesis is paused (owner, 2026-11-01): only a birth stamped with this key starts it. Reserved like the
25
+ // birth itself; a row born before the pause carries the birth alone, so it never founds.
26
+ const GENESIS_KEY = 'founding_genesis';
24
27
  // The wizard's ship-mode answer (task 1004629): auto | manual, stamped beside the birth on
25
28
  // the row the create route just made. Reserved like the birth (out of SETTINGS_VOCAB): the
26
29
  // instance's own project_settings['ship.mode'] is where the setting LIVES and where the
@@ -36,6 +39,11 @@ function bornAt(row) {
36
39
  return ISO.test(v) && Number.isFinite(new Date(v).getTime()) ? v : '';
37
40
  }
38
41
 
42
+ // genesisOn(row) — PURE. 'on' only for a born row stamped with genesis, else ''.
43
+ function genesisOn(row) {
44
+ return bornAt(row) && storedOf(row)[GENESIS_KEY] === 'on' ? 'on' : '';
45
+ }
46
+
39
47
  // demoBox(row) — PURE. A born demo's { people, hours_each }, or null. Only a row that is
40
48
  // both born and a demo carries one: an older demo has no marker and founds nothing.
41
49
  function demoBox(row) {
@@ -57,6 +65,7 @@ const bornShipMode = (row) => parseShipMode(storedOf(row)[SHIP_MODE_KEY]).mode |
57
65
  // The companion env (SETTINGS_COMPANION_ENV). src/branding.js maps them onto founding.*.
58
66
  const BIRTH_COMPANION_ENV = Object.freeze({
59
67
  FOUNDING_BIRTH: bornAt,
68
+ FOUNDING_GENESIS: genesisOn,
60
69
  DEMO_PEOPLE: (row) => { const d = demoBox(row); return d ? String(d.people) : ''; },
61
70
  DEMO_HOURS: (row) => { const d = demoBox(row); return d ? String(d.hours_each) : ''; },
62
71
  SHIP_MODE: bornShipMode,
@@ -67,9 +76,9 @@ const BIRTH_COMPANION_ENV = Object.freeze({
67
76
  async function stampBirth(db, instanceId, now = new Date()) {
68
77
  await db.query(
69
78
  `UPDATE provisioning_instances
70
- SET settings = COALESCE(settings, '{}'::jsonb) || jsonb_build_object($2::text, $3::text)
79
+ SET settings = COALESCE(settings, '{}'::jsonb) || jsonb_build_object($2::text, $3::text, $4::text, 'on')
71
80
  WHERE id = $1 AND NOT (COALESCE(settings, '{}'::jsonb) ? $2::text)`,
72
- [instanceId, BIRTH_KEY, now.toISOString()],
81
+ [instanceId, BIRTH_KEY, now.toISOString(), GENESIS_KEY],
73
82
  );
74
83
  }
75
84
 
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.24.22",
3
+ "version": "1.24.24",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.24.22",
9
+ "version": "1.24.24",
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.24.22",
3
+ "version": "1.24.24",
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",
@@ -11021,5 +11021,49 @@
11021
11021
  "id": "1005034",
11022
11022
  "text": "New projects now open as the full hall instead of starting in the guided setup (genesis and founding mode). The guided setup is paused for now and stays available for demo projects and for test projects that ask for it. Projec"
11023
11023
  }
11024
+ ],
11025
+ "1.24.23": [
11026
+ {
11027
+ "id": "1005035",
11028
+ "text": "Projects that were still in the guided setup now open as the full project instead, and no project starts the guided setup unless it is switched on for that project. Hosted projects pick this up when their version is moved forw"
11029
+ }
11030
+ ],
11031
+ "1.24.24": [
11032
+ {
11033
+ "id": "1005039",
11034
+ "text": "The automatic check that tax-detail records can never be deleted now tests real records, so it passes and still guards that rule."
11035
+ },
11036
+ {
11037
+ "id": "1004912",
11038
+ "text": "Every day the project now checks its money records against the payment processor's and alerts the owner if they differ. Until a payment processor is connected there is nothing to compare, so each day's check is recorded as not"
11039
+ },
11040
+ {
11041
+ "id": "1003186",
11042
+ "text": "Proved end to end that a funded project pays a builder exactly once and an underfunded one refuses with the shortfall, and closed a gap where money owed to a builder could be promised to a second task before they were paid."
11043
+ },
11044
+ {
11045
+ "id": "1004911",
11046
+ "text": "People can now take their own balance out to their bank, with a $10 minimum, holds that explain themselves, and a record of every failed try that can never pay twice."
11047
+ },
11048
+ {
11049
+ "id": "1004910",
11050
+ "text": "When a module sells, the money can now be split the way its project decides: the seller gets their share, and the people who built it share the rest by the work they recorded. Each person is paid once, only after a person appr"
11051
+ },
11052
+ {
11053
+ "id": "1003185",
11054
+ "text": "A project that cannot pay for a cash task now stops it being claimed, and says exactly how much money is missing; each project can choose that rule, stop work at completion instead, or carry on."
11055
+ },
11056
+ {
11057
+ "id": "1003184",
11058
+ "text": "The owner can now see where the project's money went: spending each month, what each builder was paid, and what is still owed but not yet paid, with the balance that is really left after those debts."
11059
+ },
11060
+ {
11061
+ "id": "1004907",
11062
+ "text": "Each person can now see their own balance and the project's, fund the project, and pay a teammate with no fee, and one project can never see or spend another's money. Nothing moves until the payment processor is connected."
11063
+ },
11064
+ {
11065
+ "id": "1004908",
11066
+ "text": "Contractors are now only paid once a signed NDA is on file and the payment processor has collected their tax details; if either is missing the payment waits and the person is told why. Bongos never stores tax numbers."
11067
+ }
11024
11068
  ]
11025
11069
  }
@@ -83,6 +83,7 @@ const REQUESTS = Object.freeze({
83
83
  'PATCH /project-settings/ship-mode': ['PATCH', '/project-settings/ship-mode', { mode: 'sometimes' }],
84
84
  'PATCH /project-settings/tweak-autopickup': ['PATCH', '/project-settings/tweak-autopickup', { mode: 'sometimes' }],
85
85
  'PATCH /project-settings/pay-terms': ['PATCH', '/project-settings/pay-terms', { terms: 'not a list' }],
86
+ 'PATCH /project-settings/funding-wall': ['PATCH', '/project-settings/funding-wall', { mode: 'sometimes' }],
86
87
  'PATCH /project-settings/spend-cap': ['PATCH', '/project-settings/spend-cap', { usd: 'lots' }],
87
88
  'PATCH /work-categories/:id': ['PATCH', `/work-categories/${BAD}`, {}],
88
89
  'POST /market-watch/proposals/:id/decide': ['POST', `/market-watch/proposals/${BAD}/decide`, { decision: 'approve' }],
@@ -86,6 +86,24 @@ const CARDS = Object.freeze({
86
86
  'This task needs server access, which this account does not have yet.',
87
87
  'I will pick a task that can be done from here instead.',
88
88
  'The project owner, if you should have server access.', RULE),
89
+ // task 1003185 / ADR 0386: the project's funding rule. The message above the card
90
+ // names the task's pay, the money available and how far short it is.
91
+ FUNDING_SHORTFALL: card(
92
+ 'This project does not have enough money set aside to pay for this task yet, and its rule is to stop new work until it does.',
93
+ 'I will show you how far short it is and pick a task that does not need the money.',
94
+ 'The project owner, who can add money or change the rule.', RULE),
95
+ FUNDING_SHORTFALL_AT_COMPLETION: card(
96
+ 'This project does not have enough money to pay for this task yet, and its rule is that work cannot be marked done until it does.',
97
+ 'Your work is kept as it is. I will show you how far short it is; it can be marked done once the money is there.',
98
+ 'The project owner, who can add money or change the rule.', RULE),
99
+ FUNDING_PRICE_UNREADABLE: card(
100
+ 'This task pays money, but its pay is not written as a dollar amount, so the project cannot check it can pay.',
101
+ 'I will show you what the pay says and pick another task meanwhile.',
102
+ 'Whoever looks after the task, to write its pay as a dollar amount.', RULE),
103
+ FUNDING_NO_ACCOUNT: card(
104
+ 'This task pays money, and the record of the project’s money is switched off, so nobody can check the project can pay.',
105
+ 'I will pick a task that does not pay money meanwhile.',
106
+ 'The project owner, who can switch the record back on or change the rule.', RULE),
89
107
  archon_approval_required: card(
90
108
  'This goal covers a protected part of the project, so joining it needs an Archon to say yes.',
91
109
  'I will file the request for you and we pick other work while it waits.',