@bongos/core 1.19.646 → 1.19.648

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.
@@ -254,6 +254,117 @@ function sparsePathsForScopeKeys(scopeKeys, moduleScopeMap) {
254
254
  return [...base, ...extra];
255
255
  }
256
256
 
257
+ // ---------------------------------------------------------------------------
258
+ // PURE: builder-requested WIDENING (task 1003087 / idea 1000793)
259
+ // ---------------------------------------------------------------------------
260
+
261
+ // A builder sometimes needs a directory their task scope did not pull — to run
262
+ // the full suite, to read a caller, to regenerate a TREE-DEPENDENT artifact. The
263
+ // obvious move, `git sparse-checkout add <path>`, does not survive: the */10
264
+ // source-fetch cron re-applies the SERVER-computed set and deletes the addition,
265
+ // including WHILE A COMMAND IS RUNNING (a live suite died mid-run with
266
+ // MODULE_NOT_FOUND that read exactly like a code bug; a box-committed
267
+ // gen-repo-map regen off a narrow tree reds CI's freshness gate — learning
268
+ // 1000192, which stranded PR #254).
269
+ //
270
+ // So the widening is recorded SERVER-side and unioned in HERE, which is what
271
+ // makes it tick-surviving: the cron asks this endpoint for the set on every tick,
272
+ // so it gets the union too. Nothing in the instance-owned fetch script changes —
273
+ // which matters, because the neutral core does not ship it (ADR 0150).
274
+
275
+ // Bounds. Small on purpose: this is an escape hatch for a handful of dirs, not a
276
+ // second scope system. Over the cap the extras are dropped, not an error — a
277
+ // truncated widen still fetches, and failing the source pull over it would be a
278
+ // far worse trade than materializing less.
279
+ const MAX_WIDEN_PATHS = 24;
280
+ const MAX_WIDEN_PATH_LEN = 200;
281
+
282
+ // withinAny(p, roots) — is p one of roots, or underneath one? Cone mode
283
+ // materializes a directory and everything below it, so "already covered" and
284
+ // "inside the allowed surface" are the same ancestor walk.
285
+ function withinAny(p, roots) {
286
+ let q = p;
287
+ for (;;) {
288
+ if (roots.includes(q)) return true;
289
+ const cut = q.lastIndexOf('/');
290
+ if (cut < 0) return false;
291
+ q = q.slice(0, cut);
292
+ }
293
+ }
294
+
295
+ // normalizeWidenPath — repo-relative POSIX directory, or null when it is not one.
296
+ //
297
+ // This is a path that reaches `git sparse-checkout set` on a box, so it is
298
+ // validated as INPUT rather than trusted: absolute paths, Windows drive letters,
299
+ // '..' traversal and empty segments are all refused rather than normalized away,
300
+ // because "cleaned up into something that still works" is how a traversal becomes
301
+ // a shrug. Backslashes are folded to '/' first so a Windows-typed path is judged
302
+ // on what it means, not on its separator.
303
+ function normalizeWidenPath(raw) {
304
+ if (typeof raw !== 'string') return null;
305
+ // BACKSLASH by char code, not as a literal: this file is edited through
306
+ // tooling that has silently collapsed a doubled backslash before, and a
307
+ // separator fold that quietly stops folding is invisible until a Windows
308
+ // path slips through as one long segment.
309
+ const BACKSLASH = String.fromCharCode(92);
310
+ let p = raw.trim().split(BACKSLASH).join('/');
311
+ while (p.startsWith('./')) p = p.slice(2);
312
+ p = p.replace(/\/+/g, '/').replace(/\/+$/, '');
313
+ if (!p || p === '.' || p.length > MAX_WIDEN_PATH_LEN) return null;
314
+ if (p.startsWith('/') || /^[A-Za-z]:/.test(p)) return null;
315
+ const segs = p.split('/');
316
+ if (!segs.every((s) => s && s !== '.' && s !== '..' && /^[A-Za-z0-9._-]+$/.test(s))) return null;
317
+ return p;
318
+ }
319
+
320
+ // admissibleWidenPaths — the widen requests this builder's RANK actually permits,
321
+ // normalized, deduped, capped and sorted.
322
+ //
323
+ // THIS IS THE SECURITY BOUNDARY OF THE FEATURE, so it is stated plainly: widening
324
+ // changes what is MATERIALIZED on a box, and rank decides how much of the repo a
325
+ // box may hold at all (ADR 0031 section 9.2 — 'full' for Metic+, the 'starter'
326
+ // surface below that). Honoring an arbitrary widen would hand a Xenos the whole
327
+ // tree through a convenience flag, which is a rank escalation dressed as
328
+ // ergonomics. So:
329
+ // - a builder whose RANK scope is 'full' may widen anywhere (they are already
330
+ // entitled to the entire repo; the widen only decides what gets pulled);
331
+ // - anyone else may widen only INSIDE the starter surface. That is not a null
332
+ // grant: a task-scoped box is BASE union its modules, which is NARROWER than
333
+ // starter, so this is exactly the room to climb back to what their rank
334
+ // already allowed — and no further.
335
+ // The authoring wall is untouched either way. What a builder may CHANGE is still
336
+ // goal-scope-check against the module globs, never what happens to be on disk
337
+ // (ADR 0148).
338
+ function admissibleWidenPaths(widenPaths, rank) {
339
+ const unrestricted = boxScopeForRank(rank) === 'full';
340
+ const out = [];
341
+ const seen = new Set();
342
+ for (const raw of Array.isArray(widenPaths) ? widenPaths : []) {
343
+ const p = normalizeWidenPath(raw);
344
+ if (!p || seen.has(p)) continue;
345
+ if (!unrestricted && !withinAny(p, STARTER_SPARSE_PATHS)) continue;
346
+ seen.add(p);
347
+ out.push(p);
348
+ if (out.length >= MAX_WIDEN_PATHS) break;
349
+ }
350
+ return out.sort();
351
+ }
352
+
353
+ // withWidenPaths — union admitted widen dirs into a computed sparse set, skipping
354
+ // any the set already covers (cone mode pulls a dir and everything under it, so
355
+ // adding 'scripts/gds' when 'scripts' is present is noise in the git command and
356
+ // noise in the box's scope readout). A null set is the 'full' clone — there is
357
+ // nothing to widen into, so it passes through untouched.
358
+ function withWidenPaths(sparsePaths, admitted) {
359
+ if (!Array.isArray(sparsePaths) || !admitted || admitted.length === 0) return sparsePaths;
360
+ const out = [...sparsePaths];
361
+ for (const dir of admitted) {
362
+ if (withinAny(dir, out)) continue;
363
+ out.push(dir);
364
+ }
365
+ return out;
366
+ }
367
+
257
368
  // ---------------------------------------------------------------------------
