@bongos/core 1.19.485 → 1.19.486

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.
@@ -167,6 +167,19 @@
167
167
  }
168
168
 
169
169
  function onClick(e) {
170
+ // task 1003279: a rot verb. Handled FIRST because its rows carry no data-goal —
171
+ // the path is server-declared and already tier-correct, so this branch needs to
172
+ // know nothing about which of the four verbs was pressed beyond what to say
173
+ // afterwards. Refreshes the ROT slice only: repainting the goals list would
174
+ // leave the acted-on row sitting there (the task-1003066 lesson).
175
+ const rotBtn = e.target.closest('[data-rot-path]');
176
+ if (rotBtn) {
177
+ const verb = rotBtn.dataset.rotVerb;
178
+ act(() => postJSON(`${API}${rotBtn.dataset.rotPath}`, {}),
179
+ ROT_TOASTS[verb] || 'Done.', loadRot);
180
+ return;
181
+ }
182
+
170
183
  // task 1003066 (BV1.R05): the nod. It reuses the EXISTING promote route —
171
184
  // POST /tasks/:id/promote, already Metic+ via task.promote — so giving a nod
172
185
  // adds no endpoint and no permission. Handled before the membership branches
@@ -262,6 +275,104 @@
262
275
  .catch(() => { /* a non-triager's 403, or a hiccup: never break the hall */ });
263
276
  }
264
277
 
