@bongos/core 1.24.14 → 1.24.16
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.
- package/.bongos-core.json +308 -138
- package/.claude/skills/blocker-review/SKILL.md +2 -0
- package/.claude/skills/session-board/SKILL.md +29 -0
- package/.claude/skills/strand-fix/SKILL.md +1 -1
- package/clients/bongos-client/README.md +1 -1
- package/clients/bongos-client/bongos-client.global.js +14 -0
- package/clients/bongos-client/index.cjs +14 -0
- package/clients/bongos-client/index.d.ts +23 -2
- package/clients/bongos-client/index.mjs +14 -0
- package/docs/adr/0385-money-lives-with-a-processor-credits-stay-a-project-meter.md +4 -0
- package/docs/api/openapi.json +369 -6
- package/docs/api-reference.md +19 -4
- package/docs/architecture.md +6 -0
- package/docs/copy-inventory.md +231 -204
- package/docs/copy-registry.json +488 -255
- package/docs/file-map.md +1 -0
- package/docs/module-api-changelog.md +4 -2
- package/docs/page-inventory.json +7 -1
- package/docs/page-readings.json +1132 -1103
- package/migrations/core_289_task_short_name.sql +34 -0
- package/modules/ai-studio/db.js +10 -0
- package/modules/ai-studio/gemini.js +73 -5
- package/modules/ai-studio/openrouter.js +17 -2
- package/modules/ai-studio/routes/ai-studio.js +7 -1
- package/modules/ai-studio/studio.js +8 -0
- package/modules/builder-settings/checkout-freshness.js +71 -0
- package/modules/builder-settings/routes/settings.js +26 -0
- package/modules/builder-settings/settings.js +2 -0
- package/modules/economy/migrations/economy_002_project_account.sql +83 -0
- package/modules/economy/module.json +1 -1
- package/modules/economy/project-account.js +166 -0
- package/modules/economy/routes/project-account.js +40 -0
- package/modules/hall-ui/public/ai-studio.css +3 -0
- package/modules/hall-ui/public/ai-studio.html +2 -1
- package/modules/hall-ui/public/ai-studio.js +72 -1
- package/modules/hall-ui/public/atlas.html +1 -1
- package/modules/hall-ui/public/blockers.html +1 -1
- package/modules/hall-ui/public/board-room.html +1 -1
- package/modules/hall-ui/public/city.html +1 -1
- package/modules/hall-ui/public/collab.html +1 -1
- package/modules/hall-ui/public/commands.html +1 -1
- package/modules/hall-ui/public/copy-desk.html +1 -1
- package/modules/hall-ui/public/deploy.html +1 -1
- package/modules/hall-ui/public/diagrams.html +1 -1
- package/modules/hall-ui/public/drachmae.html +1 -1
- package/modules/hall-ui/public/fleet.html +1 -1
- package/modules/hall-ui/public/gate.html +1 -1
- package/modules/hall-ui/public/github-chip.js +85 -0
- package/modules/hall-ui/public/goal-map.html +1 -1
- package/modules/hall-ui/public/goals.html +1 -1
- package/modules/hall-ui/public/government.html +1 -1
- package/modules/hall-ui/public/idea.html +1 -1
- package/modules/hall-ui/public/ideas.html +1 -1
- package/modules/hall-ui/public/index.html +1 -1
- package/modules/hall-ui/public/market-watch-item.html +2 -2
- package/modules/hall-ui/public/market-watch-item.js +10 -3
- package/modules/hall-ui/public/market-watch-item.states.json +41 -0
- package/modules/hall-ui/public/market-watch-lib.js +36 -5
- package/modules/hall-ui/public/market-watch-rules.html +1 -1
- package/modules/hall-ui/public/market-watch-settings.html +1 -1
- package/modules/hall-ui/public/market-watch.css +5 -0
- package/modules/hall-ui/public/market-watch.html +2 -2
- package/modules/hall-ui/public/modules.html +1 -1
- package/modules/hall-ui/public/my-agents.html +1 -1
- package/modules/hall-ui/public/my-agents.js +3 -0
- package/modules/hall-ui/public/panel.css +5 -0
- package/modules/hall-ui/public/primer.html +1 -1
- package/modules/hall-ui/public/profile.html +1 -1
- package/modules/hall-ui/public/project-settings.html +1 -1
- package/modules/hall-ui/public/ranks.html +1 -1
- package/modules/hall-ui/public/roadmap.html +1 -1
- package/modules/hall-ui/public/roster.html +1 -1
- package/modules/hall-ui/public/sessions.html +1 -1
- package/modules/hall-ui/public/settings-age.js +65 -0
- package/modules/hall-ui/public/settings.html +27 -1
- package/modules/hall-ui/public/settings.js +1 -0
- package/modules/hall-ui/public/shell.js +22 -22
- package/modules/hall-ui/public/street.html +1 -1
- package/modules/hall-ui/public/studio.html +1 -1
- package/modules/hall-ui/public/style.css +28 -0
- package/modules/hall-ui/public/task.html +1 -1
- package/modules/hall-ui/public/thinking.html +1 -1
- package/modules/hall-ui/public/tweak-editor.html +1 -1
- package/modules/hall-ui/public/watch.html +1 -1
- package/modules/hall-ui/public/work.html +1 -1
- package/modules/ideas/blockers.js +13 -7
- package/modules/lifecycle/db-claim-reads.js +1 -1
- package/modules/lifecycle/db-ship.js +2 -2
- package/modules/lifecycle/db-tasks.js +4 -4
- package/modules/lifecycle/migrations/lifecycle_019_pay_type_gates.sql +50 -0
- package/modules/lifecycle/module.json +1 -0
- package/modules/lifecycle/pay-type-gates.js +180 -0
- package/modules/lifecycle/publish-reconciler.js +31 -1
- package/modules/lifecycle/rebase-gate.js +3 -2
- package/modules/lifecycle/routes/claims.js +3 -2
- package/modules/lifecycle/routes/pay-type-gates.js +59 -0
- package/modules/lifecycle/routes/project-settings.js +4 -0
- package/modules/lifecycle/routes/task-write-routes.js +43 -0
- package/modules/lifecycle/ship-card.js +4 -1
- package/modules/lifecycle/task-name-db.js +55 -0
- package/modules/lifecycle/task-name.js +140 -0
- package/modules/market-watch/db.js +10 -1
- package/modules/market-watch/migrations/market_watch_004_entry_corrections.sql +20 -0
- package/modules/market-watch/pages.js +20 -1
- package/modules/market-watch/proposals.js +57 -7
- package/modules/market-watch/routes/pages.js +2 -0
- package/modules/market-watch/routes/rules.js +4 -0
- package/modules/platform-identity/terms-acceptance.js +9 -9
- package/modules/public-landing/public/account-google.states.json +14 -0
- package/modules/public-landing/public/assets/cosmos.css +12 -0
- package/modules/public-landing/public/assets/hub-bar.js +44 -1
- package/modules/public-landing/public/privacy.html +5 -1
- package/modules/public-landing/public/projects-google.states.json +14 -0
- package/modules/public-landing/public/projects.html +39 -0
- package/modules/public-landing/public/terms.html +12 -2
- package/modules/ui-design/kit/fixtures/me-google.json +10 -0
- package/modules/ui-design/kit/fixtures/me-sign-in-methods-google.json +4 -0
- package/modules/ui-design/kit/serve.js +7 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +55 -1
- package/scripts/bongos/claim.js +4 -2
- package/scripts/bongos/land-watch.js +1 -0
- package/scripts/bongos/report-checkout.js +30 -0
- package/scripts/bongos/run-unit-tests.js +11 -0
- package/scripts/bongos/sessions-board.js +264 -0
- package/scripts/bongos/ship-finish.js +1 -0
- package/scripts/bongos/ship-flow.js +5 -2
- package/scripts/bongos/strand-lease.js +5 -3
- package/scripts/bongos/strand-watch.js +38 -7
- package/scripts/gds/report-checkout.js +7 -0
- package/scripts/gds/sessions-board.js +7 -0
- package/src/bongos/agent-standing-rules.js +13 -3
- package/src/bongos/routes/me.js +7 -1
- package/src/module-api.js +1 -1
- package/tests/agent_standing_rules.mjs +18 -1
- package/tests/ai_studio_billing.mjs +13 -0
- package/tests/ai_studio_gemini_models.mjs +81 -0
- package/tests/ai_studio_requests.mjs +24 -0
- package/tests/blocker_detail_hydration.mjs +5 -3
- package/tests/blocker_tasks_named_db.mjs +74 -0
- package/tests/claim_gate_ci_unblock.mjs +1 -1
- package/tests/github_chip.mjs +217 -0
- package/tests/hall_ai_studio.mjs +129 -5
- package/tests/host_topology_skips.mjs +1 -0
- package/tests/hub_account_page.mjs +3 -2
- package/tests/landing_legal_pages.mjs +3 -2
- package/tests/market_watch_data_db.mjs +6 -0
- package/tests/market_watch_pages.mjs +87 -2
- package/tests/market_watch_rules.mjs +101 -5
- package/tests/market_watch_rules_db.mjs +42 -0
- package/tests/nav_permission_atoms.mjs +1 -1
- package/tests/pay_terms.mjs +8 -0
- package/tests/pay_type_gates.mjs +234 -0
- package/tests/pay_type_gates_db.mjs +84 -0
- package/tests/project_account.mjs +125 -0
- package/tests/project_account_db.mjs +97 -0
- package/tests/self_healing_start.mjs +162 -0
- package/tests/sessions_board.mjs +153 -0
- package/tests/settings_age.mjs +110 -0
- package/tests/stop_refcheck.mjs +40 -0
- package/tests/task_name.mjs +237 -0
- package/tests/task_name_db.mjs +79 -0
- package/tests/terms_acceptance.mjs +25 -3
- package/tests/terms_grandfather_db.mjs +11 -4
|
@@ -4416,6 +4416,34 @@ a.stat-tile:hover .stat-tile__sub { color: var(--ink-soft); }
|
|
|
4416
4416
|
white-space: nowrap;
|
|
4417
4417
|
}
|
|
4418
4418
|
|
|
4419
|
+
/* ---------- the Link GitHub chip (task 1004996, github-chip.js) ----------
|
|
4420
|
+
Very small and quiet on purpose: shown only to someone with no GitHub linked,
|
|
4421
|
+
it is a plain link, never a banner — ideating and art go on without it. The
|
|
4422
|
+
engineer's glyph says what it is for; the label folds away on a phone. */
|
|
4423
|
+
.gh-chip {
|
|
4424
|
+
display: inline-flex;
|
|
4425
|
+
align-items: center;
|
|
4426
|
+
gap: 5px;
|
|
4427
|
+
min-height: 24px;
|
|
4428
|
+
min-width: 24px;
|
|
4429
|
+
padding: 0 8px 0 6px;
|
|
4430
|
+
border: 1px solid var(--rule);
|
|
4431
|
+
border-radius: var(--radius-pill, 999px);
|
|
4432
|
+
color: var(--ink-soft);
|
|
4433
|
+
font-size: 12px;
|
|
4434
|
+
line-height: 1;
|
|
4435
|
+
text-decoration: none;
|
|
4436
|
+
white-space: nowrap;
|
|
4437
|
+
flex-shrink: 0;
|
|
4438
|
+
}
|
|
4439
|
+
.gh-chip:hover { color: var(--ink); border-color: var(--ink-faint); }
|
|
4440
|
+
.gh-chip:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
|
4441
|
+
.gh-chip svg { width: 13px; height: 13px; flex-shrink: 0; }
|
|
4442
|
+
@media (max-width: 640px) {
|
|
4443
|
+
.gh-chip { padding: 0; justify-content: center; }
|
|
4444
|
+
.gh-chip__label { display: none; }
|
|
4445
|
+
}
|
|
4446
|
+
|
|
4419
4447
|
.palette { position: fixed; inset: 0; z-index: 2100; }
|
|
4420
4448
|
.palette__scrim { position: absolute; inset: 0; background: var(--scrim); }
|
|
4421
4449
|
.palette__panel {
|
|
@@ -59,7 +59,7 @@
|
|
|
59
59
|
<script src="/builders/dom-utils.js?v=2026-10-25-escape"></script>
|
|
60
60
|
<script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
|
|
61
61
|
<script src="/builders/craft-mode.js?v=2026-10-30-market-watch"></script>
|
|
62
|
-
<script src="/builders/shell.js?v=2026-10-30-
|
|
62
|
+
<script src="/builders/shell.js?v=2026-10-30-github-chip"></script>
|
|
63
63
|
<!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
|
|
64
64
|
<script src="/builders/palette.js" defer></script>
|
|
65
65
|
<!-- task-detail.js still owns the #/idea/N and #/blocker/N deep-link overlay
|
|
@@ -141,7 +141,7 @@
|
|
|
141
141
|
<script src="/builders/dom-utils.js?v=2026-10-25-escape"></script>
|
|
142
142
|
<script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
|
|
143
143
|
<script src="/builders/craft-mode.js?v=2026-10-30-market-watch"></script>
|
|
144
|
-
<script src="/builders/shell.js?v=2026-10-30-
|
|
144
|
+
<script src="/builders/shell.js?v=2026-10-30-github-chip"></script>
|
|
145
145
|
<script src="/builders/palette.js" defer></script>
|
|
146
146
|
|
|
147
147
|
<script src="/builders/idea-objects.js?v=2026-09-27-split"></script>
|
|
@@ -145,7 +145,7 @@
|
|
|
145
145
|
<script src="/builders/dom-utils.js?v=2026-10-25-escape"></script>
|
|
146
146
|
<script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
|
|
147
147
|
<script src="/builders/craft-mode.js?v=2026-10-30-market-watch"></script>
|
|
148
|
-
<script src="/builders/shell.js?v=2026-10-30-
|
|
148
|
+
<script src="/builders/shell.js?v=2026-10-30-github-chip"></script>
|
|
149
149
|
<script src="/builders/palette.js" defer></script>
|
|
150
150
|
<script src="/builders/brand-holes.js?v=2026-09-29-aqfix"></script>
|
|
151
151
|
<script src="/builders/tweak-editor-lib.js?v=2026-09-29-tw27"></script>
|
|
@@ -166,7 +166,7 @@
|
|
|
166
166
|
<script src="/builders/dom-utils.js?v=2026-10-25-escape"></script>
|
|
167
167
|
<script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
|
|
168
168
|
<script src="/builders/craft-mode.js?v=2026-10-30-market-watch"></script>
|
|
169
|
-
<script src="/builders/shell.js?v=2026-10-30-
|
|
169
|
+
<script src="/builders/shell.js?v=2026-10-30-github-chip"></script>
|
|
170
170
|
<!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
|
|
171
171
|
<script src="/builders/palette.js" defer></script>
|
|
172
172
|
<!-- shared component kit (window.OTBKit) — tabs, ledger, pager (task 2166). -->
|
|
@@ -174,7 +174,7 @@
|
|
|
174
174
|
<script src="/builders/dom-utils.js?v=2026-10-25-escape"></script>
|
|
175
175
|
<script src="/builders/shell-trail.js?v=2026-10-08-city-tab"></script>
|
|
176
176
|
<script src="/builders/craft-mode.js?v=2026-10-30-market-watch"></script>
|
|
177
|
-
<script src="/builders/shell.js?v=2026-10-30-
|
|
177
|
+
<script src="/builders/shell.js?v=2026-10-30-github-chip"></script>
|
|
178
178
|
<!-- jump palette (Ctrl/Cmd-K). Optional by design: without it the shell's slot stays hidden. -->
|
|
179
179
|
<script src="/builders/palette.js" defer></script>
|
|
180
180
|
<!-- task 1004727: the ONE karma vote control every task/idea list renders -->
|
|
@@ -12,11 +12,17 @@ const { pool } = require('../../src/module-api');
|
|
|
12
12
|
// Shared by listOpenBlockers + getBlocker so the two reads can never drift
|
|
13
13
|
// (task 1002459 — getBlocker used to omit this column entirely, so a
|
|
14
14
|
// link-then-verify against the detail route read as a false negative).
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
) AS
|
|
15
|
+
// task 1004958: ONE lateral read gives both shapes — the ids (the hall reads those)
|
|
16
|
+
// and the same tasks with their 2-3 word names, so a session can write
|
|
17
|
+
// "task N - payout approvals" instead of a bare number.
|
|
18
|
+
const BLOCKING_TASK_IDS_SQL = `COALESCE(bl.ids, ARRAY[]::bigint[]) AS blocking_task_ids,
|
|
19
|
+
COALESCE(bl.tasks, '[]'::json) AS blocking_tasks`;
|
|
20
|
+
const BLOCKING_TASKS_JOIN = `LEFT JOIN LATERAL (
|
|
21
|
+
SELECT array_agg(tb.task_id ORDER BY tb.task_id) AS ids,
|
|
22
|
+
json_agg(json_build_object('id', tb.task_id, 'short_name', bt.short_name) ORDER BY tb.task_id) AS tasks
|
|
23
|
+
FROM task_blockers tb LEFT JOIN tasks bt ON bt.id = tb.task_id
|
|
24
|
+
WHERE tb.blocker_id = b.id
|
|
25
|
+
) bl ON true`;
|
|
20
26
|
|
|
21
27
|
// Optional { limit, offset } pushes the page into SQL so a small slice (the Home
|
|
22
28
|
// attention inbox reads limit=5 on every load) never scans the whole open set
|
|
@@ -32,7 +38,7 @@ async function listOpenBlockers({ limit, offset } = {}) {
|
|
|
32
38
|
`SELECT b.id, b.title, b.body_md, b.status, b.created_at,
|
|
33
39
|
b.source, b.source_ref,
|
|
34
40
|
${BLOCKING_TASK_IDS_SQL}
|
|
35
|
-
FROM blockers b
|
|
41
|
+
FROM blockers b ${BLOCKING_TASKS_JOIN}
|
|
36
42
|
WHERE b.status = 'open'
|
|
37
43
|
ORDER BY b.id${page}`,
|
|
38
44
|
params
|
|
@@ -50,7 +56,7 @@ async function getBlocker(id) {
|
|
|
50
56
|
`SELECT b.id, b.title, b.body_md, b.status, b.created_at, b.resolved_at, b.resolved_by,
|
|
51
57
|
b.resolution_note, b.source, b.source_ref,
|
|
52
58
|
${BLOCKING_TASK_IDS_SQL}
|
|
53
|
-
FROM blockers b WHERE b.id = $1`,
|
|
59
|
+
FROM blockers b ${BLOCKING_TASKS_JOIN} WHERE b.id = $1`,
|
|
54
60
|
[id]
|
|
55
61
|
);
|
|
56
62
|
return rows[0] || null;
|
|
@@ -21,7 +21,7 @@ async function getActiveClaimsForBuilder(builderId) {
|
|
|
21
21
|
`SELECT c.id AS claim_id, c.task_id, c.worktree_name, c.claimed_at,
|
|
22
22
|
c.head_sha, c.reserved_migration_number,
|
|
23
23
|
c.release_requested_by, c.release_requested_at, c.release_requested_note,
|
|
24
|
-
t.title, t.touches, t.priority, t.credits_reward, t.version_id, t.module_key,
|
|
24
|
+
t.title, t.short_name, t.touches, t.priority, t.credits_reward, t.version_id, t.module_key,
|
|
25
25
|
-- task 1003855: the craft of the claimed work. A speciality contract is
|
|
26
26
|
-- craft-scoped, so the Conductor needs to know which craft is in play
|
|
27
27
|
-- before it can pick the right one to inject.
|
|
@@ -485,7 +485,7 @@ const UNPROVABLE_STRAND_REASON = 'strand:no_branch_no_tip';
|
|
|
485
485
|
// release it.
|
|
486
486
|
async function gatingRebaseTasksForBuilder(builderId, client = pool, graceMs = UNPROVABLE_GATE_GRACE_MS) {
|
|
487
487
|
const { rows } = await client.query(
|
|
488
|
-
`SELECT id, title, needs_rebase_reason, needs_rebase_at
|
|
488
|
+
`SELECT id, title, short_name, needs_rebase_reason, needs_rebase_at
|
|
489
489
|
FROM tasks
|
|
490
490
|
WHERE needs_rebase = true AND status = 'confirmed' AND shipped_by = $1
|
|
491
491
|
AND NOT (
|
|
@@ -501,7 +501,7 @@ async function gatingRebaseTasksForBuilder(builderId, client = pool, graceMs = U
|
|
|
501
501
|
|
|
502
502
|
async function needsRebaseTasksForBuilder(builderId, client = pool) {
|
|
503
503
|
const { rows } = await client.query(
|
|
504
|
-
`SELECT id, title, needs_rebase_reason, needs_rebase_at
|
|
504
|
+
`SELECT id, title, short_name, needs_rebase_reason, needs_rebase_at
|
|
505
505
|
FROM tasks
|
|
506
506
|
WHERE needs_rebase = true AND status = 'confirmed' AND shipped_by = $1
|
|
507
507
|
ORDER BY needs_rebase_at ASC NULLS LAST, id ASC`,
|
|
@@ -24,6 +24,7 @@ const { gateOnSpecTask } = require('./goal-spec-task.js');
|
|
|
24
24
|
const { ensureArtistReviewGoal } = require('./artist-review-goal.js');
|
|
25
25
|
const { notPageMarkerSql } = require('./page-tweak-artist.js');
|
|
26
26
|
const { needsFilterWhere, capabilityFeedWhere } = require('./task-needs-gate.js');
|
|
27
|
+
const { deriveTaskName } = require('./task-name.js');
|
|
27
28
|
|
|
28
29
|
// -------------------------------------------------------------------------
|
|
29
30
|
// Tasks
|
|
@@ -270,13 +271,13 @@ async function createTask({
|
|
|
270
271
|
est_minutes, est_cost_usd, manual_degree, priority,
|
|
271
272
|
automation_tag, credits_reward, source, source_ref, value_summary,
|
|
272
273
|
kind, discipline, parallel_safe, newcomer_friendly, needs_migration,
|
|
273
|
-
security_sensitive, requires_rank, module_key, goal_id, created_by, promoted_at, needs)
|
|
274
|
+
security_sensitive, requires_rank, module_key, goal_id, created_by, promoted_at, needs, short_name)
|
|
274
275
|
VALUES ($1, $2, $3, $4, $5,
|
|
275
276
|
$6, $7, $8, $9,
|
|
276
277
|
$10, $11, $12, $13, $14,
|
|
277
278
|
$15, $16, $17, $18, $19,
|
|
278
279
|
$20, $21, $22, $23, $24,
|
|
279
|
-
CASE WHEN $4 IN ('ready','active') THEN now() ELSE NULL END, $25)
|
|
280
|
+
CASE WHEN $4 IN ('ready','active') THEN now() ELSE NULL END, $25, $26)
|
|
280
281
|
RETURNING *`,
|
|
281
282
|
[
|
|
282
283
|
versionId,
|
|
@@ -303,7 +304,7 @@ async function createTask({
|
|
|
303
304
|
moduleKey ?? null,
|
|
304
305
|
goalIdEffective ?? null,
|
|
305
306
|
createdBy ?? null,
|
|
306
|
-
Array.isArray(needs) ? needs : [],
|
|
307
|
+
Array.isArray(needs) ? needs : [], deriveTaskName(title, description), // $26: the short name (task 1004958)
|
|
307
308
|
]
|
|
308
309
|
);
|
|
309
310
|
// task 1677 / ADR 0096: audit each creation-time auto-assignment so the trail is
|
|
@@ -486,7 +487,6 @@ async function updateTaskPriority(id, priority) { return updateTaskColumn(id, 'p
|
|
|
486
487
|
// keeps on every other value_summary write.
|
|
487
488
|
async function updateTaskValueSummary(id, valueSummary) { return updateTaskColumn(id, 'value_summary', valueSummary); }
|
|
488
489
|
|
|
489
|
-
|
|
490
490
|
async function updateTaskDiscipline(id, discipline) { return updateTaskColumn(id, 'discipline', discipline); }
|
|
491
491
|
|
|
492
492
|
// #360 / ADR 0018 + V3.R15 #239: open (or close) a task to Xenos claims by
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
-- lifecycle_019_pay_type_gates.sql — task 1004991 (goal 1000092; ADR 0385, "Pay-type
|
|
2
|
+
-- gates").
|
|
3
|
+
--
|
|
4
|
+
-- A pay type becomes switchable only after its gate is cleared and recorded. A
|
|
5
|
+
-- gate is a short list of requirements per pay type (the list lives in
|
|
6
|
+
-- modules/lifecycle/pay-type-gates.js, taken from the ADR's table); this table
|
|
7
|
+
-- records, for each requirement, who said it is met, when, and the evidence they
|
|
8
|
+
-- point at. The gate is cleared when every requirement's NEWEST row says 'cleared'.
|
|
9
|
+
-- The instance is the project, so there is no project column: one database, one
|
|
10
|
+
-- project.
|
|
11
|
+
--
|
|
12
|
+
-- A LIFECYCLE MIGRATION, NOT A CORE ONE: a brand-new table the lifecycle module
|
|
13
|
+
-- owns (pay-type-gates.js is its only reader and writer), the lifecycle_018_pay_terms
|
|
14
|
+
-- precedent.
|
|
15
|
+
--
|
|
16
|
+
-- RECORDS ONLY. Nothing here moves money. Clearing a requirement is a person's act
|
|
17
|
+
-- (often with counsel) and the evidence column holds only a REFERENCE to it (a
|
|
18
|
+
-- document title, a ticket, a processor verdict id) — never identity, tax or KYC
|
|
19
|
+
-- data, which Bongos does not store.
|
|
20
|
+
--
|
|
21
|
+
-- APPEND-ONLY. Every clearing or revoking is a new row; the newest row per
|
|
22
|
+
-- (pay_type, requirement) is current, and a row trigger refuses UPDATE and DELETE,
|
|
23
|
+
-- so the table is its own audit trail. The database also refuses outright any row for
|
|
24
|
+
-- eu_labour: until a later ADR enables it, no code path, however forged, can clear it.
|
|
25
|
+
|
|
26
|
+
CREATE TABLE IF NOT EXISTS lifecycle_pay_type_gates (
|
|
27
|
+
id bigserial PRIMARY KEY,
|
|
28
|
+
pay_type text NOT NULL CHECK (pay_type IN
|
|
29
|
+
('cash', 'equity', 'module_share', 'credit_pool', 'non_monetary', 'self_managed')),
|
|
30
|
+
requirement text NOT NULL CHECK (char_length(requirement) BETWEEN 1 AND 64),
|
|
31
|
+
state text NOT NULL CHECK (state IN ('cleared', 'revoked')),
|
|
32
|
+
evidence_ref text NOT NULL CHECK (char_length(evidence_ref) BETWEEN 1 AND 500),
|
|
33
|
+
decided_by bigint NOT NULL REFERENCES builders(id),
|
|
34
|
+
decided_at timestamptz NOT NULL DEFAULT now()
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
-- The current state of a requirement: newest first.
|
|
38
|
+
CREATE INDEX IF NOT EXISTS lifecycle_pay_type_gates_current
|
|
39
|
+
ON lifecycle_pay_type_gates (pay_type, requirement, decided_at DESC, id DESC);
|
|
40
|
+
|
|
41
|
+
CREATE OR REPLACE FUNCTION lifecycle_pay_type_gates_refuse_change() RETURNS trigger AS $$
|
|
42
|
+
BEGIN
|
|
43
|
+
RAISE EXCEPTION 'pay-type gates are a history: % on % is refused; record a new row instead', TG_OP, TG_TABLE_NAME
|
|
44
|
+
USING ERRCODE = 'check_violation';
|
|
45
|
+
END;
|
|
46
|
+
$$ LANGUAGE plpgsql;
|
|
47
|
+
|
|
48
|
+
DROP TRIGGER IF EXISTS lifecycle_pay_type_gates_append_only ON lifecycle_pay_type_gates;
|
|
49
|
+
CREATE TRIGGER lifecycle_pay_type_gates_append_only BEFORE UPDATE OR DELETE ON lifecycle_pay_type_gates
|
|
50
|
+
FOR EACH ROW EXECUTE FUNCTION lifecycle_pay_type_gates_refuse_change();
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// modules/lifecycle/pay-type-gates.js — a pay type may be put on a task only after its
|
|
2
|
+
// gate is cleared for the project (task 1004991, goal 1000092; ADR 0385 "Pay-type gates").
|
|
3
|
+
//
|
|
4
|
+
// A gate is a fixed list of requirements per pay type, taken from the ADR's table and
|
|
5
|
+
// written down here as data. A person records each requirement as cleared (with a
|
|
6
|
+
// reference to the evidence) or revoked; the gate is cleared when every requirement's
|
|
7
|
+
// newest record says cleared. The pay-terms write paths ask `refuseUnclearedGates`
|
|
8
|
+
// before they write, so the server refuses a pay term whose type has an open gate.
|
|
9
|
+
//
|
|
10
|
+
// FOUR RULES THIS FILE HOLDS:
|
|
11
|
+
// 1. The list is data, not prose: REQUIREMENTS is the one place the ADR's table lives
|
|
12
|
+
// in code, and the table pins it against the pay-type list pay-terms.js accepts.
|
|
13
|
+
// 2. eu_labour can never be cleared. It is not in any list a write accepts and the
|
|
14
|
+
// table's CHECK refuses a row for it, so no request, however forged, reaches a
|
|
15
|
+
// cleared state (a later ADR enables it, per ADR 0385's EU labour row).
|
|
16
|
+
// 3. self_managed clears with one act, the acknowledgement that Bongos moves and
|
|
17
|
+
// guarantees nothing. Nothing else is asked of it.
|
|
18
|
+
// 4. Records only. Evidence is a REFERENCE a person typed; Bongos stores no identity,
|
|
19
|
+
// tax or KYC data here, and nothing in this file moves money.
|
|
20
|
+
//
|
|
21
|
+
// Who may record a requirement is decided by the ROUTE (the project owner, on the
|
|
22
|
+
// server — ADR 0016); who recorded it comes from the signed-in builder, never the body.
|
|
23
|
+
|
|
24
|
+
const api = require('../../src/module-api');
|
|
25
|
+
const { PAY_TYPES, SELF_MANAGED_LABEL } = require('./pay-terms');
|
|
26
|
+
|
|
27
|
+
const NEVER_CLEARABLE = ['eu_labour'];
|
|
28
|
+
const ACKNOWLEDGEMENT = 'bongos_moves_and_guarantees_nothing';
|
|
29
|
+
const EVIDENCE_MAX = 500;
|
|
30
|
+
|
|
31
|
+
// The machine-checkable list per pay type (ADR 0385, "Must exist first").
|
|
32
|
+
const REQUIREMENTS = {
|
|
33
|
+
cash: [
|
|
34
|
+
['processor_onboarding', 'Processor onboarding done'],
|
|
35
|
+
['tax_details', 'Tax details collected'],
|
|
36
|
+
['age_check', 'Age check passed'],
|
|
37
|
+
['sanctions_screening', 'Sanctions screening done'],
|
|
38
|
+
['signed_rule', 'Signed pay rule on file'],
|
|
39
|
+
],
|
|
40
|
+
equity: [
|
|
41
|
+
['counsel_approved_structure', 'The project\'s own counsel-approved structure'],
|
|
42
|
+
['one_signature_per_grant', 'One signature per grant'],
|
|
43
|
+
['no_percentage_fee', 'No percentage fee taken'],
|
|
44
|
+
['wording_filter', 'Credit and valuation wording filter in force'],
|
|
45
|
+
],
|
|
46
|
+
module_share: [
|
|
47
|
+
['standard_module_licence', 'Standard module licence'],
|
|
48
|
+
['takedown_contact', 'Takedown contact named'],
|
|
49
|
+
['payee_onboarding', 'Payee onboarding done'],
|
|
50
|
+
],
|
|
51
|
+
credit_pool: [
|
|
52
|
+
['credit_wording_in_force', 'Credit wording rule (ADR 0385 decision 9) in force'],
|
|
53
|
+
['model_inputs_visible', 'Model inputs visible to the people affected'],
|
|
54
|
+
['signed_rule', 'Signed pay rule on file'],
|
|
55
|
+
],
|
|
56
|
+
non_monetary: [
|
|
57
|
+
['guardian_consent_13_15', 'Guardian consent for ages 13 to 15'],
|
|
58
|
+
['no_cash_redemption', 'No cash redemption of any credit'],
|
|
59
|
+
],
|
|
60
|
+
self_managed: [
|
|
61
|
+
[ACKNOWLEDGEMENT, 'Acknowledged: Bongos moves and guarantees nothing'],
|
|
62
|
+
],
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
function requirementIds(payType) {
|
|
66
|
+
return (REQUIREMENTS[payType] || []).map(([id]) => id);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// PURE. The verdict for one pay type, from its current rows ({ requirement, state,
|
|
70
|
+
// evidence_ref, decided_by, decided_at }, newest per requirement). A requirement with
|
|
71
|
+
// no row, or whose newest row is revoked, is missing. Rows for requirements not in the
|
|
72
|
+
// list are ignored, so a stray row can never clear a gate.
|
|
73
|
+
function gateVerdict(payType, currentRows) {
|
|
74
|
+
if (NEVER_CLEARABLE.includes(payType)) {
|
|
75
|
+
return { pay_type: payType, cleared: false, never: true, requirements: [], missing: [] };
|
|
76
|
+
}
|
|
77
|
+
const byReq = new Map((currentRows || []).filter((r) => r.pay_type == null || r.pay_type === payType).map((r) => [r.requirement, r]));
|
|
78
|
+
const requirements = (REQUIREMENTS[payType] || []).map(([id, label]) => {
|
|
79
|
+
const r = byReq.get(id);
|
|
80
|
+
const met = !!r && r.state === 'cleared';
|
|
81
|
+
return {
|
|
82
|
+
requirement: id,
|
|
83
|
+
label,
|
|
84
|
+
met,
|
|
85
|
+
evidence_ref: r ? r.evidence_ref : null,
|
|
86
|
+
cleared_by: met ? r.decided_by : null,
|
|
87
|
+
cleared_at: met ? r.decided_at : null,
|
|
88
|
+
};
|
|
89
|
+
});
|
|
90
|
+
const missing = requirements.filter((x) => !x.met).map((x) => x.requirement);
|
|
91
|
+
return { pay_type: payType, cleared: requirements.length > 0 && missing.length === 0, never: false, requirements, missing };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// The newest row per (pay_type, requirement) for these pay types, one read.
|
|
95
|
+
async function readCurrentRows(payTypes, { db } = {}) {
|
|
96
|
+
const exec = db || api.pool;
|
|
97
|
+
const { rows } = await exec.query(
|
|
98
|
+
`SELECT DISTINCT ON (pay_type, requirement) pay_type, requirement, state, evidence_ref, decided_by, decided_at
|
|
99
|
+
FROM lifecycle_pay_type_gates
|
|
100
|
+
WHERE pay_type = ANY($1::text[])
|
|
101
|
+
ORDER BY pay_type, requirement, decided_at DESC, id DESC`, [payTypes]);
|
|
102
|
+
return rows;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// Every pay type's verdict, for the owner's view (and the settings page).
|
|
106
|
+
async function readGates({ db } = {}) {
|
|
107
|
+
const rows = await readCurrentRows(PAY_TYPES, { db });
|
|
108
|
+
const gates = PAY_TYPES.map((p) => gateVerdict(p, rows.filter((r) => r.pay_type === p)));
|
|
109
|
+
for (const p of NEVER_CLEARABLE) gates.push(gateVerdict(p, []));
|
|
110
|
+
return gates;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// PURE. A verdict as a non-owner may see it: met or missing per requirement, without the
|
|
114
|
+
// evidence reference a person typed or who cleared it (those are the owner's own records).
|
|
115
|
+
function withoutEvidence(gate) {
|
|
116
|
+
return { ...gate, requirements: gate.requirements.map(({ evidence_ref, cleared_by, cleared_at, ...rest }) => rest) };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Checks a list of normalized terms against the gates. Returns null when every type
|
|
120
|
+
// in it is cleared, else { code, message, pay_type, missing } for the first that is
|
|
121
|
+
// not. One read however many terms there are, and none for an empty list.
|
|
122
|
+
async function refuseUnclearedGates(terms, { db } = {}) {
|
|
123
|
+
const types = [...new Set((terms || []).map((t) => t.pay_type))];
|
|
124
|
+
if (!types.length) return null;
|
|
125
|
+
const rows = await readCurrentRows(types, { db });
|
|
126
|
+
for (const payType of types) {
|
|
127
|
+
const v = gateVerdict(payType, rows.filter((r) => r.pay_type === payType));
|
|
128
|
+
if (!v.cleared) {
|
|
129
|
+
return {
|
|
130
|
+
code: 'pay_type_gate_not_cleared',
|
|
131
|
+
pay_type: payType,
|
|
132
|
+
missing: v.missing,
|
|
133
|
+
message: `The ${payType} pay type is not switched on for this project: its gate is not cleared (still needed: ${v.missing.join(', ')}).`,
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// PURE. Checks a request body to record one requirement. Returns { state, evidenceRef }
|
|
141
|
+
// or { status, code, error }. The recorder is never read from the body.
|
|
142
|
+
function normalizeGateWrite(payType, requirement, body) {
|
|
143
|
+
if (NEVER_CLEARABLE.includes(payType)) {
|
|
144
|
+
return { status: 409, code: 'pay_type_never_clearable', error: `${payType} cannot be cleared; it stays off until a later decision enables it.` };
|
|
145
|
+
}
|
|
146
|
+
if (!PAY_TYPES.includes(payType)) return { status: 404, code: 'unknown_pay_type', error: `pay type must be one of ${PAY_TYPES.join(', ')}.` };
|
|
147
|
+
if (!requirementIds(payType).includes(requirement)) {
|
|
148
|
+
return { status: 404, code: 'unknown_requirement', error: `${payType} has no such requirement; it has ${requirementIds(payType).join(', ')}.` };
|
|
149
|
+
}
|
|
150
|
+
const b = body && typeof body === 'object' && !Array.isArray(body) ? body : {};
|
|
151
|
+
const extra = Object.keys(b).filter((k) => !['state', 'evidence_ref', 'acknowledged'].includes(k));
|
|
152
|
+
if (extra.length) return { status: 400, code: 'unknown_field', error: `unknown field: ${extra.join(', ')}.` };
|
|
153
|
+
if (b.state !== 'cleared' && b.state !== 'revoked') return { status: 400, code: 'bad_state', error: 'state is "cleared" or "revoked".' };
|
|
154
|
+
if (requirement === ACKNOWLEDGEMENT && b.state === 'cleared') {
|
|
155
|
+
if (b.acknowledged !== true) {
|
|
156
|
+
return { status: 400, code: 'acknowledgement_required', error: `Send acknowledged: true. It records: ${SELF_MANAGED_LABEL}` };
|
|
157
|
+
}
|
|
158
|
+
return { state: 'cleared', evidenceRef: SELF_MANAGED_LABEL };
|
|
159
|
+
}
|
|
160
|
+
const ev = typeof b.evidence_ref === 'string' ? b.evidence_ref.trim() : '';
|
|
161
|
+
if (!ev || ev.length > EVIDENCE_MAX) {
|
|
162
|
+
return { status: 400, code: 'bad_evidence_ref', error: `evidence_ref is a reference to the evidence (or the reason, for a revoke), 1 to ${EVIDENCE_MAX} characters.` };
|
|
163
|
+
}
|
|
164
|
+
return { state: b.state, evidenceRef: ev };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Appends one record. Returns the gate's new verdict for that pay type.
|
|
168
|
+
async function recordRequirement({ payType, requirement, state, evidenceRef, builderId }, { db } = {}) {
|
|
169
|
+
const exec = db || api.pool;
|
|
170
|
+
await exec.query(
|
|
171
|
+
`INSERT INTO lifecycle_pay_type_gates (pay_type, requirement, state, evidence_ref, decided_by)
|
|
172
|
+
VALUES ($1, $2, $3, $4, $5)`, [payType, requirement, state, evidenceRef, builderId]);
|
|
173
|
+
const rows = await readCurrentRows([payType], { db: exec });
|
|
174
|
+
return gateVerdict(payType, rows);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
module.exports = {
|
|
178
|
+
REQUIREMENTS, ACKNOWLEDGEMENT,
|
|
179
|
+
requirementIds, gateVerdict, readGates, refuseUnclearedGates, normalizeGateWrite, recordRequirement, withoutEvidence,
|
|
180
|
+
};
|
|
@@ -981,6 +981,34 @@ async function runCraftAchievementBackfill(deps = {}) {
|
|
|
981
981
|
}
|
|
982
982
|
}
|
|
983
983
|
|
|
984
|
+
// task 1004958: name the tasks that have no short name yet — every task created
|
|
985
|
+
// before names existed, and any row a path outside createTask inserted. Pure DB,
|
|
986
|
+
// idempotent (it writes only `short_name IS NULL` rows), and bounded: at most
|
|
987
|
+
// TASK_NAME_BATCHES batches a run, so the first run on a big board drains it in
|
|
988
|
+
// one go and every later run is one indexed read that finds next to nothing.
|
|
989
|
+
const TASK_NAME_BATCHES = 20;
|
|
990
|
+
async function runTaskNameBackfill(deps = {}) {
|
|
991
|
+
const { logFn, errFn } = loggers(deps);
|
|
992
|
+
const tasksDb = (deps.tasksDb && typeof deps.tasksDb.backfillTaskNames === 'function')
|
|
993
|
+
? deps.tasksDb
|
|
994
|
+
: require('./task-name-db.js');
|
|
995
|
+
const exec = deps.pool || seams.pool;
|
|
996
|
+
let named = 0;
|
|
997
|
+
try {
|
|
998
|
+
for (let i = 0, after = 0; i < TASK_NAME_BATCHES; i++) {
|
|
999
|
+
const out = await tasksDb.backfillTaskNames({ exec, after });
|
|
1000
|
+
named += out.named;
|
|
1001
|
+
after = out.next;
|
|
1002
|
+
if (!out.scanned) break;
|
|
1003
|
+
}
|
|
1004
|
+
if (named) logFn(`[publish-reconciler] task names: ${named} task(s) named`);
|
|
1005
|
+
return { named };
|
|
1006
|
+
} catch (e) {
|
|
1007
|
+
errFn(`[publish-reconciler] task name backfill (non-blocking): ${e && e.message}`);
|
|
1008
|
+
return { skipped: 'error', error: e && e.message, named };
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
|
|
984
1012
|
// The sweeps one tick fires. Each is INDEPENDENT on purpose: the cost (task 1366)
|
|
985
1013
|
// and criterion (task 1002069) sweeps are pure DB — no push credential — and a
|
|
986
1014
|
// failure in either must never touch the confirmed→shipped work.
|
|
@@ -989,7 +1017,7 @@ async function runCraftAchievementBackfill(deps = {}) {
|
|
|
989
1017
|
// between runs; none means every tick (INTERVAL_MS). The publish, cost and
|
|
990
1018
|
// criterion sweeps keep the tick — a strand, an unpriced session and a newly
|
|
991
1019
|
// satisfiable criterion are all things someone is waiting on. The promotion sweep
|
|
992
|
-
// and the craft
|
|
1020
|
+
// and the craft and task-name backfills run HOURLY: the ship-time triggers already promote and
|
|
993
1021
|
// unlock on the event, so these are backlog drains, and a drain an hour late
|
|
994
1022
|
// costs nobody anything while running it every 5 minutes cost every instance
|
|
995
1023
|
// ~288 scans a day.
|
|
@@ -1000,6 +1028,7 @@ const SWEEPS = [
|
|
|
1000
1028
|
['criterion sweep', runCriterionSweep],
|
|
1001
1029
|
['promotion sweep', runPromotionSweep, HOURLY_MS],
|
|
1002
1030
|
['craft achievement backfill', runCraftAchievementBackfill, HOURLY_MS],
|
|
1031
|
+
['task name backfill', runTaskNameBackfill, HOURLY_MS],
|
|
1003
1032
|
];
|
|
1004
1033
|
|
|
1005
1034
|
// buildTick — one interval round, with a per-sweep overlap guard (task 1004924).
|
|
@@ -1083,6 +1112,7 @@ module.exports = {
|
|
|
1083
1112
|
runPromotionSweep,
|
|
1084
1113
|
runCriterionSweep,
|
|
1085
1114
|
runCraftAchievementBackfill,
|
|
1115
|
+
runTaskNameBackfill,
|
|
1086
1116
|
buildTick,
|
|
1087
1117
|
couldBeStaleGenerated,
|
|
1088
1118
|
describeTarget,
|
|
@@ -70,8 +70,9 @@ function splitRebaseGate(rows, { limit = gateLimit() } = {}) {
|
|
|
70
70
|
// the hint and the CLI can say "waiting, not on you".
|
|
71
71
|
function rebaseRefusalTasks(split) {
|
|
72
72
|
return [
|
|
73
|
-
|
|
74
|
-
...split.
|
|
73
|
+
// task 1004958: short_name rides along so every surface can write "task N - name".
|
|
74
|
+
...split.gating.map((t) => ({ id: t.id, short_name: t.short_name || null, title: t.title, reason: t.needs_rebase_reason })),
|
|
75
|
+
...split.waiting.map((t) => ({ id: t.id, short_name: t.short_name || null, title: t.title, reason: t.needs_rebase_reason, waiting: true, waits_on: t.waits_on })),
|
|
75
76
|
];
|
|
76
77
|
}
|
|
77
78
|
|
|
@@ -100,12 +100,13 @@ function taskNotReadyHint(taskId, currentStatus) {
|
|
|
100
100
|
// task 1004788: only the FIXABLE tasks gate (rebase-gate.js); a task set aside
|
|
101
101
|
// because it waits on something outside the builder is listed after them as
|
|
102
102
|
// "waiting, not on you" so nobody chases it.
|
|
103
|
+
const { taskRef } = require('../task-name.js'); // task 1004958
|
|
103
104
|
function rebaseRequiredHint(rebaseTasks) {
|
|
104
105
|
const all = rebaseTasks || [];
|
|
105
|
-
const ids = all.filter((t) => !t.waiting).map((t) =>
|
|
106
|
+
const ids = all.filter((t) => !t.waiting).map((t) => taskRef(t.id, t.short_name));
|
|
106
107
|
const waiting = all.filter((t) => t.waiting);
|
|
107
108
|
const aside = waiting.length
|
|
108
|
-
? ` Waiting, not on you (set aside; they land on their own when what they wait on clears): ${waiting.map((t) =>
|
|
109
|
+
? ` Waiting, not on you (set aside; they land on their own when what they wait on clears): ${waiting.map((t) => `${taskRef(t.id, t.short_name)} (${t.waits_on || 'outside you'})`).join('; ')}.`
|
|
109
110
|
: '';
|
|
110
111
|
return `Your queue is gated: ${ids.length} confirmed task(s) of yours are waiting to land and need you (${ids.join(', ') || 'unknown'}).${aside} Clear it with /strand-fix N (works on any machine), or bring the branch up to date with \`git merge origin/main\` and re-ship — MERGE, never rebase: server-mediated publish is append-only, and rewriting a published branch makes it un-pushable (task 1002788). /merge-mode only if this machine has a GitHub push credential. To give up on one instead, /builder-release it. If CI is simply backed up, waiting is correct: a task already landed clears the gate on its own. If main's CI is red for a reason outside your branch, the fix is still claimable: a P1 kind=infra task passes this gate (task 1004370).`;
|
|
111
112
|
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// modules/lifecycle/routes/pay-type-gates.js — read, and record, the gate each pay type
|
|
4
|
+
// must clear before it can be put on a task (task 1004991, goal 1000092; ADR 0385
|
|
5
|
+
// "Pay-type gates"). The rules live in ../pay-type-gates.js; this file is the doorway.
|
|
6
|
+
//
|
|
7
|
+
// GET /pay-type-gates — every pay type's gate: each requirement, whether it is met, the
|
|
8
|
+
// evidence reference, and who cleared it and when. eu_labour is listed as never clearable.
|
|
9
|
+
// PUT /pay-type-gates/:payType/:requirement — body { state: 'cleared'|'revoked',
|
|
10
|
+
// evidence_ref } (or { state: 'cleared', acknowledged: true } for the self-managed
|
|
11
|
+
// acknowledgement). Appends one record; the recorder is the signed-in owner, never the
|
|
12
|
+
// body. Clearing a real gate is a person's act, so nothing here clears one by itself.
|
|
13
|
+
//
|
|
14
|
+
// MODULE BOUNDARY: reaches core only through `../../../src/module-api`.
|
|
15
|
+
|
|
16
|
+
const express = require('express');
|
|
17
|
+
const api = require('../../../src/module-api');
|
|
18
|
+
const gates = require('../pay-type-gates');
|
|
19
|
+
|
|
20
|
+
const log = api.logger('lifecycle');
|
|
21
|
+
|
|
22
|
+
module.exports = function buildPayTypeGatesRouter() {
|
|
23
|
+
const router = express.Router();
|
|
24
|
+
|
|
25
|
+
// rank: any-builder for met/missing (the same pay information `GET /pay-terms` shows, no person's
|
|
26
|
+
// money); the evidence references and who cleared each requirement are the owner's records, so
|
|
27
|
+
// they come back for an archon only.
|
|
28
|
+
router.get('/pay-type-gates', api.requireBuilder, async (req, res) => {
|
|
29
|
+
try {
|
|
30
|
+
const all = await gates.readGates();
|
|
31
|
+
res.json({ gates: req.builder.rank === 'archon' ? all : all.map(gates.withoutEvidence) });
|
|
32
|
+
} catch (err) {
|
|
33
|
+
log.error(`GET /pay-type-gates: ${err && err.message}`);
|
|
34
|
+
res.fail('pay_type_gates_read_failed', { status: 500, message: 'internal error' });
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
// rank: payout.approve (archon floor). Whether a pay type is legal for this project is the
|
|
39
|
+
// owner's call, often with counsel, and it is the same authority that approves payouts — no new
|
|
40
|
+
// atom is minted for it (ADR 0016: enforced here, on the server).
|
|
41
|
+
router.put('/pay-type-gates/:payType/:requirement', api.requireBuilder, api.requirePermission('payout.approve'), async (req, res) => {
|
|
42
|
+
const { payType, requirement } = req.params;
|
|
43
|
+
// Unknown fields (a forged cleared_by, say) are refused here; the values are checked by the lib.
|
|
44
|
+
if (api.validateOrRespond(req, res, { state: {}, evidence_ref: {}, acknowledged: {} })) return;
|
|
45
|
+
const parsed = gates.normalizeGateWrite(payType, requirement, req.body);
|
|
46
|
+
if (parsed.error) return res.fail(parsed.code, { status: parsed.status, message: parsed.error });
|
|
47
|
+
try {
|
|
48
|
+
const gate = await gates.recordRequirement({
|
|
49
|
+
payType, requirement, state: parsed.state, evidenceRef: parsed.evidenceRef, builderId: req.builder.id,
|
|
50
|
+
});
|
|
51
|
+
res.json({ ok: true, gate });
|
|
52
|
+
} catch (err) {
|
|
53
|
+
log.error(`PUT /pay-type-gates: ${err && err.message}`);
|
|
54
|
+
res.fail('pay_type_gates_write_failed', { status: 500, message: 'internal error' });
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
return router;
|
|
59
|
+
};
|
|
@@ -35,6 +35,7 @@ const api = require('../../../src/module-api');
|
|
|
35
35
|
const goalCategory = require('../goal-category');
|
|
36
36
|
const autopickup = require('../autopickup');
|
|
37
37
|
const payTermsLib = require('../pay-terms'); // task 1004990
|
|
38
|
+
const payTypeGates = require('../pay-type-gates'); // task 1004991
|
|
38
39
|
|
|
39
40
|
const log = api.logger('lifecycle');
|
|
40
41
|
|
|
@@ -551,6 +552,9 @@ module.exports = function buildProjectSettingsRouter() {
|
|
|
551
552
|
const parsed = payTermsLib.normalizePayTerms(req.body.terms);
|
|
552
553
|
if (parsed.error) return res.fail('bad_pay_terms', { status: 400, message: parsed.error });
|
|
553
554
|
try {
|
|
555
|
+
// task 1004991: the project's standing terms obey the same gate as a task's (ADR 0385).
|
|
556
|
+
const gateRefusal = await payTypeGates.refuseUnclearedGates(parsed.terms);
|
|
557
|
+
if (gateRefusal) return res.fail(gateRefusal.code, { status: 409, message: gateRefusal.message });
|
|
554
558
|
await payTermsLib.setPayTerms({ taskId: null, terms: parsed.terms, builderId: req.builder.id });
|
|
555
559
|
const policy = await payTermsLib.readProjectPayTerms();
|
|
556
560
|
res.json({ ok: true, row: payTermsRow(policy) });
|