@bongos/core 1.19.357 → 1.19.359

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.
@@ -0,0 +1,90 @@
1
+ -- lifecycle_005_task_recommendations.sql — "you should look at this": recommending
2
+ -- a task to someone, which is NOT handing it to them (task 1003173 / C03,
3
+ -- goal 1000075; criterion collab-pass-a-task, ADR 0187 §3).
4
+ --
5
+ -- THE DECISION THIS TABLE ENCODES. Passing a task RECOMMENDS it; it never
6
+ -- transfers the claim. The recipient still claims it themselves, so claim/release
7
+ -- stays the single path to holding a task and nobody acquires work without opting
8
+ -- in. There is deliberately NO transfer column, NO 'accepted' status that grants
9
+ -- anything, and no seam for a future transfer: the decision was recommend-only,
10
+ -- and an unused seam is a cost that later reads as an intention.
11
+ --
12
+ -- WHY THE REASON IS NOT NULLABLE. The criterion's own argument is that a
13
+ -- recommendation which leaves no trace is indistinguishable from a task quietly
14
+ -- changing hands. A reason the sender must write is what makes the ledger a
15
+ -- record of a judgement rather than a nudge — so it is NOT NULL with a non-blank
16
+ -- CHECK, the same shape lifecycle_help_requests gives what_is_stuck.
17
+ --
18
+ -- DECLINING COSTS NOTHING, and that is why 'declined' is just a status here and
19
+ -- nothing else in the schema references it. There is no counter, no cooldown, no
20
+ -- penalty column on either party, and no karma or credit link — a table that
21
+ -- recorded a decline anywhere but here would be the penalty the criterion forbids.
22
+ --
23
+ -- MODULE-OWNED and `lifecycle_`-prefixed, so fitness check 11 permits it: a NEW
24
+ -- table the lifecycle module owns outright, unlike `tasks`/`builders` which are
25
+ -- core tables it only references (ADR 0083 §Decision #5). Mirrors
26
+ -- lifecycle_004_help_requests, its sibling under this goal.
27
+
28
+ CREATE TABLE IF NOT EXISTS lifecycle_task_recommendations (
29
+ id bigserial PRIMARY KEY,
30
+
31
+ task_id bigint NOT NULL REFERENCES tasks(id),
32
+ recommended_by bigint NOT NULL REFERENCES builders(id),
33
+ recommended_to bigint NOT NULL REFERENCES builders(id),
34
+
35
+ -- Required, per the criterion. See the header: this column IS the trace.
36
+ reason text NOT NULL,
37
+
38
+ -- 'open' — sent, not yet acted on
39
+ -- 'declined' — the recipient said no. Terminal, and carries no consequence.
40
+ -- 'withdrawn' — the sender took it back. Also terminal.
41
+ --
42
+ -- There is NO 'accepted'. Accepting a recommendation is CLAIMING THE TASK, which
43
+ -- is the claims table's job and the recipient's own action; a status here saying
44
+ -- "accepted" would be a second, weaker record of holding work and would make this
45
+ -- table look like the place claims come from. Whether the recipient went on to
46
+ -- claim it is answered by joining tasks/claims, not by a column here.
47
+ status text NOT NULL DEFAULT 'open',
48
+
49
+ created_at timestamptz NOT NULL DEFAULT now(),
50
+ updated_at timestamptz NOT NULL DEFAULT now(),
51
+
52
+ CONSTRAINT lifecycle_task_recommendations_reason_chk
53
+ CHECK (length(btrim(reason)) > 0),
54
+
55
+ CONSTRAINT lifecycle_task_recommendations_status_chk
56
+ CHECK (status IN ('open', 'declined', 'withdrawn')),
57
+
58
+ -- Recommending a task to yourself is not a recommendation.
59
+ CONSTRAINT lifecycle_task_recommendations_not_self_chk
60
+ CHECK (recommended_by <> recommended_to)
61
+ );
62
+
63
+ -- One OPEN recommendation of a given task to a given person. A second send is the
64
+ -- same judgement repeated, and two identical open rows would make the recipient's
65
+ -- list lie about how many distinct things are waiting on them. Partial, so the
66
+ -- same task CAN be recommended again after a decline or a withdrawal — the
67
+ -- decision was that declining costs nothing, and a permanent unique key would
68
+ -- quietly make a decline final.
69
+ CREATE UNIQUE INDEX IF NOT EXISTS lifecycle_task_recommendations_open_uniq
70
+ ON lifecycle_task_recommendations (task_id, recommended_to) WHERE status = 'open';
71
+
72
+ -- The recipient's read: "what has been recommended to me". Partial — a settled
73
+ -- recommendation is history and never needs routing again (the 004 pattern).
74
+ CREATE INDEX IF NOT EXISTS lifecycle_task_recommendations_open_to_idx
75
+ ON lifecycle_task_recommendations (recommended_to) WHERE status = 'open';
76
+
77
+ -- The sender's read, and the audit read the criterion asks for: the recommendation
78
+ -- and its author are readable AFTERWARDS, so this one is not filtered by status.
79
+ CREATE INDEX IF NOT EXISTS lifecycle_task_recommendations_by_idx
80
+ ON lifecycle_task_recommendations (recommended_by, created_at DESC);
81
+
82
+ -- "Who recommended this task, and why" — the per-task history.
83
+ CREATE INDEX IF NOT EXISTS lifecycle_task_recommendations_task_idx
84
+ ON lifecycle_task_recommendations (task_id, created_at DESC);
85
+
86
+ COMMENT ON TABLE lifecycle_task_recommendations IS
87
+ 'A recommendation that someone look at a task (task 1003173). It NEVER transfers a claim: there is no accepted status and no transfer column, because claiming remains the recipient''s own action.';
88
+
89
+ INSERT INTO schema_migrations (version) VALUES ('lifecycle_005_task_recommendations')
90
+ ON CONFLICT (version) DO NOTHING;
@@ -19,6 +19,7 @@
19
19
  "analytics",
20
20
  "lifecycle",
21
21
  "help-requests",
22
+ "recommendations",
22
23
  "visuals"
23
24
  ],
