@bongos/core 1.24.14 → 1.24.15

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 (161) hide show
  1. package/.bongos-core.json +296 -136
  2. package/.claude/skills/blocker-review/SKILL.md +2 -0
  3. package/.claude/skills/session-board/SKILL.md +29 -0
  4. package/.claude/skills/strand-fix/SKILL.md +1 -1
  5. package/clients/bongos-client/README.md +1 -1
  6. package/clients/bongos-client/bongos-client.global.js +14 -0
  7. package/clients/bongos-client/index.cjs +14 -0
  8. package/clients/bongos-client/index.d.ts +23 -2
  9. package/clients/bongos-client/index.mjs +14 -0
  10. package/docs/adr/0385-money-lives-with-a-processor-credits-stay-a-project-meter.md +4 -0
  11. package/docs/api/openapi.json +369 -6
  12. package/docs/api-reference.md +19 -4
  13. package/docs/architecture.md +6 -0
  14. package/docs/copy-inventory.md +138 -123
  15. package/docs/copy-registry.json +296 -171
  16. package/docs/file-map.md +1 -0
  17. package/docs/module-api-changelog.md +2 -2
  18. package/docs/page-inventory.json +6 -1
  19. package/docs/page-readings.json +1040 -1021
  20. package/migrations/core_289_task_short_name.sql +34 -0
  21. package/modules/ai-studio/db.js +10 -0
  22. package/modules/ai-studio/gemini.js +73 -5
  23. package/modules/ai-studio/openrouter.js +17 -2
  24. package/modules/ai-studio/routes/ai-studio.js +7 -1
  25. package/modules/ai-studio/studio.js +8 -0
  26. package/modules/builder-settings/checkout-freshness.js +71 -0
  27. package/modules/builder-settings/routes/settings.js +26 -0
  28. package/modules/builder-settings/settings.js +2 -0
  29. package/modules/economy/migrations/economy_002_project_account.sql +83 -0
  30. package/modules/economy/module.json +1 -1
  31. package/modules/economy/project-account.js +166 -0
  32. package/modules/economy/routes/project-account.js +40 -0
  33. package/modules/hall-ui/public/ai-studio.css +3 -0
  34. package/modules/hall-ui/public/ai-studio.html +2 -1
  35. package/modules/hall-ui/public/ai-studio.js +72 -1
  36. package/modules/hall-ui/public/atlas.html +1 -1
  37. package/modules/hall-ui/public/blockers.html +1 -1
  38. package/modules/hall-ui/public/board-room.html +1 -1
  39. package/modules/hall-ui/public/city.html +1 -1
  40. package/modules/hall-ui/public/collab.html +1 -1
  41. package/modules/hall-ui/public/commands.html +1 -1
  42. package/modules/hall-ui/public/copy-desk.html +1 -1
  43. package/modules/hall-ui/public/deploy.html +1 -1
  44. package/modules/hall-ui/public/diagrams.html +1 -1
  45. package/modules/hall-ui/public/drachmae.html +1 -1
  46. package/modules/hall-ui/public/fleet.html +1 -1
  47. package/modules/hall-ui/public/gate.html +1 -1
  48. package/modules/hall-ui/public/github-chip.js +85 -0
  49. package/modules/hall-ui/public/goal-map.html +1 -1
  50. package/modules/hall-ui/public/goals.html +1 -1
  51. package/modules/hall-ui/public/government.html +1 -1
  52. package/modules/hall-ui/public/idea.html +1 -1
  53. package/modules/hall-ui/public/ideas.html +1 -1
  54. package/modules/hall-ui/public/index.html +1 -1
  55. package/modules/hall-ui/public/market-watch-item.html +2 -2
  56. package/modules/hall-ui/public/market-watch-item.js +10 -3
  57. package/modules/hall-ui/public/market-watch-item.states.json +41 -0
  58. package/modules/hall-ui/public/market-watch-lib.js +36 -5
  59. package/modules/hall-ui/public/market-watch-rules.html +1 -1
  60. package/modules/hall-ui/public/market-watch-settings.html +1 -1
  61. package/modules/hall-ui/public/market-watch.css +5 -0
  62. package/modules/hall-ui/public/market-watch.html +2 -2
  63. package/modules/hall-ui/public/modules.html +1 -1
  64. package/modules/hall-ui/public/my-agents.html +1 -1
  65. package/modules/hall-ui/public/my-agents.js +3 -0
  66. package/modules/hall-ui/public/primer.html +1 -1
  67. package/modules/hall-ui/public/profile.html +1 -1
  68. package/modules/hall-ui/public/project-settings.html +1 -1
  69. package/modules/hall-ui/public/ranks.html +1 -1
  70. package/modules/hall-ui/public/roadmap.html +1 -1
  71. package/modules/hall-ui/public/roster.html +1 -1
  72. package/modules/hall-ui/public/sessions.html +1 -1
  73. package/modules/hall-ui/public/settings.html +1 -1
  74. package/modules/hall-ui/public/shell.js +22 -22
  75. package/modules/hall-ui/public/street.html +1 -1
  76. package/modules/hall-ui/public/studio.html +1 -1
  77. package/modules/hall-ui/public/style.css +28 -0
  78. package/modules/hall-ui/public/task.html +1 -1
  79. package/modules/hall-ui/public/thinking.html +1 -1
  80. package/modules/hall-ui/public/tweak-editor.html +1 -1
  81. package/modules/hall-ui/public/watch.html +1 -1
  82. package/modules/hall-ui/public/work.html +1 -1
  83. package/modules/ideas/blockers.js +13 -7
  84. package/modules/lifecycle/db-claim-reads.js +1 -1
  85. package/modules/lifecycle/db-ship.js +2 -2
  86. package/modules/lifecycle/db-tasks.js +4 -4
  87. package/modules/lifecycle/migrations/lifecycle_019_pay_type_gates.sql +50 -0
  88. package/modules/lifecycle/module.json +1 -0
  89. package/modules/lifecycle/pay-type-gates.js +180 -0
  90. package/modules/lifecycle/publish-reconciler.js +31 -1
  91. package/modules/lifecycle/rebase-gate.js +3 -2
  92. package/modules/lifecycle/routes/claims.js +3 -2
  93. package/modules/lifecycle/routes/pay-type-gates.js +59 -0
  94. package/modules/lifecycle/routes/project-settings.js +4 -0
  95. package/modules/lifecycle/routes/task-write-routes.js +43 -0
  96. package/modules/lifecycle/ship-card.js +4 -1
  97. package/modules/lifecycle/task-name-db.js +55 -0
  98. package/modules/lifecycle/task-name.js +140 -0
  99. package/modules/market-watch/db.js +10 -1
  100. package/modules/market-watch/migrations/market_watch_004_entry_corrections.sql +20 -0
  101. package/modules/market-watch/pages.js +20 -1
  102. package/modules/market-watch/proposals.js +57 -7
  103. package/modules/market-watch/routes/pages.js +2 -0
  104. package/modules/market-watch/routes/rules.js +4 -0
  105. package/modules/platform-identity/terms-acceptance.js +9 -9
  106. package/modules/public-landing/public/account-google.states.json +14 -0
  107. package/modules/public-landing/public/assets/cosmos.css +12 -0
  108. package/modules/public-landing/public/assets/hub-bar.js +44 -1
  109. package/modules/public-landing/public/privacy.html +5 -1
  110. package/modules/public-landing/public/projects-google.states.json +14 -0
  111. package/modules/public-landing/public/projects.html +39 -0
  112. package/modules/public-landing/public/terms.html +12 -2
  113. package/modules/ui-design/kit/fixtures/me-google.json +10 -0
  114. package/modules/ui-design/kit/fixtures/me-sign-in-methods-google.json +4 -0
  115. package/modules/ui-design/kit/serve.js +7 -1
  116. package/package-lock.json +2 -2
  117. package/package.json +1 -1
  118. package/release-notes.json +49 -1
  119. package/scripts/bongos/claim.js +4 -2
  120. package/scripts/bongos/land-watch.js +1 -0
  121. package/scripts/bongos/report-checkout.js +30 -0
  122. package/scripts/bongos/run-unit-tests.js +11 -0
  123. package/scripts/bongos/sessions-board.js +264 -0
  124. package/scripts/bongos/ship-finish.js +1 -0
  125. package/scripts/bongos/ship-flow.js +5 -2
  126. package/scripts/bongos/strand-lease.js +5 -3
  127. package/scripts/bongos/strand-watch.js +38 -7
  128. package/scripts/gds/report-checkout.js +7 -0
  129. package/scripts/gds/sessions-board.js +7 -0
  130. package/src/bongos/agent-standing-rules.js +13 -3
  131. package/src/bongos/routes/me.js +7 -1
  132. package/src/module-api.js +1 -1
  133. package/tests/agent_standing_rules.mjs +18 -1
  134. package/tests/ai_studio_billing.mjs +13 -0
  135. package/tests/ai_studio_gemini_models.mjs +81 -0
  136. package/tests/ai_studio_requests.mjs +24 -0
  137. package/tests/blocker_detail_hydration.mjs +5 -3
  138. package/tests/blocker_tasks_named_db.mjs +74 -0
  139. package/tests/claim_gate_ci_unblock.mjs +1 -1
  140. package/tests/github_chip.mjs +217 -0
  141. package/tests/hall_ai_studio.mjs +129 -5
  142. package/tests/host_topology_skips.mjs +1 -0
  143. package/tests/hub_account_page.mjs +3 -2
  144. package/tests/landing_legal_pages.mjs +3 -2
  145. package/tests/market_watch_data_db.mjs +6 -0
  146. package/tests/market_watch_pages.mjs +87 -2
  147. package/tests/market_watch_rules.mjs +101 -5
  148. package/tests/market_watch_rules_db.mjs +42 -0
  149. package/tests/nav_permission_atoms.mjs +1 -1
  150. package/tests/pay_terms.mjs +8 -0
  151. package/tests/pay_type_gates.mjs +234 -0
  152. package/tests/pay_type_gates_db.mjs +84 -0
  153. package/tests/project_account.mjs +125 -0
  154. package/tests/project_account_db.mjs +97 -0
  155. package/tests/self_healing_start.mjs +162 -0
  156. package/tests/sessions_board.mjs +153 -0
  157. package/tests/stop_refcheck.mjs +40 -0
  158. package/tests/task_name.mjs +237 -0
  159. package/tests/task_name_db.mjs +79 -0
  160. package/tests/terms_acceptance.mjs +25 -3
  161. package/tests/terms_grandfather_db.mjs +11 -4