278
+ // ---- the rot queue (task 1003279, goal 1000072) ---------------------------
279
+ //
280
+ // "Untouched-for-X triggers a configured action rather than a silent decay."
281
+ // The owner's decision (2026-08-25): the action is a LINE IN THIS CARD, not an
282
+ // automatic status change and not a nightly digest. Auto-demoting mutates real
283
+ // work with no human in the loop, and a wrong timer then silently freezes it; a
284
+ // digest is a notification, not an action, and gets scrolled past.
285
+ //
286
+ // THE BUTTONS ARE SERVER-DECLARED. Each row arrives carrying its own `verbs`
287
+ // array — label, method and path — and this renders exactly those. It is not
288
+ // decoration: three of the four verbs reuse EXISTING routes (promote, demote,
289
+ // abandon, archive) whose permissions differ by tier, and a hardcoded button list
290
+ // here would be a second copy of that mapping, free to drift into offering a
291
+ // button the server will refuse. A goal legitimately gets two verbs where a task
292
+ // gets four; this file does not need to know why.
293
+ //
294
+ // No client-side rank gate, for the same reason the nod slice has none: the read
295
+ // is scoped server-side, so a builder is only ever handed rows that are theirs to
296
+ // act on and the button list is already narrowed to what they can press.
297
+ const MAX_ROT_ROWS = 5;
298
+
299
+ function untouchedFor(iso) {
300
+ const t = iso ? Date.parse(iso) : NaN;
301
+ if (Number.isNaN(t)) return 'untouched';
302
+ const days = Math.floor((Date.now() - t) / 86400000);
303
+ if (days < 1) return 'untouched today';
304
+ return `untouched ${days} day${days === 1 ? '' : 's'}`;
305
+ }
306
+
307
+ function rotRowHtml(r) {
308
+ const href = r.tier === 'goal'
309
+ ? goalHref(r.id)
310
+ : `/task/${encodeURIComponent(r.id)}`;
311
+ const where = r.tier === 'goal'
312
+ ? 'goal'
313
+ : (r.goal_title ? escapeHtml(r.goal_title) : `goal ${escapeHtml(String(r.goal_id || '—'))}`);
314
+ // `watered` is worth showing: a row that is back DESPITE having been watered is
315
+ // a different message from one nobody has ever looked at, and hiding that would
316
+ // make the second watering feel like the first one did nothing.
317
+ const again = r.ever_watered ? ' · watered before' : '';
318
+ const buttons = (Array.isArray(r.verbs) ? r.verbs : []).map((v) => {
319
+ const path = String(v.path || '').replace(':id', encodeURIComponent(r.id));
320
+ return `<button class="btn-ghost" type="button" data-rot-path="${escapeHtml(path)}"`
321
+ + ` data-rot-verb="${escapeHtml(String(v.verb || ''))}"`
322
+ + ` title="${escapeHtml(String(v.hint || ''))}">${escapeHtml(String(v.label || v.verb || '?'))}</button>`;
323
+ }).join('');
324
+ return `<li class="ginbox__item" data-rot-tier="${escapeHtml(String(r.tier))}">
325
+ <div class="ginbox__main">
326
+ <a class="ginbox__goal" href="${href}">${escapeHtml(r.title || `${r.tier} #${r.id}`)}</a>
327
+ <span class="ginbox__sub">${escapeHtml(String(r.status || ''))} · ${where} · ${escapeHtml(untouchedFor(r.last_touched_at))}${again}</span>
328
+ </div>
329
+ <div class="ginbox__acts">${buttons}</div>
330
+ </li>`;
331
+ }
332
+
333
+ // What each verb says when it lands. Keyed by the SERVER's verb name so a new verb
334
+ // degrades to a neutral confirmation instead of a blank toast.
335
+ const ROT_TOASTS = {
336
+ prioritise: 'Prioritised — it’s claimable now.',
337
+ prune: 'Pruned — back in the backlog.',
338
+ kill: 'Killed — it won’t come back.',
339
+ water: 'Watered — the clock is reset.',
340
+ };
341
+
342
+ function renderRot(data) {
343
+ const box = host && host.querySelector('#attention-rot');
344
+ if (!box) return;
345
+ const rows = (data && Array.isArray(data.rows)) ? data.rows : [];
346
+ if (!rows.length) {
347
+ box.innerHTML = '';
348
+ window.OTBAttention?.refresh();
349
+ return;
350
+ }
351
+ const shown = rows.slice(0, MAX_ROT_ROWS);
352
+ const more = rows.length > shown.length
353
+ ? `<p class="ginbox__pending">…${rows.length - shown.length} more going quiet.</p>`
354
+ : '';
355
+ // The heading names the TIMER, not just the count. A reader who disagrees with
356
+ // the line ("30 days is too short for a backlog") needs to know which number
357
+ // produced it before they can go change it — and when that number came from a
358
+ // value the server could not use, the receipt says so here, on the screen of
359
+ // somebody who can fix it.
360
+ const t = (data && data.timer) || {};
361
+ const timerNote = t.invalid
362
+ ? `<span class="ginbox__sub"> — ${escapeHtml(String(t.describe || ''))}</span>`
363
+ : `<span class="ginbox__sub"> — untouched ${escapeHtml(String(t.rot_days ?? '?'))}+ days</span>`;
364
+ box.innerHTML = '<h3 class="ginbox__sec-h">Going quiet '
365
+ + `<span class="count-pill">${rows.length}</span>${timerNote}</h3>`
366
+ + `<ul class="ginbox__list">${shown.map(rotRowHtml).join('')}</ul>${more}`;
367
+ window.OTBAttention?.refresh();
368
+ }
369
+
370
+ function loadRot() {
371
+ return getJSON(`${API}/inbox/rotting`)
372
+ .then((data) => renderRot(data))
373
+ .catch(() => { /* signed out, older server, or a hiccup: never break the hall */ });
374
+ }
375
+
265
376
  // task 2169: this widget IS the "Needs your attention" card — ONE capped
266
377
  // summary hosting every actionable item class: goal invitations +
267
378
  // join-requests (this file) and ideas awaiting triage (builders.js renders
@@ -294,10 +405,17 @@
294
405
  // awaiting a nod inside a goal sits nearer the goals slice than idea
295
406
  // triage. Its rows carry the shared .ginbox__item class, so refresh()
296
407
  // counts them with no change to the selector below.
297
- '<div id="attention-nod"></div>';
408
+ '<div id="attention-nod"></div>' +
409
+ // task 1003279 (goal 1000072): work going quiet — a goal or task untouched
410
+ // past the project's rot timer, with the owner's four verbs on it. LAST in
411
+ // the card on purpose: every slice above is somebody waiting on YOU right
412
+ // now, while rot is a standing question that has already waited weeks and
413
+ // can wait until they are read.
414
+ '<div id="attention-rot"></div>';
298
415
  host.addEventListener('click', onClick);
299
416
  load();
300
417
  loadNodQueue();
418
+ loadRot();
301
419
  // task 1002802 (P06): the profile slice lives in its own file (builders.js is
302
420
  // over the size budget); this card owns its div, so it boots it here.
303
421
  window.OTBProfileNudge?.load();
@@ -22,7 +22,8 @@
22
22
  "recommendations",
23
23
  "closeness",
24
24
  "visuals",
25
- "kickoff-seed"
25
+ "kickoff-seed",
26
+ "rot"
26
27
  ],
27
28
  "pollers": [
28
29
  "publish-reconciler"
@@ -0,0 +1,210 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/rot.js — what is quietly rotting, and the one write that says
4
+ // "leave it alone" (task 1003279, goal 1000072, criterion legibility-sort-and-staleness).
5
+ //
6
+ // WHY THIS EXISTS. The goal's rotting half: "Untouched-for-X triggers a configured
7
+ // action rather than a silent decay." The owner's decision (2026-08-25) fixed what
8
+ // the action is — a NEEDS-ATTENTION ITEM, not an automatic status change and not a
9
+ // nightly digest. A goal or task past the timer raises one line in the hall's
10
+ // existing inbox, addressed to whoever created it and to the owner, offering the
11
+ // four verbs the goal's own prose asks for: prune, kill, prioritise, water.
12
+ // Rejected, and why: auto-demoting mutates real work with no human in the loop, and
13
+ // a wrong timer then silently freezes it; a digest is a notification, not an action,
14
+ // and gets scrolled past.
15
+ //
16
+ // THERE IS NO SWEEP, AND THAT IS THE DESIGN. Every line here is DERIVED on read from
17
+ // the rot clock against the timer. Nothing is stored, so "exactly one open item per
18
+ // stale thing, not one per sweep" is true by construction rather than by a
19
+ // deduplicating poller that has to be written correctly and then stay correct. The
20
+ // three consequences the owner's note asks for all fall out of the same fact:
21
+ // • acting on a line (prune/kill/prioritise) changes status → the row stops matching
22
+ // • watering pushes attention_watered_at forward → the row stops matching
23
+ // • a real edit moves updated_at → the row stops matching
24
+ // Nothing to reconcile, nothing to garbage-collect, no way to accrete duplicates,
25
+ // and no window in which the inbox and the database disagree.
26
+ //
27
+ // WHAT CAN ROT, AND WHAT DELIBERATELY CANNOT. Rot is *work nobody has picked up* —
28
+ // not work in flight. Each exclusion below is excluded because some OTHER surface
29
+ // already owns that question, and two surfaces asking the same question with two
30
+ // different timers is the ADR 0226 failure:
31
+ // 'active' → the CLAIM-silence timer owns it (src/stale-timer.js);
32
+ // the sweeper releases it and the Archon roster reports it.
33
+ // 'blocked' → a blocker owns it; /blocker-review is its cadence.
34
+ // 'completed','confirmed' → the ship pipeline owns it; the publish reconciler and
35
+ // the "Stranded ships" line report it.
36
+ // 'shipped','abandoned' → terminal. Nothing rots after it is done.
37
+ // That leaves the queue nobody is working, which is exactly the pile the four verbs
38
+ // are verbs for.
39
+
40
+ const api = require('../../src/module-api');
41
+
42
+ const { pool } = api;
43
+
44
+ // The statuses that can rot, per tier. Exported so the tests assert against the same
45
+ // set the query reads — a second copy of this list is how the inbox and its tests
46
+ // drift into disagreeing about what rot even is.
47
+ const TASK_ROTTABLE_STATUSES = Object.freeze(['backlog', 'ready']);
48
+ const GOAL_ROTTABLE_STATUSES = Object.freeze(['open']);
49
+
50
+ // The rot clock, spelled once. Both strings are duplicated VERBATIM in the index
51
+ // expressions in migration core_231 — if you change one, change both, or the query
52
+ // silently stops using the index. See that migration's header for why the first two
53
+ // terms are shaped the way they are (they are task-lib.js's published definition of
54
+ // "touched"; the water mark is the third term this task adds).
55
+ const TASK_ROT_CLOCK = 'GREATEST(t.updated_at, COALESCE(t.shipped_at, t.updated_at), COALESCE(t.attention_watered_at, t.updated_at))';
56
+ const GOAL_ROT_CLOCK = 'GREATEST(g.updated_at, COALESCE(g.attention_watered_at, g.updated_at))';
57
+
58
+ // The verbs a line offers, per tier — served WITH the rows so the client renders
59
+ // what the server will actually accept, instead of a button list that can drift out
60
+ // of step with the routes behind it.
61
+ //
62
+ // Three of the four are EXISTING routes, on purpose: the nod slice in this same card
63
+ // established the precedent (task 1003066 reused POST /tasks/:id/promote rather than
64
+ // inventing an endpoint), and a verb that reuses a route also reuses its permission,
65
+ // so this feature adds no new authority. Only `water` is new, because nothing in the
66
+ // system could previously say "I looked at this, it is fine, ask me again later".
67
+ //
68
+ // A GOAL gets two verbs, not four, and the omission is honest rather than lazy:
69
+ // promote/demote are task-lifecycle transitions that a goal has no equivalent of
70
+ // (a goal is open, achieved or archived). Rendering a dead "prioritise" button on a
71
+ // goal row would be worse than not offering it.
72
+ const TASK_VERBS = Object.freeze([
73
+ Object.freeze({ verb: 'prioritise', label: 'Prioritise', method: 'POST', path: '/tasks/:id/promote', hint: 'make it claimable now' }),
74
+ Object.freeze({ verb: 'prune', label: 'Prune', method: 'POST', path: '/tasks/:id/demote', hint: 'back to the backlog, not dead' }),
75
+ Object.freeze({ verb: 'kill', label: 'Kill', method: 'POST', path: '/tasks/:id/abandon', hint: 'it is not going to happen' }),
76
+ Object.freeze({ verb: 'water', label: 'Water', method: 'POST', path: '/tasks/:id/water', hint: 'leave it — reset the clock' }),
77
+ ]);
78
+ const GOAL_VERBS = Object.freeze([
79
+ Object.freeze({ verb: 'kill', label: 'Archive', method: 'POST', path: '/goals/:id/archive', hint: 'close it out' }),
80
+ Object.freeze({ verb: 'water', label: 'Water', method: 'POST', path: '/goals/:id/water', hint: 'leave it — reset the clock' }),
81
+ ]);
82
+
83
+ const MAX_ROT_ROWS = 200;
84
+
85
+ // rottingFor({ builderId, canSeeAll, rotDays, limit }) — the rot lines this caller
86
+ // should see, oldest first.
87
+ //
88
+ // SCOPE. The owner's note addresses the item "to its creator, and to the owner", so:
89
+ // a builder sees what they created; a caller with `canSeeAll` (the owner) sees
90
+ // everything. A thing whose creator is NULL — created before attribution existed, or
91
+ // by a system path (the core_227 convention) — is visible ONLY to canSeeAll, because
92
+ // there is no person it could honestly be addressed to. Silently showing orphaned
93
+ // rot to every builder would make the inbox everyone's problem and therefore
94
+ // nobody's.
95
+ //
96
+ // Returns { rows, rotDays, cutoff } — `cutoff` is the timestamp the clock was
97
+ // compared against, echoed so a reader can tell WHY a row is here without redoing
98
+ // the arithmetic.
99
+ async function rottingFor({ builderId, canSeeAll = false, rotDays, limit = 50 } = {}) {
100
+ const days = Number(rotDays);
101
+ if (!Number.isInteger(days) || days <= 0) {
102
+ throw new Error('rottingFor: rotDays must be a positive whole number of days');
103
+ }
104
+ const id = Number(builderId);
105
+ if (!Number.isInteger(id) || id <= 0) return { rows: [], rotDays: days, cutoff: null };
106
+ const cap = Math.max(1, Math.min(MAX_ROT_ROWS, Number(limit) || 50));
107
+
108
+ // One round trip for both tiers. The UNION arms are shaped to the same column list
109
+ // so the client renders one list of lines, not two lists it has to interleave.
110
+ const { rows } = await pool.query(
111
+ `WITH cutoff AS (SELECT now() - make_interval(days => $1::int) AS at)
112
+ SELECT * FROM (
113
+ SELECT 'task'::text AS tier,
114
+ t.id::text AS id,
115
+ t.title AS title,
116
+ t.status AS status,
117
+ t.goal_id::text AS goal_id,
118
+ g.title AS goal_title,
119
+ t.created_by::text AS created_by,
120
+ ${TASK_ROT_CLOCK} AS last_touched_at,
121
+ (t.attention_watered_at IS NOT NULL) AS ever_watered
122
+ FROM tasks t
123
+ LEFT JOIN goals g ON g.id = t.goal_id
124
+ WHERE t.status = ANY($2::text[])
125
+ AND ${TASK_ROT_CLOCK} < (SELECT at FROM cutoff)
126
+ AND ($4::boolean OR t.created_by = $3::bigint)
127
+ UNION ALL
128
+ SELECT 'goal'::text AS tier,
129
+ g.id::text AS id,
130
+ g.title AS title,
131
+ g.status AS status,
132
+ NULL::text AS goal_id,
133
+ NULL::text AS goal_title,
134
+ g.created_by::text AS created_by,
135
+ ${GOAL_ROT_CLOCK} AS last_touched_at,
136
+ (g.attention_watered_at IS NOT NULL) AS ever_watered
137
+ FROM goals g
138
+ WHERE g.status = ANY($5::text[])
139
+ AND ${GOAL_ROT_CLOCK} < (SELECT at FROM cutoff)
140
+ AND ($4::boolean OR g.created_by = $3::bigint)
141
+ ) rot
142
+ ORDER BY last_touched_at ASC, tier ASC, id ASC
143
+ LIMIT $6`,
144
+ [days, TASK_ROTTABLE_STATUSES, id, canSeeAll, GOAL_ROTTABLE_STATUSES, cap]
145
+ );
146
+
147
+ const cutoffAt = new Date(Date.now() - days * 24 * 60 * 60 * 1000).toISOString();
148
+ return {
149
+ rows: rows.map((r) => ({ ...r, verbs: r.tier === 'task' ? TASK_VERBS : GOAL_VERBS })),
150
+ rotDays: days,
151
+ cutoff: cutoffAt,
152
+ };
153
+ }
154
+
155
+ // water(tier, id, builderId) — the clock reset, and the only write this file makes.
156
+ //
157
+ // IDEMPOTENT BY NATURE: watering an already-watered thing just moves the mark
158
+ // forward again. There is no "already watered" error to handle, because a second
159
+ // watering is a perfectly sensible thing for a human to do.
160
+ //
161
+ // Returns the new mark, or null when no such open row exists — the caller turns that
162
+ // into a 404. Note the status filter: watering something that is already shipped,
163
+ // abandoned or archived is refused, because the clock on a finished thing means
164
+ // nothing and silently succeeding would tell the caller a lie.
165
+ async function water(tier, id, builderId) {
166
+ const rowId = Number(id);
167
+ if (!Number.isInteger(rowId) || rowId <= 0) return null;
168
+ const by = Number.isInteger(Number(builderId)) && Number(builderId) > 0 ? Number(builderId) : null;
169
+
170
+ const table = tier === 'goal' ? 'goals' : 'tasks';
171
+ const statuses = tier === 'goal' ? GOAL_ROTTABLE_STATUSES : TASK_ROTTABLE_STATUSES;
172
+ const { rows } = await pool.query(
173
+ `UPDATE ${table}
174
+ SET attention_watered_at = now(),
175
+ attention_watered_by = $2
176
+ WHERE id = $1
177
+ AND status = ANY($3::text[])
178
+ RETURNING id::text AS id, attention_watered_at, attention_watered_by::text AS attention_watered_by`,
179
+ [rowId, by, statuses]
180
+ );
181
+ return rows[0] || null;
182
+ }
183
+
184
+ // createdBy(tier, id) — who a rot line is addressed to, for the authorization check
185
+ // on `water`. Returns { created_by, status } or null. Separate from water() so the
186
+ // route can refuse BEFORE writing, rather than writing and then discovering it
187
+ // should not have.
188
+ async function createdBy(tier, id) {
189
+ const rowId = Number(id);
190
+ if (!Number.isInteger(rowId) || rowId <= 0) return null;
191
+ const table = tier === 'goal' ? 'goals' : 'tasks';
192
+ const { rows } = await pool.query(
193
+ `SELECT created_by::text AS created_by, status FROM ${table} WHERE id = $1`,
194
+ [rowId]
195
+ );
196
+ return rows[0] || null;
197
+ }
198
+
199
+ module.exports = {
200
+ TASK_ROTTABLE_STATUSES,
201
+ GOAL_ROTTABLE_STATUSES,
202
+ TASK_ROT_CLOCK,
203
+ GOAL_ROT_CLOCK,
204
+ TASK_VERBS,
205
+ GOAL_VERBS,
206
+ MAX_ROT_ROWS,
207
+ rottingFor,
208
+ water,
209
+ createdBy,
210
+ };
@@ -0,0 +1,160 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/routes/rot.js — GET /inbox/rotting + the two water writes
4
+ // (task 1003279, goal 1000072, criterion legibility-sort-and-staleness).
5
+ //
6
+ // WHY. Goal 1000072's rotting half: work must not decay in silence. This is the read
7
+ // the hall's needs-attention inbox calls to find out what is rotting, plus the one
8
+ // write the owner's fourth verb needs — "water": leave it alone and reset its clock.
9
+ //
10
+ // The other three verbs (prune, kill, prioritise) are NOT here, and their absence is
11
+ // the design. They reuse the routes that already do those jobs — promote, demote,
12
+ // abandon, archive — which is the precedent the nod slice set in this same card
13
+ // (task 1003066). A verb that reuses a route reuses its permission, so three
14
+ // quarters of this feature adds no new authority to the system at all.
15
+ //
16
+ // The route is only the doorway; ../rot.js owns the SQL and the definition of rot.
17
+ // Keep it that way — this file must never grow a query.
18
+ //
19
+ // MODULE BOUNDARY: reaches core only through the published doorway
20
+ // `../../../src/module-api`; `../rot` is an intra-module sibling.
21
+
22
+ const express = require('express');
23
+ const api = require('../../../src/module-api');
24
+ const rot = require('../rot');
25
+
26
+ // The module's namespaced pino child (task 1003208) rather than console.* — a
27
+ // structured line is attributable and rides ADR 0022's redaction; a bare console
28
+ // call is neither, which is why the quality ratchet counts them.
29
+ const log = api.logger('lifecycle');
30
+
31
+ // Who may see rot that is addressed to someone else.
32
+ //
33
+ // The owner's note addresses each item "to its creator, and to the owner". The
34
+ // creator half is an ownership fact the query already applies. This atom is the
35
+ // other half — `builder.roster.read` is "may read another builder's record off the
36
+ // roster", which is exactly what seeing someone else's rot is, and it is the atom
37
+ // /builders/:id/active-claims and /builders/:id/credits already carry for the same
38
+ // act. ADR 0157 delegated it archon→metic; the owner holds it a fortiori, so
39
+ // "addressed to the owner" is satisfied without hardcoding an Archon identity
40
+ // anywhere (the rule audit-authorship.sh enforces).
41
+ const SEE_ALL_ROT = 'builder.roster.read';
42
+
43
+ // Programmatic permission check — the middleware form cannot be used here because a
44
+ // CREATOR with no atom at all must still be allowed through, and a gate mounted in
45
+ // front of the handler would refuse them before the ownership fact is known.
46
+ // Returns false when the governance port is absent or throws: authority that cannot
47
+ // be established is a denial, never a pass (ADR 0016 fail-closed).
48
+ async function holdsSeeAll(builderId) {
49
+ try {
50
+ const governance = api.resolveOptional('government');
51
+ if (!governance || typeof governance.builderHasPermissions !== 'function') return false;
52
+ return await governance.builderHasPermissions(Number(builderId), [SEE_ALL_ROT]);
53
+ } catch (err) {
54
+ log.error(`rot: permission resolve failed — denying fail-closed: ${err && err.message}`);
55
+ return false;
56
+ }
57
+ }
58
+
59
+ module.exports = function buildRotRouter() {
60
+ const router = express.Router();
61
+
62
+ // GET /inbox/rotting — what is quietly rotting, for this caller.
63
+ //
64
+ // rank: any-builder — own-scoped by default. The builder id comes from the
65
+ // authenticated session and is never read off the request, so a caller can only
66
+ // ever widen the result by HOLDING an atom, never by naming a different builder.
67
+ // A Xenos sees exactly the things they created and nothing else.
68
+ //
69
+ // ?rot_days= is a LENS, not a setting: it answers "what would 7 days show me?"
70
+ // without reconfiguring the project, and is never written back. The response
71
+ // reports the timer and where the number came from, so a reader can always tell a
72
+ // lens apart from the project's real answer (the src/stale-timer.js receipt
73
+ // convention — a number with no provenance is what let 6h and 24h coexist).
74
+ router.get('/inbox/rotting', api.requireBuilder, async (req, res) => {
75
+ try {
76
+ const builderId = Number(req.builder.id);
77
+ const timer = await api.resolveRotDays({ override: req.query.rot_days });
78
+ const canSeeAll = await holdsSeeAll(builderId);
79
+ const { limit } = api.parsePagination(req, { defaultLimit: 50, maxLimit: rot.MAX_ROT_ROWS });
80
+ const found = await rot.rottingFor({ builderId, canSeeAll, rotDays: timer.days, limit });
81
+
82
+ // A rot read is a clock comparison — a cached one is a wrong one the moment
83
+ // somebody waters a row.
84
+ res.set('Cache-Control', 'no-store');
85
+ res.json({
86
+ rows: found.rows,
87
+ count: found.rows.length,
88
+ scope: canSeeAll ? 'all' : 'mine',
89
+ timer: {
90
+ rot_days: timer.days,
91
+ source: timer.source,
92
+ // Present ONLY when a configured value could not be used. The inbox shows
93
+ // it to the person who can fix it rather than 500-ing (the reporting-
94
+ // degrades half of ADR 0226's asymmetric fail posture).
95
+ invalid: timer.invalid,
96
+ describe: api.projectSettings.describeRotTimer(timer),
97
+ },
98
+ cutoff: found.cutoff,
99
+ });
100
+ } catch (err) {
101
+ log.error(`GET /inbox/rotting: ${err && err.message}`);
102
+ res.fail('rot_read_failed', { status: 500, message: 'internal error' });
103
+ }
104
+ });
105
+
106
+ // The two water writes share everything but their tier, so they share a handler.
107
+ // Two separate routes rather than one /:tier/ route on purpose: an explicit path
108
+ // per tier is what lets the route-rank auditor and the generated OpenAPI describe
109
+ // each one, and it keeps a tier name out of the URL where a typo would 404 into
110
+ // something confusing.
111
+ //
112
+ // rank: any-builder + an ownership-or-atom check in the handler. Watering is the
113
+ // gentlest of the four verbs — it changes no status and destroys nothing; it says
114
+ // "I looked, it is fine, ask me later". Gating it any harder than seeing the line
115
+ // would mean showing a builder a button they are not allowed to press.
116
+ function waterHandler(tier) {
117
+ return async (req, res) => {
118
+ const id = api.parseId(req, res, { code: tier === 'goal' ? 'bad_goal_id' : 'bad_task_id', positiveInt: true });
119
+ if (id === null) return;
120
+ try {
121
+ const row = await rot.createdBy(tier, id);
122
+ if (!row) return res.fail(tier === 'goal' ? 'goal_not_found' : 'task_not_found', 404);
123
+
124
+ // Authorized iff the line was addressed to you: you created the thing, or you
125
+ // hold the atom that lets you see other people's rot.
126
+ const mine = row.created_by != null && String(row.created_by) === String(req.builder.id);
127
+ if (!mine && !(await holdsSeeAll(Number(req.builder.id)))) {
128
+ return res.fail('permission_forbidden', {
129
+ status: 403,
130
+ message: `only the ${tier}'s creator, or a builder holding ${SEE_ALL_ROT}, can water it`,
131
+ });
132
+ }
133
+
134
+ const watered = await rot.water(tier, id, Number(req.builder.id));
135
+ if (!watered) {
136
+ // The row exists but is not in a rottable status — already shipped,
137
+ // abandoned or archived. Say so rather than succeeding vacuously: a clock
138
+ // on a finished thing means nothing, and a silent 200 would tell the caller
139
+ // their click did something it did not.
140
+ return res.fail('not_rottable', {
141
+ status: 409,
142
+ message: `${tier} ${id} is '${row.status}' — a finished ${tier} has no rot clock to reset`,
143
+ });
144
+ }
145
+ res.json({ ok: true, tier, id: watered.id, watered_at: watered.attention_watered_at });
146
+ } catch (err) {
147
+ log.error(`POST /${tier}s/:id/water: ${err && err.message}`);
148
+ res.fail('water_failed', { status: 500, message: 'internal error' });
149
+ }
150
+ };
151
+ }
152
+
153
+ // POST /tasks/:id/water — reset a rotting task's clock.
154
+ router.post('/tasks/:id/water', api.requireBuilder, waterHandler('task'));
155
+
156
+ // POST /goals/:id/water — reset a rotting goal's clock.
157
+ router.post('/goals/:id/water', api.requireBuilder, waterHandler('goal'));
158
+
159
+ return router;
160
+ };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.485",
3
+ "version": "1.19.486",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.485",
9
+ "version": "1.19.486",
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.485",
3
+ "version": "1.19.486",
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",
@@ -515,6 +515,15 @@ const KERNEL_FILES = [
515
515
  // this roster would read as a kernel->non-kernel leak; it imports only
516
516
  // instance-config + node built-ins, so joining costs nothing.
517
517
  'src/stale-timer.js',
518
+ // task 1003279 (goal 1000072, ADR 0232): the project's live knob store and the ROT
519
+ // timer resolver over it — the same category as stale-timer.js directly above (host
520
+ // configuration a module reads rather than hardcodes), differing only in living in
521
+ // the DB instead of the env, because task 1003280 requires that knob be changeable
522
+ // from a page without a redeploy. The doorway re-exports it, so leaving it off this
523
+ // roster reads as a kernel->non-kernel leak; it imports only pool.js (itself on this
524
+ // roster) + node built-ins, so joining costs nothing and buys the import-direction
525
+ // guarantee on the file every future project knob will be read through.
526
+ 'src/bongos/project-settings.js',
518
527
  // BV1.R86: the git-remote/slug + publish-target identity the doorway exposes
519
528
  // (api.repoInfo) for the carved lifecycle github-push/conflict-resolve. It is
520
529
  // KERNEL (already in the module-scope-map kernel key) and imports only the