258
369
  // PURE: the per-fetch access decision (consumed by GET /box/source-access)
259
370
  // ---------------------------------------------------------------------------
@@ -295,7 +406,7 @@ function sparsePathsForScopeKeys(scopeKeys, moduleScopeMap) {
295
406
  // { allowed:false, reason:'BUILDER_INACTIVE' }
296
407
  // { allowed:false, reason:'INSUFFICIENT_RANK', required, actual }
297
408
  // { allowed:true, scope, spec }
298
- function decideSourceAccess({ rank, status, floor = DEFAULT_PROVISION_FLOOR, scopeKeys, hasActiveClaims, moduleScopeMap } = {}) {
409
+ function decideSourceAccess({ rank, status, floor = DEFAULT_PROVISION_FLOOR, scopeKeys, hasActiveClaims, moduleScopeMap, widenPaths } = {}) {
299
410
  if (status && status !== 'active') {
300
411
  return { allowed: false, reason: 'BUILDER_INACTIVE' };
301
412
  }
@@ -307,23 +418,32 @@ function decideSourceAccess({ rank, status, floor = DEFAULT_PROVISION_FLOOR, sco
307
418
  actual: rank ?? null,
308
419
  };
309
420
  }
421
+ // The builder's own widening, narrowed to what their rank already reaches
422
+ // (task 1003087). Computed once here so every sparse branch below unions the
423
+ // SAME set — a widen that applied to one scope and not another would be a
424
+ // surprise that only surfaces when a claim changes.
425
+ const widen = admissibleWidenPaths(widenPaths, rank);
426
+
310
427
  // Claim-driven task scope (present → the box asks for exactly its work's code).
311
428
  if (Array.isArray(scopeKeys)) {
312
429
  if (scopeKeys.length > 0) {
313
- const sparsePaths = sparsePathsForScopeKeys(scopeKeys, moduleScopeMap);
430
+ const sparsePaths = withWidenPaths(sparsePathsForScopeKeys(scopeKeys, moduleScopeMap), widen);
314
431
  return { allowed: true, scope: 'task', spec: { scope: 'task', mode: 'sparse', sparsePaths } };
315
432
  }
316
433
  // Empty. A builder mid-claim whose goals declare no wall gets the rank scope,
317
434
  // NOT a stripped box — the planning gap is not theirs to pay for.
318
435
  if (!hasActiveClaims) {
319
- const sparsePaths = sparsePathsForScopeKeys([], moduleScopeMap);
436
+ const sparsePaths = withWidenPaths(sparsePathsForScopeKeys([], moduleScopeMap), widen);
320
437
  return { allowed: true, scope: 'base', spec: { scope: 'base', mode: 'sparse', sparsePaths } };
321
438
  }
322
439
  }
323
440
  // No claim signal (no lifecycle port on this instance), or claims with an
324
441
  // undeclared goal wall: the pre-ADR-0148 rank fallback.
325
442
  const scope = boxScopeForRank(rank);
326
- return { allowed: true, scope, spec: cloneSpecForScope(scope) };
443
+ const spec = cloneSpecForScope(scope);
444
+ // 'full' carries sparsePaths null (the whole repo) — withWidenPaths passes it
445
+ // through, because there is no sparse set to widen INTO.
446
+ return { allowed: true, scope, spec: { ...spec, sparsePaths: withWidenPaths(spec.sparsePaths, widen) } };
327
447
  }
328
448
 
329
449
  // scopeMetadata — PURE. The credential-FREE projection of a source-access
@@ -470,6 +590,10 @@ module.exports = {
470
590
  boxCanHoldSource,
471
591
  cloneSpecForScope,
472
592
  sparsePathsForScopeKeys, // ADR 0148 — BASE ∪ the claims' module dirs (cone-mode)
593
+ MAX_WIDEN_PATHS, // task 1003087 — the builder-widening cap
594
+ normalizeWidenPath, // task 1003087 — repo-relative dir, or null
595
+ admissibleWidenPaths, // task 1003087 — the widen requests this RANK permits
596
+ withWidenPaths, // task 1003087 — union a widen set into a sparse set
473
597
  scopeMetadata, // BV1.R100 — credential-free projection for GET /box/scope
474
598
  // pure — decisions
475
599
  decideSourceAccess,
@@ -397,6 +397,26 @@ function decideEnsureAction({ boxState, rank, status, blocked = false, floor = D
397
397
  // DB helpers (async). Each takes the pg pool (or a client mid-transaction).
398
398
  // ---------------------------------------------------------------------------
399
399
 
400
+ // setBoxWidenPaths — replace a box row's builder-requested widen set (task
401
+ // 1003087). Whole-set replace rather than add/remove SQL: the set is tiny and
402
+ // capped, the caller has already normalized and rank-filtered it, and a
403
+ // read-modify-write in one statement keeps two concurrent widens from
404
+ // interleaving into a half-applied list.
405
+ //
406
+ // Returns the stored array, or null when the builder has no box row — the
407
+ // caller turns that into a 404 rather than silently creating one, because a
408
+ // widen with no box to apply it to is a request that has not happened yet.
409
+ async function setBoxWidenPaths(db, builderId, paths) {
410
+ const { rows } = await db.query(
411
+ `UPDATE builder_boxes
412
+ SET widen_paths = $2::text[], updated_at = now()
413
+ WHERE builder_id = $1
414
+ RETURNING widen_paths`,
415
+ [builderId, Array.isArray(paths) ? paths : []]
416
+ );
417
+ return rows[0] ? rows[0].widen_paths : null;
418
+ }
419
+
400
420
  async function getBoxByBuilderId(db, builderId) {
401
421
  const { rows } = await db.query(
402
422
  `SELECT * FROM builder_boxes WHERE builder_id = $1`,
@@ -891,6 +911,7 @@ module.exports = {
891
911
  decideEnsureAction,
892
912
  // db
893
913
  getBoxByBuilderId,
914
+ setBoxWidenPaths,
894
915
  ensureBoxRow,
895
916
  listBoxes,
896
917
  recordEvent,
@@ -480,8 +480,16 @@ module.exports = function buildBoxRouter() {
480
480
  hasActiveClaims = undefined;
481
481
  }
482
482
  }
483
- const decision = boxAccess.decideSourceAccess({ rank, status, scopeKeys, hasActiveClaims, moduleScopeMap: api.moduleScopeMap });
483
+ // Read the box BEFORE deciding: its widen_paths are an INPUT to the sparse
484
+ // set now (task 1003087). A builder-requested directory is unioned in here,
485
+ // which is what makes it survive the */10 cron — the cron re-asks THIS
486
+ // endpoint every tick, so the union comes back with it instead of being
487
+ // deleted by the next `git sparse-checkout set`.
484
488
  const box = await boxes.getBoxByBuilderId(pool, req.builder.id);
489
+ const decision = boxAccess.decideSourceAccess({
490
+ rank, status, scopeKeys, hasActiveClaims, moduleScopeMap: api.moduleScopeMap,
491
+ widenPaths: box ? box.widen_paths : null,
492
+ });
485
493
  const auditDeny = (reason, extra) =>
486
494
  box && boxAccess.recordSourceEvent(pool, {
487
495
  box, builderId: req.builder.id, event: 'source_deny',
@@ -598,7 +606,13 @@ module.exports = function buildBoxRouter() {
598
606
  hasActiveClaims = undefined;
599
607
  }
600
608
  }
601
- const decision = boxAccess.decideSourceAccess({ rank, status, scopeKeys, hasActiveClaims, moduleScopeMap: api.moduleScopeMap });
609
+ // Same widen input as the real fetch this readout exists precisely so it
610
+ // cannot drift from what the box will actually hold.
611
+ const scopeBox = await boxes.getBoxByBuilderId(pool, req.builder.id);
612
+ const decision = boxAccess.decideSourceAccess({
613
+ rank, status, scopeKeys, hasActiveClaims, moduleScopeMap: api.moduleScopeMap,
614
+ widenPaths: scopeBox ? scopeBox.widen_paths : null,
615
+ });
602
616
  if (!decision.allowed) {
603
617
  return res.fail('SOURCE_ACCESS_REVOKED', 403, {
604
618
  reason: decision.reason,
@@ -753,6 +767,98 @@ module.exports = function buildBoxRouter() {
753
767
  }
754
768
  });
755
769
 
770
+ // GET /box/widen — the caller's own recorded widen set (task 1003087).
771
+ // rank: any authenticated builder, own resource. Reports what is STORED plus
772
+ // what is currently ADMITTED, because those differ whenever a request sits
773
+ // outside the builder's rank scope — showing only the stored list would let a
774
+ // Xenos believe a widen took effect that the fetch quietly drops.
775
+ router.get('/box/widen', auth.requireBuilder, async (req, res) => {
776
+ try {
777
+ const builder = await gdsDb.getBuilderById(req.builder.id);
778
+ const box = await boxes.getBoxByBuilderId(pool, req.builder.id);
779
+ if (!box) return res.fail('no_box', 404, { message: 'no dev box for this builder' });
780
+ const stored = Array.isArray(box.widen_paths) ? box.widen_paths : [];
781
+ const admitted = boxAccess.admissibleWidenPaths(stored, builder ? builder.rank : null);
782
+ res.json({
783
+ widen_paths: stored,
784
+ admitted,
785
+ ignored: stored.filter((p) => !admitted.includes(boxAccess.normalizeWidenPath(p))),
786
+ max: boxAccess.MAX_WIDEN_PATHS,
787
+ });
788
+ } catch (err) {
789
+ log.error('[gds] GET /box/widen', err);
790
+ res.fail('widen_read_failed', { status: 500, message: 'internal error' });
791
+ }
792
+ });
793
+
794
+ // POST /box/widen — record extra sparse-checkout directories that must SURVIVE
795
+ // the */10 source-fetch (task 1003087, idea 1000793). rank: any authenticated
796
+ // builder, OWN box (builder_id-scoped in SQL, so no id from the body can point
797
+ // this at someone else's box).
798
+ //
799
+ // Deliberately NOT allowBoxScope. The on-box crons read source-access with a
800
+ // box-scoped session (ADR 0053); this is a WRITE that changes what the box
801
+ // pulls forever after, so it takes the builder's own session — the same posture
802
+ // that keeps a stolen box token from re-scoping the checkout it was stolen from.
803
+ //
804
+ // add/remove are applied to the stored set and the result is re-normalized, so
805
+ // the endpoint is idempotent: widening twice is not an error, and neither is
806
+ // removing something that was never there.
807
+ //
808
+ // A path outside the caller's rank scope is ACCEPTED into storage but reported
809
+ // in `ignored` rather than refused. That is the honest shape: rank can change,
810
+ // and a builder promoted to Metic should find the widen they asked for as a
811
+ // Xenos simply start working, instead of having been silently discarded months
812
+ // earlier. What it can never do is take effect early — admissibleWidenPaths is
813
+ // re-evaluated against the LIVE rank on every single fetch.
814
+ router.post('/box/widen', auth.requireBuilder, async (req, res) => {
815
+ if (validateOrRespond(req, res, {
816
+ add: { type: 'array' },
817
+ remove: { type: 'array' },
818
+ clear: { type: 'boolean' },
819
+ })) return;
820
+ try {
821
+ const box = await boxes.getBoxByBuilderId(pool, req.builder.id);
822
+ if (!box) return res.fail('no_box', 404, { message: 'no dev box for this builder' });
823
+ const builder = await gdsDb.getBuilderById(req.builder.id);
824
+
825
+ const current = Array.isArray(box.widen_paths) ? box.widen_paths : [];
826
+ const next = new Set(req.body && req.body.clear === true ? [] : current);
827
+
828
+ const rejected = [];
829
+ for (const raw of Array.isArray(req.body && req.body.add) ? req.body.add : []) {
830
+ const p = boxAccess.normalizeWidenPath(raw);
831
+ if (!p) { rejected.push(String(raw).slice(0, 120)); continue; }
832
+ next.add(p);
833
+ }
834
+ for (const raw of Array.isArray(req.body && req.body.remove) ? req.body.remove : []) {
835
+ const p = boxAccess.normalizeWidenPath(raw);
836
+ if (p) next.delete(p);
837
+ }
838
+ if (rejected.length) {
839
+ return res.fail('bad_widen_path', 400, {
840
+ rejected,
841
+ message: 'a widen path must be a repo-relative directory: no absolute paths, no "..", no drive letters',
842
+ });
843
+ }
844
+
845
+ const stored = [...next].sort().slice(0, boxAccess.MAX_WIDEN_PATHS);
846
+ const saved = await boxes.setBoxWidenPaths(pool, req.builder.id, stored);
847
+ if (saved === null) return res.fail('no_box', 404, { message: 'no dev box for this builder' });
848
+ const admitted = boxAccess.admissibleWidenPaths(saved, builder ? builder.rank : null);
849
+ res.json({
850
+ ok: true,
851
+ widen_paths: saved,
852
+ admitted,
853
+ ignored: saved.filter((p) => !admitted.includes(p)),
854
+ max: boxAccess.MAX_WIDEN_PATHS,
855
+ });
856
+ } catch (err) {
857
+ log.error('[gds] POST /box/widen', err);
858
+ res.fail('widen_failed', { status: 500, message: 'internal error' });
859
+ }
860
+ });
861
+
756
862
  // GET /box/authorized-keys — the box pulls its builder's authorized_keys.
757
863
  // rank: any authenticated builder (own resource) — but the ALLOW/DENY is decided
758
864
  // INSIDE from the caller's LIVE rank+status (boxAccess.decideSourceAccess), NOT a
@@ -308,7 +308,7 @@ async function findTaskBySourceRef({ source, sourceRef } = {}, deps = {}) {
308
308
 
309
309
  const TASK_SETTER_COLUMNS = new Set([
310
310
  'kind', 'discipline', 'newcomer_friendly', 'security_sensitive', 'parallel_safe', 'automation_tag',
311
- 'needs_migration', 'module_key', 'description', 'title', 'priority',
311
+ 'needs_migration', 'module_key', 'description', 'title', 'priority', 'value_summary',
312
312
  ]);
313
313
 
314
314
  async function updateTaskColumn(id, column, value) {
@@ -383,6 +383,17 @@ async function updateTaskTitle(id, title) { return updateTaskColumn(id, 'title',
383
383
  // 1 = most urgent, 5 = least; null clears it back to unset (weighted as 5).
384
384
  async function updateTaskPriority(id, priority) { return updateTaskColumn(id, 'priority', priority); }
385
385
 
386
+ // task 1003817: the shipped-value sentence — what the hall, /status and the
387
+ // Discord #ship-news line all read. It was written in exactly ONE place, the
388
+ // claim-resolve route, so a re-grade (which has no claim — regradeMain
389
+ // synthesizes a claimLike precisely because the claim is gone) could correct
390
+ // the summary in the PR and the merge commit and still leave the RECORD holding
391
+ // the first ship's sentence forever. This is the write vector that path uses.
392
+ // Rewritable, never blankable: the route refuses an empty or whitespace-only
393
+ // value, which is this column's form of the COALESCE/NULLIF posture db-ship.js
394
+ // keeps on every other value_summary write.
395
+ async function updateTaskValueSummary(id, valueSummary) { return updateTaskColumn(id, 'value_summary', valueSummary); }
396
+
386
397
 
387
398
  async function updateTaskDiscipline(id, discipline) { return updateTaskColumn(id, 'discipline', discipline); }
388
399
 
@@ -1037,5 +1048,6 @@ module.exports = {
1037
1048
  updateTaskRequiresRank,
1038
1049
  updateTaskSecuritySensitive,
1039
1050
  updateTaskStatus,
1051
+ updateTaskValueSummary,
1040
1052
  versionAcceptsNewWork,
1041
1053
  };
@@ -30,10 +30,16 @@
30
30
  // FACADE (task 1003204, goal 1000079). This file was a 5,748-line monolith; the
31
31
  // R03 de-monolith plan carved it into the twelve db-*.js units required below,
32
32
  // each cohesive and well under the 1,500-line ratchet. The carve was STRUCTURAL:
33
- // no function body changed, and the module.exports block at the bottom is the
34
- // original one verbatim — so every `require('./db')` call site, and every name
33
+ // no function body changed, and every `require('./db')` call site, and every name
35
34
  // reachable through it, behaves exactly as before.
36
35
  //
36
+ // The module.exports block was the carve's ORIGINAL list verbatim until task
37
+ // 1003817, which replaced the db-tasks.js third of it with a spread — see the
38
+ // long note above `const dbTasks` for why, and tests/lifecycle_facade_surface.mjs
39
+ // for the assertion that keeps that surface identical. The other eleven units
40
+ // are still re-listed by name; giving one of them a spread means giving it an
41
+ // omit list and a surface pin too, because a bare spread publishes internals.
42
+ //
37
43
  // The units form a DAG (no require cycles), in dependency order:
38
44
  // db-shared → db-rank-authz → {db-versions, db-goals, db-tasks} → db-analytics
39
45
  // → db-ship → db-claims; db-deps-criteria and db-claim-reads stand alone.
@@ -100,38 +106,37 @@ const {
100
106
  setGoalVisibility,
101
107
  transferGoalOwnership,
102
108
  } = require('./db-goals.js');
103
- const {
104
- REWARD_SUGGESTION_CAP,
105
- createCriterion,
106
- createTask,
107
- findTaskBySourceRef,
108
- generalGoalIdForVersion,
109
- getTask,
110
- listActiveClaims,
111
- listClaimableTasks,
112
- listTasks,
113
- rankCrossDisciplineRecommendations,
114
- rewardGateAssignment,
115
- rewardGateError,
116
- rewardMissing,
117
- setCreditsReward,
118
- suggestCreditsReward,
119
- taskTouchesProtectedPath,
120
- updateTaskAutomationTag,
121
- updateTaskDescription,
122
- updateTaskTitle,
123
- updateTaskDiscipline,
124
- updateTaskGoal,
125
- updateTaskKind,
126
- updateTaskModuleKey,
127
- updateTaskNeedsMigration,
128
- updateTaskNewcomerFriendly,
129
- updateTaskParallelSafe,
130
- updateTaskPriority,
131
- updateTaskRequiresRank,
132
- updateTaskSecuritySensitive,
133
- updateTaskStatus,
134
- } = require('./db-tasks.js');
109
+ // task 1003817: db-tasks.js's exports ARE this facade's task surface, so they are
110
+ // spread into module.exports below instead of being re-listed name by name.
111
+ //
112
+ // WHY THE TWIN LIST HAD TO GO. What stood here was 31 sorted names destructured
113
+ // from db-tasks.js purely so module.exports could name them again — not one was
114
+ // called anywhere inside this file. Two hand-maintained copies of one list is
115
+ // real duplication, and the `duplicate_window_count` ratchet reads it as such:
116
+ // the lists were 29 identical normalized lines, one under the 30-line window, so
117
+ // task 1003750 adding `updateTaskPriority` to both pushed the run to 30 and cost
118
+ // an owner-decided baseline raise (59 → 60). That decision is recorded in
119
+ // config/fitness-baselines.json along with the instruction for whoever added the
120
+ // NEXT db-tasks function — this task, adding updateTaskValueSummary: fix the
121
+ // facade, do not raise the baseline again. Spreading removes the whole class, so
122
+ // the next setter costs nothing.
123
+ //
124
+ // TASK_FACADE_OMIT is what keeps this surface-PRESERVING rather than quietly
125
+ // wider. Two of db-tasks.js's 33 exports were deliberately never re-exported
126
+ // here, and a bare spread would publish them — which ADR 0091 §3 treats as a
127
+ // real surface change, not a tidy-up. Naming them means dropping one has to be
128
+ // somebody's decision. tests/lifecycle_facade_surface.mjs pins the resulting key
129
+ // set, so this can never silently widen or narrow.
130
+ const dbTasks = require('./db-tasks.js');
131
+ const TASK_FACADE_OMIT = new Set([
132
+ // Internal to db-tasks.js's own queries — a SQL fragment, not a callable.
133
+ 'CREDITS_AWARDED_SUBQUERY',
134
+ // The version-gate predicate; routes reach it through db-versions, not here.
135
+ 'versionAcceptsNewWork',
136
+ ]);
137
+ const dbTasksFacade = Object.fromEntries(
138
+ Object.entries(dbTasks).filter(([name]) => !TASK_FACADE_OMIT.has(name))
139
+ );
135
140
  const {
136
141
  addDependency,
137
142
  addTaskCriterion,
@@ -214,6 +219,7 @@ const {
214
219
  } = require('./db-claims.js');
215
220
 
216
221
  module.exports = {
222
+ ...dbTasksFacade,
217
223
  tallyPeerVotes,
218
224
  PEER_VOTE_KARMA_LAG_DAYS,
219
225
  RANKS,
@@ -263,19 +269,6 @@ module.exports = {
263
269
  listCriteriaForGoal,
264
270
  archiveGoalWithDispositions,
265
271
  openTasksInGoal,
266
- createTask,
267
- generalGoalIdForVersion,
268
- findTaskBySourceRef,
269
- getTask,
270
- listTasks,
271
- updateTaskGoal,
272
- createCriterion,
273
- updateTaskKind,
274
- updateTaskDescription,
275
- updateTaskTitle,
276
- updateTaskDiscipline,
277
- updateTaskNewcomerFriendly,
278
- updateTaskRequiresRank,
279
272
  xenosClaimAllowed,
280
273
  rankAllowsTask,
281
274
  REQUIRES_RANK_TIER,
@@ -290,31 +283,14 @@ module.exports = {
290
283
  LIVE_RANK_LADDER,
291
284
  deriveRequiredRank,
292
285
  highestRank,
293
- rewardMissing,
294
- suggestCreditsReward,
295
- REWARD_SUGGESTION_CAP,
296
- rewardGateAssignment,
297
- rewardGateError,
298
- setCreditsReward,
299
286
  THETES_GRADUATION_THRESHOLD,
300
287
  qualifiesForThetesGraduation,
301
288
  countShippedTasksForBuilder,
302
289
  singleRungPromotionError,
303
290
  disambiguateWorktreeName,
304
291
  foldWorktreeName,
305
- updateTaskParallelSafe,
306
- updateTaskPriority,
307
- updateTaskSecuritySensitive,
308
- updateTaskAutomationTag,
309
- updateTaskNeedsMigration,
310
- updateTaskModuleKey,
311
- listClaimableTasks,
312
292
  capNewcomerTasks,
313
- taskTouchesProtectedPath, // SR-12 / G2 (task 994): autonomous-fit protected-path exclusion (pure, testable)
314
293
  decorateGoalMembership, // task 1003137: feed-side joinable flag (pure, testable)
315
- rankCrossDisciplineRecommendations,
316
- listActiveClaims,
317
- updateTaskStatus,
318
294
  getTaskDependencies,
319
295
  getTaskDependents,
320
296
  attachTaskDepSummaries,
@@ -326,6 +326,14 @@ module.exports = function registerTaskWriteRoutes(router) {
326
326
  // A whole-goal re-rank (needed-vs-nice) had no route and had to run as SQL.
327
327
  // null clears it back to unset; the 1..5 bounds mirror tasks_priority_check.
328
328
  priority: {}, // 1..5 or null — checked inline
329
+ // task 1003817: the shipped-value sentence. Written in exactly one place
330
+ // before this — the claim-resolve route — so `ship.js --regrade --summary`
331
+ // (no claim, by definition) could correct it in the PR and the merge commit
332
+ // and still leave the hall, /status and the Discord ship line reading the
333
+ // FIRST ship's text forever. Same task.edit permission and audit_log trail
334
+ // as the title/description/priority edits above; minLength 1 (plus the
335
+ // whitespace check below) so the record can be rewritten, never blanked.
336
+ value_summary: { type: 'string', minLength: 1, maxLength: LIMITS.VALUE_SUMMARY },
329
337
  // task 1002614 (map D-18): DECLARED so strict-mode admits the request far
330
338
  // enough to reach the deliberate status_not_patchable hint below (#514) —
331
339
  // the hint had been unreachable dead code behind unknown_field since the
@@ -354,6 +362,7 @@ module.exports = function registerTaskWriteRoutes(router) {
354
362
  const hasDescription = Object.prototype.hasOwnProperty.call(body, 'description');
355
363
  const hasTitle = Object.prototype.hasOwnProperty.call(body, 'title');
356
364
  const hasPriority = Object.prototype.hasOwnProperty.call(body, 'priority');
365
+ const hasValueSummary = Object.prototype.hasOwnProperty.call(body, 'value_summary');
357
366
  // #514: status is a lifecycle field, not a PATCH-able column — it moves only
358
367
  // through the dedicated transition routes. The #506 session tried
359
368
  // `PATCH status=ready`, got the generic no_supported_fields error, and had
@@ -365,8 +374,8 @@ module.exports = function registerTaskWriteRoutes(router) {
365
374
  if (Object.prototype.hasOwnProperty.call(body, 'status')) {
366
375
  return res.fail('status_not_patchable', { status: 400, message: STATUS_HINT });
367
376
  }
368
- if (!hasParent && !hasKind && !hasDiscipline && !hasNewcomerOrXenos && !hasParallelSafe && !hasSecuritySensitive && !hasAutomationTag && !hasNeedsMigration && !hasRequiresRank && !hasModuleKey && !hasGoalId && !hasDescription && !hasTitle && !hasPriority) {
369
- return res.fail('no_supported_fields', { status: 400, message: 'PATCH /tasks/:id supports parent_task_id, kind, discipline, title, description, priority, parallel_safe, security_sensitive, automation_tag, needs_migration, requires_rank, module_key, goal_id, and newcomer_friendly (alias: xenos_claimable). ' + STATUS_HINT });
377
+ if (!hasParent && !hasKind && !hasDiscipline && !hasNewcomerOrXenos && !hasParallelSafe && !hasSecuritySensitive && !hasAutomationTag && !hasNeedsMigration && !hasRequiresRank && !hasModuleKey && !hasGoalId && !hasDescription && !hasTitle && !hasPriority && !hasValueSummary) {
378
+ return res.fail('no_supported_fields', { status: 400, message: 'PATCH /tasks/:id supports parent_task_id, kind, discipline, title, description, value_summary, priority, parallel_safe, security_sensitive, automation_tag, needs_migration, requires_rank, module_key, goal_id, and newcomer_friendly (alias: xenos_claimable). ' + STATUS_HINT });
370
379
  }
371
380
  // ---- task 1002609 (G-22): settle EVERY gate and validation before the ----
372
381
  // ---- FIRST write. The previous shape validated-and-wrote per field in ----
@@ -393,6 +402,14 @@ module.exports = function registerTaskWriteRoutes(router) {
393
402
  return res.fail('bad_priority', { status: 400, message: `priority must be an integer ${TASK_FIELD_BOUNDS.priority.min}-${TASK_FIELD_BOUNDS.priority.max} (1 = most urgent), or null to clear` });
394
403
  }
395
404
  }
405
+ // task 1003817: minLength 1 refuses "" but not " ", and the write trims —
406
+ // so without this a whitespace-only body would blank the shipped-value
407
+ // record through the one field whose whole point is that it survives. An
408
+ // omitted field writes nothing at all (hasValueSummary is false), which is
409
+ // this route's form of the COALESCE/NULLIF posture db-ship.js keeps.
410
+ if (hasValueSummary && body.value_summary.trim() === '') {
411
+ return res.fail('bad_value_summary', { status: 400, message: 'value_summary must be a non-empty sentence — the shipped-value record can be rewritten, never blanked. Omit the field to leave it unchanged.' });
412
+ }
396
413
  if (hasSecuritySensitive) {
397
414
  // task 990: explicit Archon-only-broadcast flag. Boolean only (no null) —
398
415
  // it gates whether ship.js routes the broadcast to the Archon-only
@@ -556,6 +573,15 @@ module.exports = function registerTaskWriteRoutes(router) {
556
573
  return res.fail('patch_failed', { status: 500, message: 'internal error' });
557
574
  }
558
575
  }
576
+ if (hasValueSummary) {
577
+ try {
578
+ updated = await db.updateTaskValueSummary(id, body.value_summary.trim());
579
+ if (!updated) return res.fail('task_not_found', 404);
580
+ } catch (err) {
581
+ log.error('[gds] PATCH /tasks/:id value_summary', err);
582
+ return res.fail('patch_failed', { status: 500, message: 'internal error' });
583
+ }
584
+ }
559
585
  if (hasGoalId) {
560
586
  try {
561
587
  updated = await db.updateTaskGoal(id, body.goal_id);
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.646",
3
+ "version": "1.19.648",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.646",
9
+ "version": "1.19.648",
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.646",
3
+ "version": "1.19.648",
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",