@bongos/core 1.19.316 → 1.19.317
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 +91 -26
- package/modules/lifecycle/db-analytics.js +719 -0
- package/modules/lifecycle/db-claim-reads.js +165 -0
- package/modules/lifecycle/db-claims.js +934 -0
- package/modules/lifecycle/db-deps-criteria.js +441 -0
- package/modules/lifecycle/db-goals.js +763 -0
- package/modules/lifecycle/db-grade.js +233 -0
- package/modules/lifecycle/db-overrides.js +181 -0
- package/modules/lifecycle/db-rank-authz.js +446 -0
- package/modules/lifecycle/db-shared.js +115 -0
- package/modules/lifecycle/db-ship.js +994 -0
- package/modules/lifecycle/db-tasks.js +810 -0
- package/modules/lifecycle/db-versions.js +118 -0
- package/modules/lifecycle/db.js +167 -5564
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/coreB-carve-proof.sh +18 -9
- package/scripts/gds/fitness.js +34 -26
- package/scripts/gds/identity-strip.js +75 -0
- package/scripts/gds/provision.js +7 -3
- package/src/module-api.js +3 -1
- package/tests/auto_satisfy_criteria.mjs +3 -2
- package/tests/dependencies_migration.mjs +3 -2
- package/tests/fitness.mjs +43 -0
- package/tests/goal_abuse_matrix.mjs +3 -2
- package/tests/goal_advisory.mjs +3 -2
- package/tests/government_claim_eligibility.mjs +3 -1
- package/tests/government_rank_sync.mjs +9 -2
- package/tests/helpers.mjs +19 -0
- package/tests/migration_allocation.mjs +3 -1
- package/tests/publish_reconciler.mjs +3 -1
- package/tests/rank_gate.mjs +3 -1
- package/tests/reward_gate.mjs +3 -1
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
// modules/lifecycle/db-grade.js — applyGrade — writes the grader result and promotes completed→confirmed.
|
|
2
|
+
//
|
|
3
|
+
// Carved out of modules/lifecycle/db.js by task 1003204 (goal 1000079, the R03
|
|
4
|
+
// de-monolith plan) — a STRUCTURAL move, not a rewrite: the code below is the
|
|
5
|
+
// original db.js lines 4561-4773 unchanged. db.js remains the facade that
|
|
6
|
+
// re-exports every name, so every existing db call site is unchanged.
|
|
7
|
+
//
|
|
8
|
+
// THE BOUNDARY (ADR 0093 §1): this file reaches core ONLY through the published
|
|
9
|
+
// doorway ../../src/module-api.js — never a core internal, never a sibling module.
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const api = require('../../src/module-api');
|
|
14
|
+
const { pool, withTx } = api;
|
|
15
|
+
const seams = api;
|
|
16
|
+
const { logGradeAttempt } = require('./db-analytics.js');
|
|
17
|
+
const { mostRecentClaimHolderId } = require('./db-shared.js');
|
|
18
|
+
|
|
19
|
+
// applyGrade — V3.R18 (#209). Writes the subagent grader result to task_grades
|
|
20
|
+
// and, if the grade passed, promotes completed→confirmed + awards credits.
|
|
21
|
+
//
|
|
22
|
+
// Inputs (the route handler validates + scores the subagent JSON before
|
|
23
|
+
// calling this, so we accept a pre-built grade-result):
|
|
24
|
+
// taskId — number
|
|
25
|
+
// builderId — current authenticated builder (must own the task's most-
|
|
26
|
+
// recent claim; the route handler enforces this)
|
|
27
|
+
// gradeResult — { score, passed, threshold, grader_kind, grader_version,
|
|
28
|
+
// rubric_scores, signals, notes_md, issues }
|
|
29
|
+
//
|
|
30
|
+
// Returns: { taskId, taskTitle, taskStatus, creditsAwarded, justUnlocked,
|
|
31
|
+
// grade: {score, passed, ...} }.
|
|
32
|
+
//
|
|
33
|
+
// Idempotent: the task_grades row is upsert-on-task_id (a re-grade overwrites);
|
|
34
|
+
// credit_log is guarded by an existence check on (task_id, 'task.confirmed').
|
|
35
|
+
|
|
36
|
+
async function applyGrade({ taskId, builderId, gradeResult, committedFiles = null, diffHash = null }, deps = {}) {
|
|
37
|
+
// committedFiles (touches[] cleanup [3/8] / task 878): the real committed
|
|
38
|
+
// changed-files for this ship, computed client-side by ship.js. Threaded to
|
|
39
|
+
// evaluateAfterShip so the prefix-keyed shipper badges reflect the actual
|
|
40
|
+
// diff rather than the predicted touches[]. Optional — null/absent falls back
|
|
41
|
+
// to touches[] inside evaluateAfterShip.
|
|
42
|
+
// #871 test seam (mirrors confirmTask's #538 seam): a caller-supplied pool is
|
|
43
|
+
// honored ONLY under NODE_ENV==='test' so tests/grade_attribution.mjs can drive
|
|
44
|
+
// the txn with a SQL-aware fakeClient. Production callers omit deps → module pool.
|
|
45
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
46
|
+
const result = await withTx(async (client) => {
|
|
47
|
+
const { rows: taskRows } = await client.query(
|
|
48
|
+
`SELECT id, title, status, credits_reward, kind FROM tasks WHERE id = $1 FOR UPDATE`,
|
|
49
|
+
[taskId]
|
|
50
|
+
);
|
|
51
|
+
if (taskRows.length === 0) {
|
|
52
|
+
throw Object.assign(new Error('task not found'), { code: 'TASK_NOT_FOUND' });
|
|
53
|
+
}
|
|
54
|
+
const task = taskRows[0];
|
|
55
|
+
|
|
56
|
+
// The grade can only land while the task is at `completed` (just shipped
|
|
57
|
+
// and awaiting grading) or `confirmed` (already promoted by a prior grade
|
|
58
|
+
// pass — re-grading is allowed and writes a fresh row, but it does not
|
|
59
|
+
// re-award credits or re-evaluate achievements). At `shipped`/`backlog`/
|
|
60
|
+
// `active`/`ready`/`blocked`/`abandoned` the grade has no lifecycle target,
|
|
61
|
+
// so we refuse.
|
|
62
|
+
if (!['completed', 'confirmed'].includes(task.status)) {
|
|
63
|
+
throw Object.assign(new Error(`task is at ${task.status}; grade only applies at completed/confirmed`), {
|
|
64
|
+
code: 'TASK_NOT_GRADABLE',
|
|
65
|
+
current_status: task.status,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// SECURITY (#871 — mirrors the #661 confirmTask fix): credit + attribute the
|
|
70
|
+
// builder who actually DID the work — the task's most-recent claim holder —
|
|
71
|
+
// NEVER the caller of this endpoint. Before this fix applyGrade set
|
|
72
|
+
// tasks.shipped_by and the credit_log row to `builderId` (req.builder.id from
|
|
73
|
+
// the route), so a builder who POSTed a fabricated passing grade on ANOTHER
|
|
74
|
+
// builder's `completed` task captured both the ship attribution AND the
|
|
75
|
+
// drachmae. POST /tasks/:id/grade now also gates to the claim holder/Archon,
|
|
76
|
+
// but we fix the attribution here too: defense-in-depth, and it is the
|
|
77
|
+
// CORRECT behaviour for the Archon `--regrade` path where the caller is not
|
|
78
|
+
// the worker. Fall back to the caller only if the task has no claim at all
|
|
79
|
+
// (degenerate — e.g. status set directly via SQL) so a legit grade is never
|
|
80
|
+
// refused.
|
|
81
|
+
const creditedBuilderId = (await mostRecentClaimHolderId(client, taskId)) ?? builderId;
|
|
82
|
+
|
|
83
|
+
// Upsert the grade row. The subagent's issues[] is embedded into the
|
|
84
|
+
// signals jsonb so /me + the public dashboard can surface structured
|
|
85
|
+
// failure detail without a schema change. (task_grades has no `issues`
|
|
86
|
+
// column.)
|
|
87
|
+
const signalsForRow = {
|
|
88
|
+
...(gradeResult.signals || {}),
|
|
89
|
+
issues: gradeResult.issues || [],
|
|
90
|
+
};
|
|
91
|
+
await client.query(
|
|
92
|
+
`INSERT INTO task_grades
|
|
93
|
+
(task_id, score, passed, threshold, grader_kind, grader_version,
|
|
94
|
+
rubric_scores, signals, notes_md, diff_hash)
|
|
95
|
+
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8::jsonb, $9, $10)
|
|
96
|
+
ON CONFLICT (task_id) DO UPDATE SET
|
|
97
|
+
score = EXCLUDED.score,
|
|
98
|
+
passed = EXCLUDED.passed,
|
|
99
|
+
threshold = EXCLUDED.threshold,
|
|
100
|
+
grader_kind = EXCLUDED.grader_kind,
|
|
101
|
+
grader_version = EXCLUDED.grader_version,
|
|
102
|
+
rubric_scores = EXCLUDED.rubric_scores,
|
|
103
|
+
signals = EXCLUDED.signals,
|
|
104
|
+
notes_md = EXCLUDED.notes_md,
|
|
105
|
+
diff_hash = EXCLUDED.diff_hash,
|
|
106
|
+
graded_at = now()`,
|
|
107
|
+
[
|
|
108
|
+
taskId,
|
|
109
|
+
gradeResult.score,
|
|
110
|
+
gradeResult.passed,
|
|
111
|
+
gradeResult.threshold,
|
|
112
|
+
gradeResult.grader_kind,
|
|
113
|
+
gradeResult.grader_version,
|
|
114
|
+
JSON.stringify(gradeResult.rubric_scores || {}),
|
|
115
|
+
JSON.stringify(signalsForRow),
|
|
116
|
+
gradeResult.notes_md ?? null,
|
|
117
|
+
diffHash ?? null,
|
|
118
|
+
]
|
|
119
|
+
);
|
|
120
|
+
|
|
121
|
+
let finalStatus = task.status;
|
|
122
|
+
let creditsAwarded = 0;
|
|
123
|
+
let creditsRaw = 0;
|
|
124
|
+
let creditsMultiplier = 1.0;
|
|
125
|
+
let ideaBonus = null; // { ideaBonusAwarded, bonusBuilderId, ideaId } | null
|
|
126
|
+
let netNegativeBonus = 0;
|
|
127
|
+
let subtractorUnlocks = [];
|
|
128
|
+
let justUnlocked = [];
|
|
129
|
+
// Credit allocation + gamification carved to modules/economy/ (BV1.R77 / ADR
|
|
130
|
+
// 0093 §1-2): resolve the `reward` kernel port ONCE; every credit/achievement
|
|
131
|
+
// write below runs inside this same txn via the resolved provider. The
|
|
132
|
+
// state-machine SQL (FOR UPDATE, task_grades upsert, UPDATE tasks status,
|
|
133
|
+
// claim-holder lookup, logGradeAttempt) stays here in the lifecycle.
|
|
134
|
+
const reward = seams.resolveOptional('reward');
|
|
135
|
+
if (gradeResult.passed && task.status === 'completed') {
|
|
136
|
+
await client.query(
|
|
137
|
+
`UPDATE tasks
|
|
138
|
+
SET status = 'confirmed',
|
|
139
|
+
shipped_by = COALESCE(shipped_by, $2),
|
|
140
|
+
updated_at = now()
|
|
141
|
+
WHERE id = $1 AND status = 'completed'`,
|
|
142
|
+
[taskId, creditedBuilderId]
|
|
143
|
+
);
|
|
144
|
+
finalStatus = 'confirmed';
|
|
145
|
+
|
|
146
|
+
// V3.R93 (#354): reward simplification. A ship whose diff removed more
|
|
147
|
+
// lines than it added earns a small flat bonus, written as its own
|
|
148
|
+
// credit_log row (reason='ship.net_negative') with a self-describing
|
|
149
|
+
// description. Idempotent on (task_id, reason) so a re-grade is a no-op.
|
|
150
|
+
// diff_stats arrives via gradeResult.signals (ship.js decorates it).
|
|
151
|
+
//
|
|
152
|
+
// Awarded BEFORE the achievement evaluators so the Subtractor achievement
|
|
153
|
+
// (which counts the builder's net-negative ships) sees the current ship's
|
|
154
|
+
// bonus row in this same transaction.
|
|
155
|
+
const nn = await reward.awardNetNegativeBonus(
|
|
156
|
+
client, creditedBuilderId, taskId, gradeResult.signals?.diff_stats
|
|
157
|
+
);
|
|
158
|
+
netNegativeBonus = nn.bonus;
|
|
159
|
+
subtractorUnlocks = nn.justUnlocked;
|
|
160
|
+
|
|
161
|
+
// ADR 0120 "pay on land": the per-task credit + idea-promotion bonus are
|
|
162
|
+
// NO LONGER awarded at grader-confirm. They land at the confirmed→shipped
|
|
163
|
+
// flip (shipTask → awardLandingCredit, keyed 'task.shipped'), so a task
|
|
164
|
+
// that strands at 'confirmed' pays nothing until its code is on `main`.
|
|
165
|
+
// The net-negative (simplification) bonus above STAYS here — it derives
|
|
166
|
+
// from grade-time diff_stats that only exist at grade time, so it cannot
|
|
167
|
+
// move to shipTask (and is outside ADR 0120's two named reward writes).
|
|
168
|
+
// creditsAwarded/creditsRaw/creditsMultiplier/ideaBonus stay 0/null at
|
|
169
|
+
// confirm for a stable return contract; the credit lands at ship. The
|
|
170
|
+
// credit-crossing achievement evaluator (evaluateAfterCreditAward) moves
|
|
171
|
+
// with the credit to shipTask's reconcile; here we still fire the
|
|
172
|
+
// ship/subtractor unlocks that DON'T depend on the per-task credit.
|
|
173
|
+
const shipUnlocks = await reward.evaluateAfterShip(client, creditedBuilderId, taskId, committedFiles);
|
|
174
|
+
justUnlocked = [...subtractorUnlocks, ...shipUnlocks];
|
|
175
|
+
|
|
176
|
+
// task 1679: state-based safety net. The granular evaluators above are
|
|
177
|
+
// boundary-/path-sensitive — breaking-the-100c-bar only fires when a
|
|
178
|
+
// per-task confirm credit crosses 100 (the session cost-plus reward never
|
|
179
|
+
// hits it), and streaks key off current_streak. reconcile re-checks every
|
|
180
|
+
// achievement against current state and unlocks any straggler. It is
|
|
181
|
+
// idempotent: anything the granular calls just unlocked is visible in this
|
|
182
|
+
// txn, so reconcile's alreadyUnlocked guard skips it (no dup awards).
|
|
183
|
+
// SAVEPOINT-wrapped so it can never fail the grade-confirm.
|
|
184
|
+
if (reward && reward.reconcileBuilderAchievements) {
|
|
185
|
+
await client.query('SAVEPOINT ach_reconcile');
|
|
186
|
+
try {
|
|
187
|
+
const reconciled = await reward.reconcileBuilderAchievements(client, creditedBuilderId, { taskId });
|
|
188
|
+
await client.query('RELEASE SAVEPOINT ach_reconcile');
|
|
189
|
+
if (reconciled.length) justUnlocked = [...justUnlocked, ...reconciled];
|
|
190
|
+
} catch (_) {
|
|
191
|
+
await client.query('ROLLBACK TO SAVEPOINT ach_reconcile');
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
// !passed → task_grades row written above; task stays at completed; no
|
|
196
|
+
// credits, no achievements. Builder can manual-confirm via /confirm if the
|
|
197
|
+
// grade was wrong.
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
taskId,
|
|
201
|
+
taskTitle: task.title,
|
|
202
|
+
taskStatus: finalStatus,
|
|
203
|
+
creditedBuilderId, // #871: the builder credited/attributed (claim holder, not caller)
|
|
204
|
+
creditsAwarded,
|
|
205
|
+
creditsRaw,
|
|
206
|
+
creditsMultiplier,
|
|
207
|
+
ideaBonus, // V3.R27 (#247): { ideaBonusAwarded, bonusBuilderId, ideaId } | null
|
|
208
|
+
netNegativeBonus,
|
|
209
|
+
advanceToMerge: finalStatus === 'confirmed',
|
|
210
|
+
justUnlocked,
|
|
211
|
+
grade: {
|
|
212
|
+
score: gradeResult.score,
|
|
213
|
+
passed: gradeResult.passed,
|
|
214
|
+
threshold: gradeResult.threshold,
|
|
215
|
+
rubric_scores: gradeResult.rubric_scores || {},
|
|
216
|
+
notes_md: gradeResult.notes_md ?? null,
|
|
217
|
+
issues: gradeResult.issues || [],
|
|
218
|
+
},
|
|
219
|
+
};
|
|
220
|
+
}, { pool: activePool });
|
|
221
|
+
|
|
222
|
+
// 6D.6 (ADR 0027): record this grade ATTEMPT in the append-only log so
|
|
223
|
+
// re-grade thrash is countable (task_grades is upsert-unique). Post-commit +
|
|
224
|
+
// best-effort: logGradeAttempt uses a pooled connection and swallows its own
|
|
225
|
+
// errors, so it can never roll back or block the grade that just committed.
|
|
226
|
+
await logGradeAttempt({ taskId, builderId, passed: gradeResult.passed, score: gradeResult.score });
|
|
227
|
+
|
|
228
|
+
return result;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
module.exports = {
|
|
232
|
+
applyGrade,
|
|
233
|
+
};
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
// modules/lifecycle/db-overrides.js — the grader override-request flow (builder asks an Archon to confirm anyway).
|
|
2
|
+
//
|
|
3
|
+
// Carved out of modules/lifecycle/db.js by task 1003204 (goal 1000079, the R03
|
|
4
|
+
// de-monolith plan) — a STRUCTURAL move, not a rewrite: the code below is the
|
|
5
|
+
// original db.js lines 4401-4560 unchanged. db.js remains the facade that
|
|
6
|
+
// re-exports every name, so every existing db call site is unchanged.
|
|
7
|
+
//
|
|
8
|
+
// THE BOUNDARY (ADR 0093 §1): this file reaches core ONLY through the published
|
|
9
|
+
// doorway ../../src/module-api.js — never a core internal, never a sibling module.
|
|
10
|
+
|
|
11
|
+
'use strict';
|
|
12
|
+
|
|
13
|
+
const api = require('../../src/module-api');
|
|
14
|
+
const { pool, withTx } = api;
|
|
15
|
+
const { mostRecentClaimHolderId } = require('./db-shared.js');
|
|
16
|
+
|
|
17
|
+
// task override requests — V3.R74 (#293). Rare path: a builder whose ship was
|
|
18
|
+
// rejected by the subagent grader (task stays at 'completed', no credits)
|
|
19
|
+
// believes the grader was wrong and asks an Archon to confirm anyway.
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
async function createOverrideRequest({ taskId, requesterId, rationale }) {
|
|
23
|
+
const { rows: taskRows } = await pool.query(
|
|
24
|
+
`SELECT id, status FROM tasks WHERE id = $1`,
|
|
25
|
+
[taskId]
|
|
26
|
+
);
|
|
27
|
+
if (taskRows.length === 0) {
|
|
28
|
+
throw Object.assign(new Error('task not found'), { code: 'TASK_NOT_FOUND' });
|
|
29
|
+
}
|
|
30
|
+
if (taskRows[0].status !== 'completed') {
|
|
31
|
+
throw Object.assign(new Error('task not in completed state'), {
|
|
32
|
+
code: 'TASK_NOT_COMPLETED',
|
|
33
|
+
currentStatus: taskRows[0].status,
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
const { rows } = await pool.query(
|
|
38
|
+
`INSERT INTO task_override_requests (task_id, requested_by_builder_id, rationale)
|
|
39
|
+
VALUES ($1, $2, $3)
|
|
40
|
+
RETURNING id, task_id, requested_by_builder_id, rationale, status, created_at`,
|
|
41
|
+
[taskId, requesterId, rationale]
|
|
42
|
+
);
|
|
43
|
+
return rows[0];
|
|
44
|
+
} catch (err) {
|
|
45
|
+
// uniq_task_override_requests_open_per_task — one open request per task.
|
|
46
|
+
if (err && err.code === '23505') {
|
|
47
|
+
throw Object.assign(new Error('open request already exists for this task'), {
|
|
48
|
+
code: 'OPEN_REQUEST_EXISTS',
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
throw err;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async function listOverrideRequests({ status = 'open', limit = 50 } = {}) {
|
|
57
|
+
const { rows } = await pool.query(
|
|
58
|
+
`SELECT r.id, r.task_id, r.requested_by_builder_id, r.rationale, r.status,
|
|
59
|
+
r.decided_by_builder_id, r.decided_at, r.decision_note, r.created_at,
|
|
60
|
+
t.title AS task_title, t.status AS task_status, t.version_id, t.credits_reward,
|
|
61
|
+
b.github_login AS requester_login, b.display_name AS requester_display_name
|
|
62
|
+
FROM task_override_requests r
|
|
63
|
+
JOIN tasks t ON t.id = r.task_id
|
|
64
|
+
JOIN builders b ON b.id = r.requested_by_builder_id
|
|
65
|
+
WHERE r.status = $1
|
|
66
|
+
ORDER BY r.created_at DESC
|
|
67
|
+
LIMIT $2`,
|
|
68
|
+
[status, limit]
|
|
69
|
+
);
|
|
70
|
+
return rows;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async function decideOverrideRequest({ requestId, archonId, decision, note }, deps = {}) {
|
|
75
|
+
if (decision !== 'approve' && decision !== 'deny') {
|
|
76
|
+
throw Object.assign(new Error('bad decision'), { code: 'BAD_DECISION' });
|
|
77
|
+
}
|
|
78
|
+
// `deps.pool` is a TEST-ONLY injection seam (#538) — honored ONLY when
|
|
79
|
+
// NODE_ENV==='test' (not merely "not production"), so an unset NODE_ENV in a
|
|
80
|
+
// prod-like box can't accidentally activate it, and a future route that
|
|
81
|
+
// naively forwarded a caller-supplied pool could never swap the DB connection
|
|
82
|
+
// in prod (hacker review #538). It lets tests drive the txn with a fakeClient
|
|
83
|
+
// (same pattern as credit_grant.mjs). Production callers omit it → module pool.
|
|
84
|
+
const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
|
|
85
|
+
return withTx(async (client) => {
|
|
86
|
+
// RETURNING requested_by_builder_id so the approve path doesn't re-read the
|
|
87
|
+
// row it just locked (#538): the work was the ORIGINAL REQUESTER's, and both
|
|
88
|
+
// the credit AND the ship attribution must go to them.
|
|
89
|
+
const { rows } = await client.query(
|
|
90
|
+
`UPDATE task_override_requests
|
|
91
|
+
SET status = $1,
|
|
92
|
+
decided_by_builder_id = $2,
|
|
93
|
+
decided_at = now(),
|
|
94
|
+
decision_note = $3
|
|
95
|
+
WHERE id = $4 AND status = 'open'
|
|
96
|
+
RETURNING id, task_id, status, requested_by_builder_id`,
|
|
97
|
+
[decision === 'approve' ? 'approved' : 'denied', archonId, note ?? null, requestId]
|
|
98
|
+
);
|
|
99
|
+
if (rows.length === 0) {
|
|
100
|
+
throw Object.assign(new Error('request not open'), { code: 'REQUEST_NOT_OPEN' });
|
|
101
|
+
}
|
|
102
|
+
const row = rows[0];
|
|
103
|
+
|
|
104
|
+
let confirmResult = null;
|
|
105
|
+
if (decision === 'approve') {
|
|
106
|
+
// BOTH the credit AND the ship attribution (shipped_by, which drives the
|
|
107
|
+
// builders' hall + peer-vote feed) go to the builder who actually DID the
|
|
108
|
+
// work — the task's most-recent claim holder — never the approving Archon.
|
|
109
|
+
//
|
|
110
|
+
// (#538: previously shipped_by fell to the Archon via
|
|
111
|
+
// COALESCE(shipped_by, archonId), misattributing the ship even though the
|
|
112
|
+
// credit was routed correctly. That fix attributed everything to the
|
|
113
|
+
// override REQUESTER. SR-16 / #996 tightens it once more: with the new
|
|
114
|
+
// ownership gate on POST /tasks/:id/override-request the requester is
|
|
115
|
+
// already the claim holder OR an Archon, so resolving the CLAIM HOLDER
|
|
116
|
+
// here — the same attribution source applyGrade/confirmTask use via
|
|
117
|
+
// getLastClaimHolder — keeps the credit on the worker even when an Archon
|
|
118
|
+
// files the request on a builder's behalf. Falls back to the requester
|
|
119
|
+
// only when the task has no claim on record at all, so a legitimate
|
|
120
|
+
// degenerate edge — e.g. status set directly via SQL — is never refused.)
|
|
121
|
+
const creditedBuilderId = (await mostRecentClaimHolderId(client, row.task_id)) ?? row.requested_by_builder_id;
|
|
122
|
+
// requested_by_builder_id is NOT NULL in the schema, so this can only be
|
|
123
|
+
// null in a degenerate state. Rather than silently fall back to archonId —
|
|
124
|
+
// which would re-create the exact misattribution #538 fixed — refuse the
|
|
125
|
+
// approval if there is neither a claim holder nor a requester to credit.
|
|
126
|
+
// Rolls back so the override stays open for inspection.
|
|
127
|
+
if (creditedBuilderId == null) {
|
|
128
|
+
throw Object.assign(
|
|
129
|
+
new Error(`override request ${requestId} has no claim holder or requester to credit`),
|
|
130
|
+
{ code: 'OVERRIDE_NO_REQUESTER' }
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Flip the task completed→confirmed and attribute the ship to the
|
|
135
|
+
// requester. Inlined here (rather than calling confirmTask) because
|
|
136
|
+
// confirmTask opens its own client/transaction; we want one atomic step.
|
|
137
|
+
const { rows: taskRows } = await client.query(
|
|
138
|
+
`UPDATE tasks
|
|
139
|
+
SET status = 'confirmed',
|
|
140
|
+
shipped_by = COALESCE(shipped_by, $2),
|
|
141
|
+
updated_at = now()
|
|
142
|
+
WHERE id = $1 AND status = 'completed'
|
|
143
|
+
RETURNING id, title, credits_reward, kind`,
|
|
144
|
+
[row.task_id, creditedBuilderId]
|
|
145
|
+
);
|
|
146
|
+
if (taskRows.length === 0) {
|
|
147
|
+
// Task was raced to another terminal state (e.g. someone manually
|
|
148
|
+
// confirmed it between the request and the decision). The approval row
|
|
149
|
+
// still stands as the audit signal; we just don't double-flip the task.
|
|
150
|
+
// creditedBuilderId is included for a consistent return shape so callers
|
|
151
|
+
// can always destructure it from confirmResult without a guard (#538).
|
|
152
|
+
confirmResult = { taskFlipped: false, creditsAwarded: 0, ideaBonus: null, creditedBuilderId };
|
|
153
|
+
} else {
|
|
154
|
+
// ADR 0120 "pay on land": the override-approval path flips the task
|
|
155
|
+
// completed→confirmed and attributes shipped_by to the claim holder
|
|
156
|
+
// (above), but the per-task credit + idea bonus now wait for the land
|
|
157
|
+
// (shipTask → awardLandingCredit, keyed 'task.shipped'). A stranded
|
|
158
|
+
// override-confirmed task pays nothing until its code is on `main`.
|
|
159
|
+
// creditsAwarded/ideaBonus stay in the return shape (0 / null) for a
|
|
160
|
+
// stable contract; thetes graduation was already ship-time (#704).
|
|
161
|
+
confirmResult = { taskFlipped: true, creditsAwarded: 0, ideaBonus: null, creditedBuilderId };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return { request: row, confirmResult };
|
|
166
|
+
}, { pool: activePool });
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Credit allocation carved into modules/economy/credits.js (BV1.R77 / ADR 0093
|
|
170
|
+
// §§1-2): KIND_MULTIPLIERS / IDEA_BONUS_FRACTION / REWARD_MARGIN_PCT,
|
|
171
|
+
// computeSessionTokenReward, applyKindMultiplier, insertMultipliedConfirmCredit,
|
|
172
|
+
// maybeAwardIdeaPromotionBonus, awardSessionTokenReward, and the net-negative
|
|
173
|
+
// (Subtractor) bonus all left this monolith. The lifecycle above (applyGrade /
|
|
174
|
+
// confirmTask / decideOverrideRequest / upsertSessionRecordWithReward) resolves
|
|
175
|
+
// them through the `reward` kernel port, passing its own tx client.
|
|
176
|
+
|
|
177
|
+
module.exports = {
|
|
178
|
+
createOverrideRequest,
|
|
179
|
+
decideOverrideRequest,
|
|
180
|
+
listOverrideRequests,
|
|
181
|
+
};
|