24
25
  "pollers": [
@@ -0,0 +1,148 @@
1
+ // modules/lifecycle/routes/recommendations.js — the write path and the reads for
2
+ // recommending a task to someone (task 1003173 / C03, goal 1000075; criterion
3
+ // collab-pass-a-task, ADR 0187 §3).
4
+ //
5
+ // WHY NOT `task-recommendations.js`, WHICH IS THE OBVIOUS NAME. `task-*.js` in
6
+ // this directory is a RESERVED PREFIX: tests/helpers.mjs taskRoutesSource()
7
+ // concatenates tasks.js with every sibling matching `task-*.js` and reads the
8
+ // result as ONE source, because the tasks router was carved into pieces that must
9
+ // still be asserted together. A separate surface named task-recommendations.js is
10
+ // swept into that family and silently breaks assertions about the tasks router —
11
+ // est_advisory and goal_advisory both matched THIS file's 201 body instead of the
12
+ // task-create one. This surface is a sibling of help-requests.js, not a piece of
13
+ // the tasks router, so it is named like help-requests.js. Do not rename it back.
14
+ //
15
+ // FIVE ROUTES, AND NOTHING THAT CLAIMS. Send one, see what is waiting on me, see
16
+ // what I sent, decline, withdraw. There is deliberately no "accept" route: accepting
17
+ // a recommendation IS claiming the task, which POST /claims already owns and which
18
+ // is the recipient's own action. A route here that claimed on the recipient's
19
+ // behalf would be the transfer this criterion exists to refuse, and even an
20
+ // "accept" that only flipped a status would make this table look like the place
21
+ // claims come from.
22
+ //
23
+ // Reaches core only through the doorway (ADR 0083).
24
+
25
+ 'use strict';
26
+
27
+ const express = require('express');
28
+ const api = require('../../../src/module-api');
29
+ const recs = require('../task-recommendations');
30
+
31
+ const { asyncHandler, validateOrRespond, parseId } = api;
32
+
33
+ module.exports = function buildTaskRecommendationsRouter() {
34
+ const router = express.Router();
35
+
36
+ // rank: any-builder — recommending is not a privileged act. It grants the
37
+ // recipient nothing they did not already have (the task was claimable by them
38
+ // or it was not), so gating it by rank would only stop newcomers from pointing
39
+ // at something useful.
40
+ router.post('/task-recommendations', api.requireBuilder, asyncHandler('POST /task-recommendations', async (req, res) => {
41
+ if (validateOrRespond(req, res, {
42
+ task_id: { required: true, type: 'integer', min: 1 },
43
+ recommended_to: { required: true, type: 'integer', min: 1 },
44
+ reason: { required: true, type: 'string', minLength: 1, maxLength: recs.MAX_REASON },
45
+ })) return;
46
+
47
+ // The CHECK constraints are the authority; this restates them so the refusal
48
+ // names the field rather than surfacing a Postgres string as a 500. The
49
+ // sender comes from the SESSION, never the body — self-recommendation is
50
+ // checked against the authenticated builder.
51
+ const v = recs.validateRecommendation(req.body, req.builder.id);
52
+ if (!v.ok) return res.fail('invalid_recommendation', 400, { errors: v.errors });
53
+
54
+ // Referenced rows that do not exist would otherwise hit the FKs and surface
55
+ // as a generic 500, telling the sender nothing. Checked here so the refusal
56
+ // names the field; the FKs remain the authority.
57
+ if (!(await recs.taskExists(req.body.task_id))) {
58
+ return res.fail('invalid_recommendation', 400, {
59
+ errors: [{ field: 'task_id', reason: 'no_such_task' }],
60
+ });
61
+ }
62
+ if (!(await recs.builderExists(req.body.recommended_to))) {
63
+ return res.fail('invalid_recommendation', 400, {
64
+ errors: [{ field: 'recommended_to', reason: 'no_such_builder' }],
65
+ });
66
+ }
67
+
68
+ const out = await recs.createRecommendation({
69
+ taskId: req.body.task_id,
70
+ recommendedBy: req.builder.id,
71
+ recommendedTo: req.body.recommended_to,
72
+ reason: req.body.reason,
73
+ });
74
+ if (!out.ok) {
75
+ // Not a body the sender can fix — the same recommendation is already open.
76
+ return res.fail('recommendation_already_open', 409, {
77
+ task_id: req.body.task_id,
78
+ recommended_to: req.body.recommended_to,
79
+ });
80
+ }
81
+ return res.status(201).json({ ok: true, recommendation: out.recommendation });
82
+ }, { errorCode: 'recommendation_failed' }));
83
+
84
+ // rank: any-builder — you only ever see what is addressed to you.
85
+ //
86
+ // NOTE WHAT THIS RESPONSE DOES NOT CONTAIN: any signal that the recipient now
87
+ // holds the task. It carries the task's own status so the recipient can judge,
88
+ // and that is all. Claiming is a separate, deliberate act on their part.
89
+ router.get('/task-recommendations/for-me', api.requireBuilder, asyncHandler('GET /task-recommendations/for-me', async (req, res) => {
90
+ const rows = await recs.listForRecipient({ builderId: req.builder.id });
91
+ return res.json({ recommendations: rows, count: rows.length });
92
+ }, { errorCode: 'recommendation_list_failed' }));
93
+
94
+ // rank: any-builder — what I recommended, and what became of it. Unfiltered by
95
+ // default: a declined recommendation stays readable, which is the criterion's
96
+ // "the recommendation and its author are readable afterwards".
97
+ router.get('/task-recommendations/mine', api.requireBuilder, asyncHandler('GET /task-recommendations/mine', async (req, res) => {
98
+ const status = typeof req.query.status === 'string' ? req.query.status : null;
99
+ const rows = await recs.listFromSender({ builderId: req.builder.id, status });
100
+ return res.json({ recommendations: rows, count: rows.length });
101
+ }, { errorCode: 'recommendation_list_failed' }));
102
+
103
+ // The per-task audit read: who recommended this task, to whom, and why.
104
+ //
105
+ // rank: any-builder, and deliberately NOT scoped to the two parties. The
106
+ // criterion's argument for a ledger is that an untraced recommendation is
107
+ // indistinguishable from a task quietly changing hands — a trace only the two
108
+ // people involved can read does not answer that. Tasks are already visible to
109
+ // every builder, and a recommendation says nothing more private than "this
110
+ // person thought that person should look at this".
111
+ router.get('/tasks/:id/recommendations', api.requireBuilder, asyncHandler('GET /tasks/:id/recommendations', async (req, res) => {
112
+ const id = parseId(req, res, { code: 'bad_task_id', positiveInt: true });
113
+ if (id === null) return;
114
+ const rows = await recs.listForTask({ taskId: id });
115
+ return res.json({ recommendations: rows, count: rows.length });
116
+ }, { errorCode: 'recommendation_list_failed' }));
117
+
118
+ // Decline. Costs nothing, and this route is where that is visible: it flips a
119
+ // status and returns the row. No karma call, no counter, no notification to the
120
+ // sender beyond the row itself turning up in their own list.
121
+ //
122
+ // rank: any-builder — ownership is enforced in the UPDATE's WHERE, not by rank.
123
+ router.patch('/task-recommendations/:id', api.requireBuilder, asyncHandler('PATCH /task-recommendations/:id', async (req, res) => {
124
+ const id = parseId(req, res, { code: 'bad_recommendation_id', positiveInt: true });
125
+ if (id === null) return;
126
+ if (validateOrRespond(req, res, {
127
+ status: { required: true, type: 'string', enum: ['declined', 'withdrawn'] },
128
+ })) return;
129
+
130
+ // Who may set which: the RECIPIENT declines, the SENDER withdraws. Each
131
+ // update scopes itself to the right party in its own WHERE.
132
+ const row = req.body.status === 'declined'
133
+ ? await recs.declineRecommendation({ id, builderId: req.builder.id })
134
+ : await recs.withdrawRecommendation({ id, builderId: req.builder.id });
135
+
136
+ if (!row) {
137
+ // ONE refusal for every miss: absent, not yours to settle, or already
138
+ // settled. Looking the row up to say which would let a caller enumerate
139
+ // existing ids — the lesson help-requests learned from its grader round.
140
+ return res.fail('recommendation_not_settleable', 404, {
141
+ reason: 'no open recommendation you can settle with that id',
142
+ });
143
+ }
144
+ return res.json({ ok: true, recommendation: row });
145
+ }, { errorCode: 'recommendation_update_failed' }));
146
+
147
+ return router;
148
+ };
@@ -0,0 +1,247 @@
1
+ // modules/lifecycle/task-recommendations.js — "you should look at this": the data
2
+ // layer for recommending a task to someone (task 1003173 / C03, goal 1000075;
3
+ // criterion collab-pass-a-task, ADR 0187 §3).
4
+ //
5
+ // THE ONE RULE THIS FILE EXISTS TO HOLD. A pass RECOMMENDS; it never transfers.
6
+ // Nothing here writes to `claims`, and nothing here reads a claim in order to move
7
+ // one. The recipient still claims the task themselves through the ordinary claim
8
+ // path, so claim/release stays the single way to hold work and nobody acquires a
9
+ // task without opting in. There is deliberately no transfer function and no seam
10
+ // for one: the decision was recommend-only, and an unused seam is a cost that
11
+ // later reads as an intention.
12
+ //
13
+ // THE REASON IS REQUIRED, NOT OPTIONAL. The criterion's own argument is that an
14
+ // untraced recommendation is indistinguishable from a task quietly changing hands.
15
+ // So a reason the sender must write is what makes this a ledger rather than a
16
+ // nudge, and it is enforced in three places that agree: the CHECK constraint (the
17
+ // authority), validateRecommendation() (so the refusal names the field instead of
18
+ // surfacing a Postgres string), and the route's body schema.
19
+ //
20
+ // DECLINING COSTS NOTHING. That is a claim about what this module does NOT do:
21
+ // no counter, no cooldown, no karma or credit call, nothing written to either
22
+ // builder. `decline` sets a status and returns the row. If a penalty ever appears
23
+ // anywhere for declining, it did not come from here.
24
+ //
25
+ // Sibling of ./help-requests.js under the same goal, and deliberately shaped like
26
+ // it — same deps.pool injection, same capRows bound, same pure-validator split —
27
+ // so the two collab surfaces read as one thing.
28
+ //
29
+ // Reaches core only through the doorway (ADR 0083).
30
+
31
+ 'use strict';
32
+
33
+ const { pool } = require('../../src/module-api');
34
+
35
+ const COLS = 'id, task_id, recommended_by, recommended_to, reason, status, created_at, updated_at';
36
+
37
+ const MAX_REASON = 2000;
38
+ // Defence in depth, matching help-requests: both reads are bounded today (open
39
+ // status, per-builder scope), but an unsettled pile should degrade the page rather
40
+ // than the server.
41
+ const MAX_ROWS = 200;
42
+
43
+ function capRows(n) {
44
+ const v = Number(n);
45
+ return Number.isInteger(v) && v > 0 && v <= MAX_ROWS ? v : MAX_ROWS;
46
+ }
47
+
48
+ // PURE. Returns { ok, errors } so the route can 400 with named fields before any
49
+ // query runs, and every branch is testable without a database. The table's CHECK
50
+ // constraints remain the authority; these exist so a refusal is a named 400
51
+ // instead of a 500 carrying a Postgres string.
52
+ //
53
+ // `senderId` is passed rather than read from the body on purpose: self-recommendation
54
+ // is checked against the AUTHENTICATED builder, never against a client-supplied id.
55
+ function validateRecommendation(input, senderId) {
56
+ const errors = [];
57
+ const b = input || {};
58
+
59
+ const taskId = b.task_id == null ? null : Number(b.task_id);
60
+ if (taskId === null) errors.push({ field: 'task_id', reason: 'required' });
61
+ else if (!Number.isInteger(taskId) || taskId <= 0) errors.push({ field: 'task_id', reason: 'bad_task_id' });
62
+
63
+ const to = b.recommended_to == null ? null : Number(b.recommended_to);
64
+ if (to === null) errors.push({ field: 'recommended_to', reason: 'required' });
65
+ else if (!Number.isInteger(to) || to <= 0) errors.push({ field: 'recommended_to', reason: 'bad_builder_id' });
66
+ else if (senderId != null && String(to) === String(senderId)) {
67
+ // Recommending a task to yourself is not a recommendation. Caught here so the
68
+ // refusal names the field; the not-self CHECK stays the authority.
69
+ errors.push({ field: 'recommended_to', reason: 'cannot_recommend_to_self' });
70
+ }
71
+
72
+ // The trace. See the header for why this is not nullable.
73
+ const reason = typeof b.reason === 'string' ? b.reason.trim() : '';
74
+ if (!reason) errors.push({ field: 'reason', reason: 'required' });
75
+ else if (reason.length > MAX_REASON) errors.push({ field: 'reason', reason: 'too_long' });
76
+
77
+ return { ok: errors.length === 0, errors };
78
+ }
79
+
80
+ // Existence checks so a bad reference is a named 400 rather than an FK violation
81
+ // rendered as a 500. The FKs remain the authority — a row deleted between the
82
+ // check and the insert still fails safely there.
83
+ async function builderExists(id, deps = {}) {
84
+ const activePool = deps.pool || pool;
85
+ const { rows } = await activePool.query('SELECT 1 FROM builders WHERE id = $1', [id]);
86
+ return rows.length > 0;
87
+ }
88
+
89
+ async function taskExists(id, deps = {}) {
90
+ const activePool = deps.pool || pool;
91
+ const { rows } = await activePool.query('SELECT 1 FROM tasks WHERE id = $1', [id]);
92
+ return rows.length > 0;
93
+ }
94
+
95
+ // Send one. Returns { ok: true, recommendation } or { ok: false, reason }.
96
+ //
97
+ // ON CONFLICT DO NOTHING against the partial unique index, rather than letting the
98
+ // 23505 escape as a 500: a second send of the same task to the same person while
99
+ // one is still open is the same judgement repeated, and the caller should be told
100
+ // that plainly. It is NOT an error the sender can fix by changing the body, so it
101
+ // reads as `already_open`, not as a validation failure.
102
+ async function createRecommendation(input, deps = {}) {
103
+ const activePool = deps.pool || pool;
104
+ const { rows } = await activePool.query(
105
+ `INSERT INTO lifecycle_task_recommendations
106
+ (task_id, recommended_by, recommended_to, reason)
107
+ VALUES ($1, $2, $3, $4)
108
+ ON CONFLICT DO NOTHING
109
+ RETURNING ${COLS}`,
110
+ [input.taskId, input.recommendedBy, input.recommendedTo, String(input.reason).trim()]
111
+ );
112
+ if (!rows[0]) return { ok: false, reason: 'already_open' };
113
+ return { ok: true, recommendation: rows[0] };
114
+ }
115
+
116
+ // The recipient's read: what has been recommended to me, and by whom. Joined to
117
+ // the task and the sender because a bare id list cannot be acted on — the whole
118
+ // point is that the recipient can judge whether to go and claim it.
119
+ //
120
+ // It reports `recommended_task_status` rather than a "can I claim this" boolean.
121
+ // Claimability is the claim path's answer (deps, touches, rank, an existing
122
+ // claim), and duplicating that judgement here would be a second, staler copy of a
123
+ // rule that already has an owner.
124
+ async function listForRecipient({ builderId, limit = MAX_ROWS }, deps = {}) {
125
+ const activePool = deps.pool || pool;
126
+ const { rows } = await activePool.query(
127
+ `SELECT r.id, r.task_id, r.recommended_by, r.recommended_to, r.reason,
128
+ r.status, r.created_at, r.updated_at,
129
+ t.title AS task_title,
130
+ t.status AS task_status,
131
+ b.github_login AS recommended_by_login,
132
+ b.display_name AS recommended_by_name
133
+ FROM lifecycle_task_recommendations r
134
+ JOIN tasks t ON t.id = r.task_id
135
+ JOIN builders b ON b.id = r.recommended_by
136
+ WHERE r.recommended_to = $1 AND r.status = 'open'
137
+ ORDER BY r.created_at DESC, r.id DESC
138
+ LIMIT $2`,
139
+ [builderId, capRows(limit)]
140
+ );
141
+ return rows;
142
+ }
143
+
144
+ // The sender's read: what I have recommended, and what became of it. NOT filtered
145
+ // to open — the criterion asks that a recommendation and its author stay readable
146
+ // afterwards, so a declined row is still here, still naming who declined it.
147
+ async function listFromSender({ builderId, status = null, limit = MAX_ROWS }, deps = {}) {
148
+ const activePool = deps.pool || pool;
149
+ const { rows } = await activePool.query(
150
+ `SELECT r.id, r.task_id, r.recommended_by, r.recommended_to, r.reason,
151
+ r.status, r.created_at, r.updated_at,
152
+ t.title AS task_title,
153
+ t.status AS task_status,
154
+ b.github_login AS recommended_to_login,
155
+ b.display_name AS recommended_to_name
156
+ FROM lifecycle_task_recommendations r
157
+ JOIN tasks t ON t.id = r.task_id
158
+ JOIN builders b ON b.id = r.recommended_to
159
+ WHERE r.recommended_by = $1 AND ($2::text IS NULL OR r.status = $2)
160
+ ORDER BY r.created_at DESC, r.id DESC
161
+ LIMIT $3`,
162
+ [builderId, status, capRows(limit)]
163
+ );
164
+ return rows;
165
+ }
166
+
167
+ // "Who recommended this task, and why" — the per-task audit read, and the direct
168
+ // answer to the criterion's "the recommendation and its author are readable
169
+ // afterwards". Every status, oldest-relevant-first ordering aside, because the
170
+ // history is the point: a declined recommendation still records that someone
171
+ // thought this person should see this task.
172
+ async function listForTask({ taskId, limit = MAX_ROWS }, deps = {}) {
173
+ const activePool = deps.pool || pool;
174
+ const { rows } = await activePool.query(
175
+ `SELECT r.id, r.task_id, r.recommended_by, r.recommended_to, r.reason,
176
+ r.status, r.created_at, r.updated_at,
177
+ sender.github_login AS recommended_by_login,
178
+ sender.display_name AS recommended_by_name,
179
+ recipient.github_login AS recommended_to_login,
180
+ recipient.display_name AS recommended_to_name
181
+ FROM lifecycle_task_recommendations r
182
+ JOIN builders sender ON sender.id = r.recommended_by
183
+ JOIN builders recipient ON recipient.id = r.recommended_to
184
+ WHERE r.task_id = $1
185
+ ORDER BY r.created_at DESC, r.id DESC
186
+ LIMIT $2`,
187
+ [taskId, capRows(limit)]
188
+ );
189
+ return rows;
190
+ }
191
+
192
+ // The recipient declines. Costs nothing — see the header: this sets a status and
193
+ // returns the row, and writes nothing anywhere else about either party.
194
+ //
195
+ // Ownership is enforced in the WHERE, not by rank, and ONE refusal covers all
196
+ // three miss cases (absent, not addressed to you, already settled) so a caller
197
+ // cannot enumerate existing ids by reading which one it was — the same lesson
198
+ // help-requests learned from its own grader round.
199
+ async function declineRecommendation({ id, builderId }, deps = {}) {
200
+ const activePool = deps.pool || pool;
201
+ const { rows } = await activePool.query(
202
+ `UPDATE lifecycle_task_recommendations
203
+ SET status = 'declined', updated_at = now()
204
+ WHERE id = $1 AND recommended_to = $2 AND status = 'open'
205
+ RETURNING ${COLS}`,
206
+ [id, builderId]
207
+ );
208
+ return rows[0] || null;
209
+ }
210
+
211
+ // The sender takes it back. Same shape, other party, same silence about it.
212
+ async function withdrawRecommendation({ id, builderId }, deps = {}) {
213
+ const activePool = deps.pool || pool;
214
+ const { rows } = await activePool.query(
215
+ `UPDATE lifecycle_task_recommendations
216
+ SET status = 'withdrawn', updated_at = now()
217
+ WHERE id = $1 AND recommended_by = $2 AND status = 'open'
218
+ RETURNING ${COLS}`,
219
+ [id, builderId]
220
+ );
221
+ return rows[0] || null;
222
+ }
223
+
224
+ // The needs-feed read: how many open recommendations are waiting on this builder.
225
+ // A COUNT rather than the rows, for the same reason countOpenForBuilder is a count
226
+ // in help-requests — /me runs on every session open and should not carry bodies it
227
+ // will not render.
228
+ async function countOpenForRecipient(builderId, deps = {}) {
229
+ const activePool = deps.pool || pool;
230
+ const { rows } = await activePool.query(
231
+ `SELECT count(*)::int AS n
232
+ FROM lifecycle_task_recommendations
233
+ WHERE recommended_to = $1 AND status = 'open'`,
234
+ [builderId]
235
+ );
236
+ return rows[0] ? rows[0].n : 0;
237
+ }
238
+
239
+ module.exports = {
240
+ MAX_REASON, MAX_ROWS, capRows,
241
+ validateRecommendation,
242
+ builderExists, taskExists,
243
+ createRecommendation,
244
+ listForRecipient, listFromSender, listForTask,
245
+ declineRecommendation, withdrawRecommendation,
246
+ countOpenForRecipient,
247
+ };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.357",
3
+ "version": "1.19.359",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.357",
9
+ "version": "1.19.359",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.357",
3
+ "version": "1.19.359",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
@@ -68,6 +68,7 @@ const PUBLISH_ALLOWLIST = [
68
68
  'modules/dev-box/',
69
69
  'modules/discord/',
70
70
  'modules/provisioning/', // instance-hosting control plane — CORE like its sibling dev-box (same do-api/cf-tunnel toolchain, all core). Reclassified IN so the core CLI scripts/gds/provision.js resolves it from the core package post-split, not a MODULE_NOT_FOUND (task 2135). default:false — shipped ≠ enabled.
71
+ 'modules/agents/', // the declarative agent registry + spawn seam (goal 1000038) — CORE: its own manifest declares maintenance.status 'core-maintained', and it consumes the core `grade` port, so a released core without it cannot run the MAS machinery the methodology describes. default:false — shipped is not enabled (ADR 0083), exactly like provisioning + platform-identity below. Omitted at module-creation time (tasks 1002485/1002486): the omission was invisible until tests/agents_spawn.mjs became the first PUBLISHABLE file to import it and the R89 boundary guard caught it (task 1002489).
71
72
  'modules/platform-identity/', // hub identity provider (ADR 0141) — ships in core, enabled ONLY on the hub (cloudbongos.com). MUST be here or the hub's enabledModules() throws "unknown module" and provision.js's federation load MODULE_NOT_FOUNDs (task 1002235, same rationale as provisioning above). default:false — shipped ≠ enabled; a vanilla/self-hosted instance never turns it on.
72
73
  // --- neutral (customer-0) config starters + their schemas (the filled-in *.json twins are denied) ---
73
74
  'config/branding.neutral.json',
@@ -84,6 +84,7 @@ module.exports = function buildMeRouter() {
84
84
  builder, activeClaims, estVsActual, unlocked, lockedWithProgress, streakInfo,
85
85
  recentGrades, builderAvg, onboardingRow, shipCount, recentKarma, karma30d, discordLink, boxConnected, cliAuthed,
86
86
  behaviorPrefs, geminiKeyRow, needsRebaseTasks, foundingBuilder, helpRequestCount,
87
+ taskRecommendationCount,
87
88
  ] = await Promise.all([
88
89
  db.getBuilderById(req.builder.id),
89
90
  lc().getActiveClaimsForBuilder(req.builder.id),
@@ -160,6 +161,16 @@ module.exports = function buildMeRouter() {
160
161
  return await lifecycle.openHelpRequestsFor(req.builder.id);
161
162
  } catch (_) { return 0; }
162
163
  })(),
164
+ // task 1003173 (C03): open task recommendations waiting on this builder.
165
+ // Same port, same fan-out slot, same fail-open posture as the help-request
166
+ // count above — a collab read must never be able to break a profile.
167
+ (async () => {
168
+ try {
169
+ const lifecycle = lc();
170
+ if (!lifecycle || !lifecycle.openTaskRecommendationsFor) return 0;
171
+ return await lifecycle.openTaskRecommendationsFor(req.builder.id);
172
+ } catch (_) { return 0; }
173
+ })(),
163
174
  ]);