@@ -45,7 +45,7 @@ const V1 = {
45
45
 
46
46
  // ── the in-memory database ─────────────────────────────────────────────────────
47
47
  function makeDb() {
48
- const t = { versions: [], proposals: [], items: [], scores: [], runs: [{ id: 50, project: P, status: 'done' }], seq: 100 };
48
+ const t = { versions: [], proposals: [], items: [], scores: [], entries: [], runs: [{ id: 50, project: P, status: 'done' }], seq: 100 };
49
49
  const nextId = () => ++t.seq;
50
50
  t.versions.push({ id: nextId(), project: P, ...JSON.parse(JSON.stringify(V1)), approved_at: new Date('2026-10-28'), created_at: new Date('2026-10-28') });
51
51
  t.items.push({ id: nextId(), project: P, kind: 'competitor', name: 'OpenHands', url: 'https://openhands.dev', status: 'active', why_tracked: '' });
@@ -76,7 +76,11 @@ function makeDb() {
76
76
  case 'mw:proposal-lock':
77
77
  case 'mw:proposal-get-open': {
78
78
  const p = prop(a, b);
79
- return one(p && (tag === 'mw:proposal-lock' || p.status === 'open') ? p : null);
79
+ if (!p || !(tag === 'mw:proposal-lock' || p.status === 'open')) return one(null);
80
+ // Hand back only the columns the real SELECT names (task 1005026): returning the
81
+ // whole row hid a lock that never read log_entry_id, so every correction failed.
82
+ const cols = String(sql).match(/SELECT\s+([\s\S]+?)\s+FROM/)[1].split(',').map((x) => x.trim());
83
+ return one(Object.fromEntries(cols.map((k) => [k, p[k]])));
80
84
  }
81
85
  case 'mw:rules-max-version':
82
86
  return one({ v: Math.max(0, ...t.versions.filter((v) => v.project === a).map((v) => v.version)) });
@@ -131,6 +135,18 @@ function makeDb() {
131
135
  Object.assign(p, { status: c, decided_by: d, decided_at: new Date(), rule_version_id: e ?? p.rule_version_id, item_id: f ?? p.item_id, payload: { ...p.payload, ...JSON.parse(g) } });
132
136
  return one(p);
133
137
  }
138
+ case 'mw:entry-lock': {
139
+ // the entry, only through an item of this project, and only the proposal's item
140
+ const e = t.entries.find((x) => String(x.id) === String(a) && String(x.item_id) === String(c));
141
+ const it = e && t.items.find((x) => x.id === e.item_id && x.project === b);
142
+ return one(e && it ? { ...e } : null);
143
+ }
144
+ case 'mw:entry-correct': {
145
+ const e = t.entries.find((x) => String(x.id) === String(a));
146
+ if (!e || t.entries.some((o) => o.id !== e.id && o.item_id === e.item_id && o.run_id === e.run_id && o.takeaway === b)) return one(null);
147
+ e.takeaway = b;
148
+ return one({ id: e.id });
149
+ }
134
150
  case 'mw:proposal-at-vote': {
135
151
  const p = prop(a, b);
136
152
  if (p && p.status === 'open') p.payload = { ...p.payload, matter_id: c };
@@ -282,15 +298,71 @@ test('an approved add-item proposal creates the tracked item; the run\'s and the
282
298
  assert.equal(proposals.itemFromPayload({}), null);
283
299
  });
284
300
 
285
- test('remove_item takes the item off the watch; a correct_entry is recorded as approved, not applied', async () => {
301
+ test('remove_item takes the item off the watch', async () => {
286
302
  const { t, pool, withTx } = makeDb();
287
303
  const itemId = t.items[0].id;
288
304
  const filed = await proposals.createProposal(pool, { project: P, step: 'names', change: { op: 'remove', item_id: itemId }, reason: 'not a rival', proposedBy: ADA });
289
305
  await proposals.decideProposal({ project: P, id: filed.proposal.id, decision: 'approve', decidedBy: OWNER, withTx });
290
306
  assert.equal(t.items[0].status, 'removed');
291
- t.proposals.push({ id: 900, project: P, kind: 'correct_entry', origin: 'member', item_id: itemId, log_entry_id: 7, payload: {}, status: 'open', proposed_by: ADA });
307
+ });
308
+
309
+ // task 1005016: an approved correct_entry corrects the entry it names.
310
+ function withEntries() {
311
+ const d = makeDb();
312
+ const itemId = d.t.items[0].id;
313
+ d.t.items.push({ id: 300, project: P, kind: 'competitor', name: 'Other', url: 'https://other.dev', status: 'active' });
314
+ d.t.items.push({ id: 400, project: 'https://other.example', kind: 'competitor', name: 'Theirs', url: 'https://theirs.dev', status: 'active' });
315
+ d.t.entries.push(
316
+ { id: 7, item_id: itemId, run_id: 50, takeaway: 'OpenHands raised a Series B.' },
317
+ { id: 8, item_id: itemId, run_id: 50, takeaway: 'OpenHands shut down.' },
318
+ { id: 9, item_id: 300, run_id: 50, takeaway: 'Another item\'s entry.' },
319
+ { id: 10, item_id: 400, run_id: 50, takeaway: 'Another project\'s entry.' },
320
+ );
321
+ const file = (id, payload, { item = itemId, entry = 7 } = {}) => d.t.proposals.push({
322
+ id, project: P, kind: 'correct_entry', origin: 'member', item_id: item, log_entry_id: entry, payload, reason: 'their post says seed', status: 'open', proposed_by: ADA,
323
+ });
324
+ return { ...d, itemId, file };
325
+ }
326
+
327
+ test('CORRECT AN ENTRY: approving rewrites the entry, and keeps who, when, why and the old text', async () => {
328
+ const { t, withTx, file } = withEntries();
329
+ file(900, { takeaway: 'OpenHands raised a seed round, not a Series B.' });
292
330
  const out = await proposals.decideProposal({ project: P, id: 900, decision: 'approve', decidedBy: OWNER, withTx });
293
- assert.deepEqual([out.status, out.applied], ['approved', false]);
331
+ assert.deepEqual([out.ok, out.status, out.applied, out.log_entry_id], [true, 'applied', true, 7]);
332
+ assert.equal(t.entries.find((e) => e.id === 7).takeaway, 'OpenHands raised a seed round, not a Series B.', 'the entry itself now reads corrected');
333
+ const p = t.proposals.find((x) => x.id === 900);
334
+ assert.equal(p.status, 'applied');
335
+ assert.equal(p.proposed_by, ADA, 'who proposed it');
336
+ assert.equal(p.decided_by, OWNER, 'who approved it');
337
+ assert.ok(p.decided_at instanceof Date, 'when');
338
+ assert.equal(p.reason, 'their post says seed', 'why');
339
+ assert.equal(p.payload.was, 'OpenHands raised a Series B.', 'the text it replaced is never lost');
340
+ });
341
+
342
+ test('CORRECT AN ENTRY: a wrong flag leaves the text as found and applies the flag', async () => {
343
+ const { t, withTx, file } = withEntries();
344
+ file(901, { wrong: true }, { entry: 8 });
345
+ const out = await proposals.decideProposal({ project: P, id: 901, decision: 'approve', decidedBy: OWNER, withTx });
346
+ assert.deepEqual([out.status, out.applied], ['applied', true]);
347
+ assert.equal(t.entries.find((e) => e.id === 8).takeaway, 'OpenHands shut down.');
348
+ assert.deepEqual([t.proposals.find((x) => x.id === 901).payload.wrong, t.proposals.find((x) => x.id === 901).payload.was], [true, 'OpenHands shut down.']);
349
+ });
350
+
351
+ test('CORRECT AN ENTRY: refused, and left open, when it names nothing it may change', async () => {
352
+ const { t, withTx, file } = withEntries();
353
+ const decide = (id) => proposals.decideProposal({ project: P, id, decision: 'approve', decidedBy: OWNER, withTx });
354
+ file(902, {}); // filed before task 1005016: the reason was the correction
355
+ assert.equal((await decide(902)).reason, 'no_correction');
356
+ file(903, { takeaway: 'x' }, { entry: 9 }); // another item's entry
357
+ assert.equal((await decide(903)).reason, 'entry_gone');
358
+ file(904, { takeaway: 'x' }, { item: 400, entry: 10 }); // another project's item and entry
359
+ assert.equal((await decide(904)).reason, 'entry_gone');
360
+ file(905, { takeaway: 'OpenHands shut down.' }); // would duplicate entry 8 in the same run
361
+ assert.equal((await decide(905)).reason, 'same_as_another_entry');
362
+ for (const id of [902, 903, 904, 905]) assert.equal(t.proposals.find((x) => x.id === id).status, 'open', `proposal ${id} stays open`);
363
+ assert.deepEqual(t.entries.map((e) => e.takeaway), ['OpenHands raised a Series B.', 'OpenHands shut down.', 'Another item\'s entry.', 'Another project\'s entry.'], 'no entry changed');
364
+ // a decline of the old kind still works
365
+ assert.equal((await proposals.decideProposal({ project: P, id: 902, decision: 'decline', decidedBy: OWNER, withTx })).status, 'rejected');
294
366
  });
295
367
 
296
368
  test('a project cannot decide another project\'s proposal', async () => {
@@ -422,6 +494,24 @@ test('OWNER ONLY: a member who is not the owner gets 403 and nothing changes; th
422
494
  assert.equal((await call(OWNER, 'POST', `/market-watch/proposals/${id}/decide`, { decision: 'maybe' })).status, 400);
423
495
  });
424
496
 
497
+ test('OWNER ONLY, correcting an entry: a member gets 403 and the entry is untouched; the owner\'s approval corrects it', async () => {
498
+ const itemId = RT.t.items[0].id;
499
+ RT.t.entries.push({ id: 70, item_id: itemId, run_id: 50, takeaway: 'Old words.' });
500
+ RT.t.proposals.push({ id: 970, project: P, kind: 'correct_entry', origin: 'member', item_id: itemId, log_entry_id: 70, payload: { takeaway: 'New words.' }, reason: 'r', status: 'open', proposed_by: ADA });
501
+ const denied = await call(ADA, 'POST', '/market-watch/proposals/970/decide', { decision: 'approve' });
502
+ assert.deepEqual([denied.status, denied.code], [403, 'owner_only']);
503
+ assert.equal(RT.t.entries.find((e) => e.id === 70).takeaway, 'Old words.');
504
+ assert.equal(RT.t.proposals.find((p) => p.id === 970).status, 'open');
505
+ assert.equal((await call(null, 'POST', '/market-watch/proposals/970/decide', { decision: 'approve' })).status, 401);
506
+ const ok = await call(OWNER, 'POST', '/market-watch/proposals/970/decide', { decision: 'approve' });
507
+ assert.equal(ok.status, 200, JSON.stringify(ok.json));
508
+ assert.equal(RT.t.entries.find((e) => e.id === 70).takeaway, 'New words.');
509
+ RT.t.proposals.push({ id: 971, project: P, kind: 'correct_entry', origin: 'member', item_id: itemId, log_entry_id: 70, payload: {}, reason: 'r', status: 'open', proposed_by: ADA });
510
+ const old = await call(OWNER, 'POST', '/market-watch/proposals/971/decide', { decision: 'approve' });
511
+ assert.deepEqual([old.status, old.code], [409, 'no_correction'], 'an old proposal with no correction says so in words');
512
+ assert.match(old.json.error.message, /does not say how the entry should read/);
513
+ });
514
+
425
515
  test('a government that hands Rules to a sitting: the owner cannot decide alone (409), the page offers the vote', async () => {
426
516
  const filed = await proposals.createProposal(RT.pool, { project: P, step: 'brief', change: { op: 'set', text: 'Five lines.' }, reason: 'r', proposedBy: ADA });
427
517
  const sitting = {
@@ -553,4 +643,10 @@ test('the payloads the item page files are the ones an approval applies', () =>
553
643
  assert.ok(n.ok, body.kind);
554
644
  assert.ok(proposals.itemChange(n.value.kind, n.value.payload), `${body.kind}: pages.js writes a payload proposals.js applies`);
555
645
  }
646
+ // correct_entry (task 1005016): both shapes the item page files are ones an approval applies
647
+ const fix = pages.normalizeProposal({ kind: 'correct_entry', log_entry_id: '7', takeaway: ' seed,\nnot B ', reason: 'r' }, { item });
648
+ assert.deepEqual(proposals.entryCorrection(fix.value.payload), { takeaway: 'seed, not B' });
649
+ const wrong = pages.normalizeProposal({ kind: 'correct_entry', log_entry_id: '7', wrong: true, reason: 'r' }, { item });
650
+ assert.deepEqual(proposals.entryCorrection(wrong.value.payload), { wrong: true, takeaway: null });
651
+ assert.equal(proposals.entryCorrection({}), null);
556
652
  });
@@ -27,11 +27,14 @@ const seed = require('../modules/market-watch/seed.js');
27
27
  const store = require('../modules/market-watch/store.js');
28
28
  const proposals = require('../modules/market-watch/proposals.js');
29
29
  const view = require('../modules/market-watch/rules-view.js');
30
+ const pages = require('../modules/market-watch/pages.js');
31
+ const db = require('../modules/market-watch/db.js');
30
32
  const research = require('../modules/market-watch/research.js');
31
33
  const pricing = require('../src/bongos/llm-pricing.js');
32
34
 
33
35
  const MIGRATION = readFileSync(new URL('../modules/market-watch/migrations/market_watch_001_tables.sql', import.meta.url), 'utf8');
34
36
  const MIGRATION_002 = readFileSync(new URL('../modules/market-watch/migrations/market_watch_002_run.sql', import.meta.url), 'utf8');
37
+ const MIGRATION_004 = readFileSync(new URL('../modules/market-watch/migrations/market_watch_004_entry_corrections.sql', import.meta.url), 'utf8');
35
38
 
36
39
  const pool = new Pool({ connectionString: process.env.DATABASE_URL || undefined });
37
40
  try {
@@ -60,6 +63,8 @@ const withTx = async (fn) => {
60
63
 
61
64
  async function cleanup() {
62
65
  await q(`DELETE FROM market_watch_proposals WHERE project = $1`, [P]).catch(() => {});
66
+ await q(`DELETE FROM market_watch_log_entries WHERE item_id IN (SELECT id FROM market_watch_items WHERE project = $1)`, [P]).catch(() => {});
67
+ await q(`DELETE FROM market_watch_runs WHERE project = $1`, [P]).catch(() => {});
63
68
  await q(`DELETE FROM market_watch_items WHERE project = $1`, [P]).catch(() => {});
64
69
  await q(`DELETE FROM market_watch_rule_versions WHERE project = $1`, [P]).catch(() => {});
65
70
  await q(`DELETE FROM builders WHERE id = ANY($1::bigint[])`, [[OWNER, ADA]]).catch(() => {});
@@ -73,6 +78,7 @@ async function check(name, fn) {
73
78
  try {
74
79
  await q(MIGRATION);
75
80
  await q(MIGRATION_002);
81
+ await q(MIGRATION_004);
76
82
  await cleanup();
77
83
  await insertTestBuilders(pool, [{ id: OWNER, login: 't1004941-owner' }, { id: ADA, login: 't1004941-ada' }]);
78
84
  const r = seed.RULES_V1;
@@ -112,6 +118,42 @@ try {
112
118
  assert.deepEqual([it.kind, it.url, it.added_via, String(it.added_by), it.status], ['competitor', 'https://algora.io', 'proposal', String(ADA), 'active']);
113
119
  });
114
120
 
121
+ // task 1005016: an approved correct_entry rewrites the entry, keeps who/when/why and
122
+ // the old text on the proposal, and the case file reads the correction; a wrong flag
123
+ // leaves the text and reads as marked wrong.
124
+ await check('an approved correct_entry corrects the log entry, and the case file shows it', async () => {
125
+ const { rows: [it] } = await q(`SELECT id FROM market_watch_items WHERE project = $1 AND name = 'Algora'`, [P]);
126
+ const { rows: [run] } = await q(`INSERT INTO market_watch_runs (project, status, trigger) VALUES ($1, 'done', 'manual') RETURNING id`, [P]);
127
+ const { rows: [e1] } = await q(`INSERT INTO market_watch_log_entries (item_id, run_id, type, takeaway, source_url, confidence)
128
+ VALUES ($1, $2, 'news', 'Algora raised a Series B.', 'https://algora.io/blog', 2) RETURNING id`, [it.id, run.id]);
129
+ const { rows: [e2] } = await q(`INSERT INTO market_watch_log_entries (item_id, run_id, type, takeaway, source_url, confidence)
130
+ VALUES ($1, $2, 'news', 'Algora shut down.', 'https://algora.io/x', 1) RETURNING id`, [it.id, run.id]);
131
+ const file = async (body) => {
132
+ const n = pages.normalizeProposal({ kind: 'correct_entry', item_id: it.id, ...body }, { item: { id: Number(it.id), kind: 'competitor' } });
133
+ assert.equal(n.ok, true, JSON.stringify(n));
134
+ return pages.insertProposal(pool, { project: P, proposal: n.value, builderId: ADA });
135
+ };
136
+ const fix = await file({ log_entry_id: String(e1.id), takeaway: 'Algora raised a seed round, not a Series B.', reason: 'their post says seed' });
137
+ const out = await proposals.decideProposal({ project: P, id: fix.id, decision: 'approve', decidedBy: OWNER, withTx });
138
+ assert.deepEqual([out.ok, out.status], [true, 'applied'], JSON.stringify(out));
139
+ const { rows: [e] } = await q(`SELECT takeaway FROM market_watch_log_entries WHERE id = $1`, [e1.id]);
140
+ assert.equal(e.takeaway, 'Algora raised a seed round, not a Series B.');
141
+ const { rows: [p] } = await q(`SELECT status, proposed_by, decided_by, decided_at, reason, payload FROM market_watch_proposals WHERE id = $1`, [fix.id]);
142
+ assert.deepEqual([p.status, String(p.proposed_by), String(p.decided_by), p.reason, p.payload.was],
143
+ ['applied', String(ADA), String(OWNER), 'their post says seed', 'Algora raised a Series B.']);
144
+ assert.ok(p.decided_at instanceof Date);
145
+ const wrong = await file({ log_entry_id: String(e2.id), wrong: true, reason: 'they are still running' });
146
+ assert.equal((await proposals.decideProposal({ project: P, id: wrong.id, decision: 'approve', decidedBy: OWNER, withTx })).status, 'applied');
147
+ const cf = await db.caseFile(pool, { project: P, itemId: it.id });
148
+ const byId = new Map(cf.map((x) => [String(x.id), x]));
149
+ assert.equal(byId.get(String(e1.id)).correction_reason, 'their post says seed');
150
+ assert.equal(byId.get(String(e1.id)).marked_wrong, false);
151
+ assert.equal(byId.get(String(e2.id)).marked_wrong, true);
152
+ assert.equal(byId.get(String(e2.id)).takeaway, 'Algora shut down.', 'a wrong entry keeps its text');
153
+ const shaped = pages.shapeItem({ item: { id: it.id }, entries: cf, history: {}, rules: null });
154
+ assert.equal(shaped.entries.find((x) => x.id === Number(e2.id)).corrected.wrong, true);
155
+ });
156
+
115
157
  await check('the page read runs on the real schema', async () => {
116
158
  await proposals.createProposal(pool, { project: P, step: 'brief', change: { op: 'set', text: 'Write five lines.' }, reason: 'shorter', proposedBy: ADA });
117
159
  const page = await view.readView(pool, { project: P, decide: { mode: 'owner' }, pricing, model: research.MODEL });
@@ -94,7 +94,7 @@ test('the hall-preview fixture carries every atom /me resolves', () => {
94
94
  // "Upgraded to core X" notice after a move; task 1004826: Standing is Accomplishments;
95
95
  // task 1004846: the new logo (its bump landed late, in task 1004852); task 1004938: the
96
96
  // Market Watch settings nav item and its fuel glyph.
97
- const SHELL_SHA = 'b89fd877947ae9f8';
97
+ const SHELL_SHA = '1a23b1cf13567eff';
98
98
 
99
99
  test('shell.js has not changed without its cache-bust being bumped', () => {
100
100
  const actual = crypto.createHash('sha256').update(SHELL).digest('hex').slice(0, 16);
@@ -23,6 +23,14 @@ let statements = [];
23
23
  const query = async (sql, params = []) => {
24
24
  statements.push(sql);
25
25
  if (/^\s*(BEGIN|COMMIT|ROLLBACK)/i.test(sql)) return { rows: [] };
26
+ if (/FROM lifecycle_pay_type_gates/.test(sql)) {
27
+ // task 1004991: these cases are about the terms, so every gate reads as cleared;
28
+ // tests/pay_type_gates.mjs is where an open gate refuses.
29
+ const gates = require('../modules/lifecycle/pay-type-gates.js');
30
+ const at = new Date(Date.UTC(2026, 9, 30));
31
+ return { rows: params[0].flatMap((pay_type) => gates.requirementIds(pay_type).map((requirement) => (
32
+ { pay_type, requirement, state: 'cleared', evidence_ref: 'test', decided_by: 1, decided_at: at }))) };
33
+ }
26
34
  if (/INSERT INTO lifecycle_pay_term_sets/.test(sql)) {
27
35
  const row = { id: sets.length + 1, task_id: params[0], set_by: params[1], set_at: new Date(Date.UTC(2026, 9, 30, 0, sets.length)) };
28
36
  sets.push(row);
@@ -0,0 +1,234 @@
1
+ // tests/pay_type_gates.mjs — a pay type can be put on a task only after its gate is
2
+ // cleared for the project (task 1004991, goal 1000092; ADR 0385 "Pay-type gates").
3
+ //
4
+ // What is real: modules/lifecycle/pay-type-gates.js (the list, the verdict, the checker,
5
+ // the writer), the pay-type-gates router, the lifecycle tasks router (PATCH /tasks/:id)
6
+ // and the project-settings router (the standing terms). What is faked: the caller
7
+ // (requireBuilder, requireRank, requirePermission) and Postgres, a recording stand-in
8
+ // holding the gate history and the pay-term sets in memory.
9
+ //
10
+ // Run: node tests/pay_type_gates.mjs
11
+
12
+ import { strict as assert } from 'node:assert';
13
+ import { createRequire } from 'node:module';
14
+ const require = createRequire(import.meta.url);
15
+ process.env.NODE_ENV = 'test';
16
+
17
+ let gateRows = [];
18
+ let sets = [];
19
+ let terms = [];
20
+ const query = async (sql, params = []) => {
21
+ if (/^\s*(BEGIN|COMMIT|ROLLBACK)/i.test(sql)) return { rows: [] };
22
+ if (/INSERT INTO lifecycle_pay_type_gates/.test(sql)) {
23
+ const [pay_type, requirement, state, evidence_ref, decided_by] = params;
24
+ gateRows.push({ id: gateRows.length + 1, pay_type, requirement, state, evidence_ref, decided_by, decided_at: new Date(Date.UTC(2026, 9, 30, 0, gateRows.length)) });
25
+ return { rows: [] };
26
+ }
27
+ if (/FROM lifecycle_pay_type_gates/.test(sql)) {
28
+ const newest = new Map();
29
+ for (const r of gateRows.filter((x) => params[0].includes(x.pay_type))) newest.set(`${r.pay_type}/${r.requirement}`, r);
30
+ return { rows: [...newest.values()] };
31
+ }
32
+ if (/INSERT INTO lifecycle_pay_term_sets/.test(sql)) {
33
+ const row = { id: sets.length + 1, task_id: params[0], set_by: params[1], set_at: new Date() };
34
+ sets.push(row);
35
+ return { rows: [row] };
36
+ }
37
+ if (/INSERT INTO lifecycle_pay_terms/.test(sql)) {
38
+ params[1].forEach((pay_type, i) => terms.push({ set_id: params[0], pay_type, position: i }));
39
+ return { rows: [] };
40
+ }
41
+ if (/FROM lifecycle_pay_term_sets s LEFT JOIN builders/.test(sql)) return { rows: [] };
42
+ return { rows: [] };
43
+ };
44
+ const pool = { query, connect: async () => ({ query, release() {} }) };
45
+ const poolPath = require.resolve('../src/bongos/pool.js');
46
+ require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool } };
47
+
48
+ let caller = { id: 7, rank: 'archon' };
49
+ let held = [];
50
+ const api = require('../src/module-api.js');
51
+ const set = (k, v) => Object.defineProperty(api, k, { value: v, configurable: true, writable: true });
52
+ set('requireBuilder', (req, _res, next) => { req.builder = { ...caller }; next(); });
53
+ set('requireRank', (rank) => (req, res, next) => (req.builder.rank === rank ? next() : res.fail('rank_forbidden', { status: 403, message: `needs ${rank}` })));
54
+ set('requirePermission', (atom) => (req, res, next) => (held.includes(atom) ? next() : res.fail('permission_denied', { status: 403, message: `needs ${atom}` })));
55
+ set('requireActAlone', () => (_req, _res, next) => next());
56
+ set('withTx', async (fn) => fn({ query }));
57
+
58
+ const db = require('../modules/lifecycle/db.js');
59
+ const TASK = { id: 5150, title: 'a task', status: 'ready', version_id: 'BONGOS-V2' };
60
+ db.getTask = async (id) => (Number(id) === TASK.id ? { ...TASK } : null);
61
+ db.getTaskDependencies = async () => [];
62
+ db.getTaskDependents = async () => [];
63
+
64
+ const lib = require('../modules/lifecycle/pay-type-gates.js');
65
+ const payTerms = require('../modules/lifecycle/pay-terms.js');
66
+
67
+ const express = require('express');
68
+ const app = express();
69
+ app.use(express.json());
70
+ app.use((req, res, next) => {
71
+ res.fail = (code, o) => {
72
+ const opts = typeof o === 'number' ? { status: o } : (o || {});
73
+ return res.status(opts.status || 400).json({ error: code, message: opts.message });
74
+ };
75
+ next();
76
+ });
77
+ app.use('/api', require('../modules/lifecycle/routes/tasks.js')());
78
+ app.use('/api', require('../modules/lifecycle/routes/project-settings.js')());
79
+ app.use('/api', require('../modules/lifecycle/routes/pay-type-gates.js')());
80
+ const server = app.listen(0);
81
+ await new Promise((r) => server.once('listening', r));
82
+ const base = `http://127.0.0.1:${server.address().port}/api`;
83
+ const call = async (method, path, body) => {
84
+ const r = await fetch(base + path, { method, headers: { 'content-type': 'application/json' }, body: body ? JSON.stringify(body) : undefined });
85
+ return { status: r.status, body: await r.json().catch(() => null) };
86
+ };
87
+ const reset = () => { gateRows = []; sets = []; terms = []; held = ['task.edit', 'project.settings.manage', 'payout.approve']; caller = { id: 7, rank: 'archon' }; };
88
+ const clear = (payType) => Promise.all(lib.requirementIds(payType).map((requirement) => call('PUT', `/pay-type-gates/${payType}/${requirement}`,
89
+ requirement === lib.ACKNOWLEDGEMENT ? { state: 'cleared', acknowledged: true } : { state: 'cleared', evidence_ref: `doc for ${requirement}` })));
90
+
91
+ let passed = 0;
92
+ let failed = 0;
93
+ async function t(name, fn) {
94
+ reset();
95
+ try { await fn(); passed++; console.log(` PASS ${name}`); }
96
+ catch (e) { failed++; console.log(` FAIL ${name}\n ${e.stack}`); }
97
+ }
98
+
99
+ console.log('pay-type gates (task 1004991):');
100
+
101
+ await t('every pay type a term can carry has a requirement list, and nothing else does', () => {
102
+ assert.deepEqual(Object.keys(lib.REQUIREMENTS).sort(), [...payTerms.PAY_TYPES].sort());
103
+ for (const p of payTerms.PAY_TYPES) assert.ok(lib.requirementIds(p).length > 0, `${p} has requirements`);
104
+ assert.deepEqual(lib.requirementIds('self_managed'), ['bongos_moves_and_guarantees_nothing']);
105
+ assert.ok(!('eu_labour' in lib.REQUIREMENTS), 'eu_labour has no list to clear');
106
+ });
107
+
108
+ await t('a pay term whose gate is not cleared is refused, and nothing is written', async () => {
109
+ const r = await call('PATCH', `/tasks/${TASK.id}`, { pay_terms: [{ pay_type: 'cash', amount: '500', unit: 'USD' }] });
110
+ assert.equal(r.status, 409);
111
+ assert.equal(r.body.error, 'pay_type_gate_not_cleared');
112
+ assert.match(r.body.message, /processor_onboarding/);
113
+ assert.deepEqual([sets.length, terms.length], [0, 0]);
114
+ });
115
+
116
+ await t('the project\'s standing terms obey the same gate', async () => {
117
+ const r = await call('PATCH', '/project-settings/pay-terms', { terms: [{ pay_type: 'equity' }] });
118
+ assert.equal(r.status, 409);
119
+ assert.equal(r.body.error, 'pay_type_gate_not_cleared');
120
+ assert.equal(sets.length, 0);
121
+ });
122
+
123
+ await t('a half-cleared gate still refuses; the last requirement opens it', async () => {
124
+ const [first, ...rest] = lib.requirementIds('cash');
125
+ assert.equal((await call('PUT', `/pay-type-gates/cash/${first}`, { state: 'cleared', evidence_ref: 'processor verdict 41' })).status, 200);
126
+ let r = await call('PATCH', `/tasks/${TASK.id}`, { pay_terms: [{ pay_type: 'cash' }] });
127
+ assert.equal(r.status, 409);
128
+ for (const req of rest) await call('PUT', `/pay-type-gates/cash/${req}`, { state: 'cleared', evidence_ref: 'on file' });
129
+ r = await call('PATCH', `/tasks/${TASK.id}`, { pay_terms: [{ pay_type: 'cash', amount: '500', unit: 'USD' }] });
130
+ assert.equal(r.status, 200);
131
+ assert.equal(terms.length, 1);
132
+ });
133
+
134
+ await t('revoking a requirement closes the gate again', async () => {
135
+ await clear('cash');
136
+ const req = lib.requirementIds('cash')[0];
137
+ assert.equal((await call('PUT', `/pay-type-gates/cash/${req}`, { state: 'revoked', evidence_ref: 'processor paused us' })).status, 200);
138
+ const r = await call('PATCH', `/tasks/${TASK.id}`, { pay_terms: [{ pay_type: 'cash' }] });
139
+ assert.equal(r.status, 409);
140
+ });
141
+
142
+ await t('self_managed clears with only the acknowledgement', async () => {
143
+ const req = lib.ACKNOWLEDGEMENT;
144
+ let r = await call('PUT', `/pay-type-gates/self_managed/${req}`, { state: 'cleared', evidence_ref: 'anything' });
145
+ assert.equal(r.status, 400, 'evidence alone is not the acknowledgement');
146
+ r = await call('PUT', `/pay-type-gates/self_managed/${req}`, { state: 'cleared', acknowledged: true });
147
+ assert.equal(r.status, 200);
148
+ assert.equal(r.body.gate.cleared, true);
149
+ assert.equal(r.body.gate.requirements[0].evidence_ref, payTerms.SELF_MANAGED_LABEL, 'the label is what was acknowledged');
150
+ const t1 = await call('PATCH', `/tasks/${TASK.id}`, { pay_terms: [{ pay_type: 'self_managed' }] });
151
+ assert.equal(t1.status, 200);
152
+ });
153
+
154
+ await t('eu_labour can never be cleared, by any route, and never reaches a term', async () => {
155
+ const w = await call('PUT', '/pay-type-gates/eu_labour/anything', { state: 'cleared', evidence_ref: 'counsel says so' });
156
+ assert.equal(w.status, 409);
157
+ assert.equal(w.body.error, 'pay_type_never_clearable');
158
+ assert.equal(gateRows.length, 0, 'no row written');
159
+ const p = await call('PATCH', `/tasks/${TASK.id}`, { pay_terms: [{ pay_type: 'eu_labour' }] });
160
+ assert.equal(p.status, 400);
161
+ assert.equal(sets.length, 0);
162
+ const all = (await call('GET', '/pay-type-gates')).body.gates.find((g) => g.pay_type === 'eu_labour');
163
+ assert.deepEqual([all.cleared, all.never], [false, true]);
164
+ assert.deepEqual(lib.gateVerdict('eu_labour', [{ pay_type: 'eu_labour', requirement: 'x', state: 'cleared' }]).cleared, false);
165
+ });
166
+
167
+ await t('a forged client request that claims a cleared gate is refused', async () => {
168
+ for (const forged of [
169
+ { pay_type: 'cash', gate_cleared: true },
170
+ { pay_type: 'cash', cleared: true, cleared_by: 7 },
171
+ { pay_type: 'cash', gate: { state: 'cleared' } },
172
+ ]) {
173
+ const r = await call('PATCH', `/tasks/${TASK.id}`, { pay_terms: [forged] });
174
+ assert.equal(r.status, 400, JSON.stringify(forged));
175
+ }
176
+ const top = await call('PATCH', `/tasks/${TASK.id}`, { gate_cleared: true, pay_terms: [{ pay_type: 'cash' }] });
177
+ assert.ok(top.status >= 400, 'a top-level claim does not open it either');
178
+ assert.deepEqual([sets.length, terms.length, gateRows.length], [0, 0, 0]);
179
+ });
180
+
181
+ await t('only the owner records a requirement, and who recorded it is the caller, not the body', async () => {
182
+ const req = lib.requirementIds('cash')[0];
183
+ caller = { id: 9, rank: 'metic' }; held = held.filter((a) => a !== 'payout.approve');
184
+ let r = await call('PUT', `/pay-type-gates/cash/${req}`, { state: 'cleared', evidence_ref: 'x' });
185
+ assert.equal(r.status, 403);
186
+ assert.equal(gateRows.length, 0);
187
+ caller = { id: 7, rank: 'archon' }; held = [...held, 'payout.approve'];
188
+ r = await call('PUT', `/pay-type-gates/cash/${req}`, { state: 'cleared', evidence_ref: 'x', decided_by: 99 });
189
+ assert.equal(r.status, 400, 'a recorder in the body is refused');
190
+ r = await call('PUT', `/pay-type-gates/cash/${req}`, { state: 'cleared', evidence_ref: 'x' });
191
+ assert.equal(r.status, 200);
192
+ assert.equal(gateRows[0].decided_by, 7);
193
+ assert.equal(r.body.gate.requirements[0].cleared_by, 7);
194
+ });
195
+
196
+ await t('a non-owner sees met or missing, never the evidence or who cleared it', async () => {
197
+ await clear('cash');
198
+ const owner = (await call('GET', '/pay-type-gates')).body.gates.find((g) => g.pay_type === 'cash');
199
+ assert.ok(owner.requirements[0].evidence_ref && owner.requirements[0].cleared_by, 'the owner sees both');
200
+ caller = { id: 9, rank: 'metic' }; held = held.filter((a) => a !== 'payout.approve');
201
+ const other = (await call('GET', '/pay-type-gates')).body.gates.find((g) => g.pay_type === 'cash');
202
+ assert.equal(other.cleared, true);
203
+ for (const r of other.requirements) {
204
+ assert.equal(r.met, true);
205
+ assert.ok(!('evidence_ref' in r) && !('cleared_by' in r) && !('cleared_at' in r));
206
+ }
207
+ });
208
+
209
+ await t('a write needs a known requirement, a state and evidence', async () => {
210
+ const req = lib.requirementIds('cash')[0];
211
+ assert.equal((await call('PUT', '/pay-type-gates/wages/x', { state: 'cleared', evidence_ref: 'x' })).status, 404);
212
+ assert.equal((await call('PUT', '/pay-type-gates/cash/not_a_requirement', { state: 'cleared', evidence_ref: 'x' })).status, 404);
213
+ assert.equal((await call('PUT', `/pay-type-gates/cash/${req}`, { state: 'maybe', evidence_ref: 'x' })).status, 400);
214
+ assert.equal((await call('PUT', `/pay-type-gates/cash/${req}`, { state: 'cleared', evidence_ref: ' ' })).status, 400);
215
+ assert.equal((await call('PUT', `/pay-type-gates/cash/${req}`, { state: 'cleared', evidence_ref: 'x'.repeat(501) })).status, 400);
216
+ assert.equal(gateRows.length, 0);
217
+ });
218
+
219
+ await t('a stray row for a requirement not on the list cannot clear a gate', () => {
220
+ const rows = [{ pay_type: 'cash', requirement: 'made_up', state: 'cleared', evidence_ref: 'x', decided_by: 1, decided_at: new Date() }];
221
+ assert.equal(lib.gateVerdict('cash', rows).cleared, false);
222
+ });
223
+
224
+ await t('no terms means no gate read; clearing every type then lets a mixed list through', async () => {
225
+ const r0 = await call('PATCH', `/tasks/${TASK.id}`, { pay_terms: [] });
226
+ assert.equal(r0.status, 200);
227
+ for (const p of payTerms.PAY_TYPES) await clear(p);
228
+ const all = await call('PATCH', `/tasks/${TASK.id}`, { pay_terms: payTerms.PAY_TYPES.map((pay_type) => ({ pay_type })) });
229
+ assert.equal(all.status, 200);
230
+ });
231
+
232
+ console.log(`\n${passed} passed, ${failed} failed`);
233
+ server.close();
234
+ process.exit(failed ? 1 : 0);
@@ -0,0 +1,84 @@
1
+ // tests/pay_type_gates_db.mjs — task 1004991 (ADR 0385 "Pay-type gates"). Real DB.
2
+ // The gate table built from the real migration (lifecycle_019) in a throwaway schema,
3
+ // written and read through the real modules/lifecycle/pay-type-gates.js, proving the
4
+ // DATABASE holds the rules the DB-free tests/pay_type_gates.mjs assumes: the history
5
+ // cannot be edited or deleted, a row for eu_labour or an unknown type is refused, a
6
+ // state or evidence the writer would never send is refused, and the read SQL resolves
7
+ // the newest record per requirement into a verdict.
8
+ // Self-skips without a reachable Postgres.
9
+ // Run: DATABASE_URL=... node tests/pay_type_gates_db.mjs
10
+ import { createRequire } from 'node:module';
11
+ import { readFileSync } from 'node:fs';
12
+ import { fileURLToPath } from 'node:url';
13
+ import { dirname, join } from 'node:path';
14
+ import { strict as assert } from 'node:assert';
15
+ const require = createRequire(import.meta.url);
16
+ const { Pool } = require('pg');
17
+ const here = dirname(fileURLToPath(import.meta.url));
18
+
19
+ const pgPool = new Pool({ connectionString: process.env.DATABASE_URL || undefined, max: 1 });
20
+ let client;
21
+ try { client = await pgPool.connect(); } catch (e) {
22
+ console.log(`pay_type_gates_db.mjs SKIPPED — no reachable Postgres (${e.code || e.message}).`);
23
+ await pgPool.end().catch(() => {});
24
+ process.exit(0);
25
+ }
26
+ const SCHEMA = `t1004991_${process.pid}`;
27
+ let pass = 0;
28
+ const ok = (c, m) => { assert.ok(c, m); pass++; console.log(` ok - ${m}`); };
29
+ const refused = async (sql, params, m) => {
30
+ await client.query('SAVEPOINT s');
31
+ let threw = false;
32
+ try { await client.query(sql, params); } catch { threw = true; }
33
+ await client.query('ROLLBACK TO SAVEPOINT s');
34
+ ok(threw, m);
35
+ };
36
+
37
+ try {
38
+ await client.query(`CREATE SCHEMA ${SCHEMA}`);
39
+ await client.query(`SET search_path TO ${SCHEMA}`);
40
+ await client.query('CREATE TABLE builders (id bigserial PRIMARY KEY, github_login text NOT NULL)');
41
+ await client.query(readFileSync(join(here, '..', 'modules', 'lifecycle', 'migrations', 'lifecycle_019_pay_type_gates.sql'), 'utf8'));
42
+ await client.query("INSERT INTO builders (github_login) VALUES ('owner'), ('lead')");
43
+ await client.query('BEGIN');
44
+
45
+ const lib = require('../modules/lifecycle/pay-type-gates.js');
46
+ const o = { db: client };
47
+ const rec = (payType, requirement, state, evidenceRef, builderId = 1) =>
48
+ lib.recordRequirement({ payType, requirement, state, evidenceRef, builderId }, o);
49
+
50
+ let v = await rec('self_managed', lib.ACKNOWLEDGEMENT, 'cleared', 'acknowledged');
51
+ ok(v.cleared === true && Number(v.requirements[0].cleared_by) === 1, 'the one self-managed requirement clears its gate, naming who cleared it');
52
+
53
+ for (const req of lib.requirementIds('cash')) v = await rec('cash', req, 'cleared', `ref ${req}`, 2);
54
+ ok(v.cleared === true, 'every cash requirement cleared opens the gate');
55
+ v = await rec('cash', 'tax_details', 'revoked', 'details withdrawn');
56
+ ok(v.cleared === false && v.missing.join() === 'tax_details', 'the newest record per requirement wins: a revoke closes it again');
57
+ v = await rec('cash', 'tax_details', 'cleared', 'collected again');
58
+ ok(v.cleared === true, 'a later clearing reopens it');
59
+
60
+ const refusal = await lib.refuseUnclearedGates([{ pay_type: 'equity' }, { pay_type: 'cash' }], o);
61
+ ok(refusal && refusal.pay_type === 'equity', 'the checker names the first type whose gate is open');
62
+ ok((await lib.refuseUnclearedGates([{ pay_type: 'cash' }, { pay_type: 'self_managed' }], o)) === null, 'cleared types pass');
63
+
64
+ const all = await lib.readGates(o);
65
+ ok(all.length === 7 && all.find((g) => g.pay_type === 'eu_labour').never === true, 'the owner view lists eu_labour as never clearable');
66
+
67
+ const { rows: [aRow] } = await client.query('SELECT id FROM lifecycle_pay_type_gates LIMIT 1');
68
+ await refused('UPDATE lifecycle_pay_type_gates SET state = $1 WHERE id = $2', ['revoked', aRow.id], 'a record cannot be edited');
69
+ await refused('DELETE FROM lifecycle_pay_type_gates WHERE id = $1', [aRow.id], 'a record cannot be deleted');
70
+ await refused("INSERT INTO lifecycle_pay_type_gates (pay_type, requirement, state, evidence_ref, decided_by) VALUES ('eu_labour', 'x', 'cleared', 'y', 1)", [], 'the database refuses any row for eu_labour');
71
+ await refused("INSERT INTO lifecycle_pay_type_gates (pay_type, requirement, state, evidence_ref, decided_by) VALUES ('wages', 'x', 'cleared', 'y', 1)", [], 'an unknown pay type is refused');
72
+ await refused("INSERT INTO lifecycle_pay_type_gates (pay_type, requirement, state, evidence_ref, decided_by) VALUES ('cash', 'x', 'pending', 'y', 1)", [], 'a state other than cleared or revoked is refused');
73
+ await refused("INSERT INTO lifecycle_pay_type_gates (pay_type, requirement, state, evidence_ref, decided_by) VALUES ('cash', 'x', 'cleared', '', 1)", [], 'a record with no evidence reference is refused');
74
+ await refused("INSERT INTO lifecycle_pay_type_gates (pay_type, requirement, state, evidence_ref, decided_by) VALUES ('cash', 'x', 'cleared', 'y', 999)", [], 'a recorder who is not a builder is refused');
75
+
76
+ await client.query('ROLLBACK');
77
+ console.log(`pay_type_gates_db: ${pass} passed`);
78
+ } finally {
79
+ await client.query('ROLLBACK').catch(() => {});
80
+ await client.query('SET search_path TO public').catch(() => {});
81
+ await client.query(`DROP SCHEMA IF EXISTS ${SCHEMA} CASCADE`).catch(() => {});
82
+ client.release();
83
+ await pgPool.end();
84
+ }