164
175
  // V3.R84 (#302), criterion C3: the builder's month-to-date spend vs their
165
176
  // monthly budget. Surfaced here so the CLI (session-start ping) and the hall
@@ -301,6 +312,10 @@ module.exports = function buildMeRouter() {
301
312
  // module (ADR 0093); undefined when lifecycle is off, which the need reads
302
313
  // as "nothing asked".
303
314
  openHelpRequests: helpRequestCount,
315
+ // task 1003173 (C03, goal 1000075): open task recommendations waiting on
316
+ // this builder, read through the same `lifecycle` port so core never
317
+ // imports the module (ADR 0093).
318
+ openTaskRecommendations: taskRecommendationCount,
304
319
  }) : [],
305
320
  };
306
321
  // Module→core contribution seam (BV1.R42, ADR 0083). Let each enabled module
package/src/module-api.js CHANGED
@@ -1144,8 +1144,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
1144
1144
  // landed since 1.19.355 with no explicit bump. run 32861072779. (task 1002620)
1145
1145
  // 1.19.357 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1146
1146
  // landed since 1.19.356 with no explicit bump. run 32871335939. (task 1002620)
1147
+ // 1.19.358 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1148
+ // landed since 1.19.357 with no explicit bump. run 32872551850. (task 1002620)
1149
+ // 1.19.359 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1150
+ // landed since 1.19.358 with no explicit bump. run 32877754251. (task 1002620)
1147
1151
  // ---------------------------------------------------------------------------
1148
- const CORE_VERSION = '1.19.357'; // CI auto-patch carrier (ADR 0161); see changelog above
1152
+ const CORE_VERSION = '1.19.359'; // CI auto-patch carrier (ADR 0161); see changelog above
1149
1153
 
1150
1154
  // A namespaced logger so a module's log lines are attributable + consistent.
1151
1155
  // Usage: const log = api.logger('dev-box'); log.info('mounted');