@bongos/core 1.19.332 → 1.19.333

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.
Files changed (58) hide show
  1. package/.bongos-core.json +160 -40
  2. package/docs/api/openapi.json +2 -2
  3. package/docs/api-reference.md +1 -1
  4. package/docs/copy-inventory.md +202 -202
  5. package/docs/copy-registry.json +384 -384
  6. package/modules/grading/grader-config.js +98 -0
  7. package/modules/grading/grader-model.js +150 -0
  8. package/modules/grading/grader-prompt.js +139 -0
  9. package/modules/grading/grader-score.js +706 -0
  10. package/modules/grading/grader-subagent.js +328 -0
  11. package/modules/grading/grader.js +5 -1287
  12. package/modules/hall-ui/public/builders.js +19 -1182
  13. package/modules/hall-ui/public/goals-page.js +11 -997
  14. package/modules/hall-ui/public/goals-render.js +1032 -0
  15. package/modules/hall-ui/public/goals.html +4 -0
  16. package/modules/hall-ui/public/hall-render.js +1211 -0
  17. package/modules/hall-ui/public/index.html +6 -0
  18. package/modules/lifecycle/routes/goal-route-authz.js +400 -0
  19. package/modules/lifecycle/routes/goals.js +1 -367
  20. package/modules/lifecycle/routes/task-route-helpers.js +219 -0
  21. package/modules/lifecycle/routes/task-write-routes.js +542 -0
  22. package/modules/lifecycle/routes/tasks.js +3 -696
  23. package/package-lock.json +2 -2
  24. package/package.json +1 -1
  25. package/scripts/gds/box-infra.js +280 -0
  26. package/scripts/gds/box.js +1 -245
  27. package/scripts/gds/fitness-checks-identity.js +320 -0
  28. package/scripts/gds/fitness-checks-vocabulary.js +230 -0
  29. package/scripts/gds/fitness-checks-write-validation.js +144 -0
  30. package/scripts/gds/fitness-lib.js +82 -0
  31. package/scripts/gds/fitness.js +55 -668
  32. package/scripts/gds/provision-config.js +204 -0
  33. package/scripts/gds/provision-net.js +221 -0
  34. package/scripts/gds/provision-repo.js +849 -0
  35. package/scripts/gds/provision-units.js +262 -0
  36. package/scripts/gds/provision.js +4 -1395
  37. package/src/bongos/auth-admission.js +264 -0
  38. package/src/bongos/auth-config.js +151 -0
  39. package/src/bongos/auth-discord.js +198 -0
  40. package/src/bongos/auth-github.js +336 -0
  41. package/src/bongos/auth-idp.js +330 -0
  42. package/src/bongos/auth.js +5 -1100
  43. package/src/module-api.js +3 -1
  44. package/tests/auth_admission_gate.mjs +3 -1
  45. package/tests/est_advisory.mjs +3 -1
  46. package/tests/goal_advisory.mjs +3 -2
  47. package/tests/goal_criterion_confirm_ui.mjs +6 -0
  48. package/tests/goal_subtitle.mjs +5 -2
  49. package/tests/goals_card_view.mjs +10 -3
  50. package/tests/grader_generated_provenance.mjs +3 -1
  51. package/tests/hall_palette.mjs +3 -1
  52. package/tests/helpers.mjs +58 -25
  53. package/tests/lib_sh_resolution.mjs +3 -1
  54. package/tests/membership_kind_lockstep.mjs +3 -1
  55. package/tests/migration_allocation.mjs +5 -2
  56. package/tests/patch_gate_ordering.mjs +3 -1
  57. package/tests/rank_tier_single_source.mjs +16 -3
  58. package/tests/reward_gate.mjs +3 -2
@@ -0,0 +1,542 @@
1
+ // modules/lifecycle/routes/task-write-routes.js — POST /tasks and
2
+ // PATCH /tasks/:id: the two heavyweight write handlers (creation with its goal
3
+ // routing + classification, and the field-by-field edit gate).
4
+ //
5
+ // Carved out of modules/lifecycle/routes/tasks.js by task 1003206 (goal 1000079,
6
+ // the R03 de-monolith plan) — together they were 510 of that router's lines.
7
+ // A STRUCTURAL move: the handlers are unchanged and still register on the SAME
8
+ // router instance, so the mounted paths, their order and their middleware are
9
+ // exactly as before. tasks.js calls this during buildTasksRouter().
10
+ //
11
+ // Registrar shape (not a sub-router): Express matches in registration order and
12
+ // '/tasks/:id' would shadow sibling literal paths if these were mounted
13
+ // separately. Handing the same router to a register function keeps the order the
14
+ // single file had.
15
+
16
+ 'use strict';
17
+
18
+ const path = require('node:path');
19
+ const api = require('../../../src/module-api');
20
+ const auth = api;
21
+ const db = require('../db');
22
+ const hierarchy = require('../hierarchy');
23
+ const { suggestCriteria } = require('../criterion-suggest');
24
+ const { buildGoalAdvisory } = require('../goal-advisory');
25
+ const { classifyTaskKind, VALID_KINDS, VALID_DISCIPLINES, VOTABLE_TASK_STATUSES, TASK_FIELD_BOUNDS } = require('../task-classifier');
26
+ const { isModuleKey, moduleKeys } = api.moduleScopeMap;
27
+ const { validateOrRespond, LIMITS, parseId } = api;
28
+ const { badKind, detectsMigration } = require('./task-route-helpers.js');
29
+
30
+ module.exports = function registerTaskWriteRoutes(router) {
31
+ // Task create/promote/edit (plan/scope mutation) is Metic+ as of ADR 0090
32
+ // (owner decision 2026-06-26): a Metic who can run idea-triage may also seed +
33
+ // shape tasks. Was Archon-only (task #106 / ADR 0016; three-rank model #360 /
34
+ // ADR 0018). A Xenos/Thetes still cannot author — they file ideas. Setting rank
35
+ // and closing versions stay Archon. The every-route audit is task #231 (V3.R06).
36
+ // rank: metic+archon — create work (plan/scope mutation); ADR 0090 (was ADR 0016/0018 archon).
37
+ router.post('/tasks', auth.requireBuilder, auth.requirePermission('task.create'), async (req, res) => {
38
+ if (validateOrRespond(req, res, {
39
+ version_id: { required: true, type: 'string', maxLength: 64 },
40
+ title: { required: true, type: 'string', maxLength: LIMITS.TITLE, minLength: 1 },
41
+ description: { type: 'string', maxLength: LIMITS.DESCRIPTION },
42
+ status: { type: 'string', maxLength: 32 },
43
+ touches: { type: 'array', maxItems: 500, itemsType: 'string' },
44
+ est_minutes: { type: 'integer', min: 0, max: 100000 }, // AGENT-minutes — see est-advisory.js (task 1003148)
45
+ est_cost_usd: { type: 'number', min: 0 },
46
+ manual_degree: { type: 'integer', min: TASK_FIELD_BOUNDS.manual_degree.min, max: TASK_FIELD_BOUNDS.manual_degree.max },
47
+ priority: { type: 'integer', min: TASK_FIELD_BOUNDS.priority.min, max: TASK_FIELD_BOUNDS.priority.max },
48
+ automation_tag: { type: 'string', maxLength: 64 },
49
+ credits_reward: { type: 'integer', min: 0, max: 1000000 },
50
+ source: { type: 'string', maxLength: LIMITS.SOURCE },
51
+ source_ref: { type: 'string', maxLength: LIMITS.SOURCE_REF },
52
+ value_summary: { type: 'string', maxLength: LIMITS.VALUE_SUMMARY },
53
+ kind: { type: 'string', maxLength: 64 },
54
+ discipline: { type: 'string', maxLength: 64 },
55
+ parallel_safe: {}, // accepts true/false/null — type checked below
56
+ newcomer_friendly: { type: 'boolean' }, // V3.R15 #239 — Xenos-claimable flag
57
+ security_sensitive: { type: 'boolean' }, // task 990 — Archon-only ship broadcast
58
+ requires_rank: { type: 'string', maxLength: 16 }, // task 1498 / ADR 0084 — per-task rank floor (auto-derived; explicit value can only raise it)
59
+ auto_detect_touches: { type: 'boolean' },
60
+ // task 438: link the new task to done-when criteria at creation time. Each
61
+ // element is a numeric done_when_criteria.id, a positional "Cn" token, or a
62
+ // criterion_id slug — resolved against this task's version below (ADR 0025).
63
+ criterion_ids: { type: 'array', maxItems: 100 },
64
+ // BV1.R53 (task 1422): module affinity label. Drives module-disjointness at
65
+ // claim time and diff⊆module drift detection at ship time.
66
+ module_key: { type: 'string', maxLength: 64 },
67
+ // Parent the new task under a specific goal at create (task 1763 added the
68
+ // PATCH-move vector; this is the create-time counterpart so a planning seed
69
+ // lands tasks under their goal, not the version's default catch-all).
70
+ // Validated to exist + belong to this version below.
71
+ goal_id: { type: 'integer', min: 1 },
72
+ })) return;
73
+ const body = req.body || {};
74
+ if (!body.version_id || !body.title) {
75
+ return res.fail('version_id_and_title_required', 400);
76
+ }
77
+ // task 975: a version is the ledger of one bounded chunk of work. Once it
78
+ // ships it is sealed history — its progress bar reads 100% and its scope
79
+ // is archived. Filing new work against it would reopen a closed book and
80
+ // (until the view fix above) silently drag the bar back below 100%. Reject
81
+ // creation against a missing or shipped version; the caller should target
82
+ // an open version or file the idea to idea_inbox for the next planning
83
+ // session. (POST /tasks is the only API create-vector — PATCH cannot
84
+ // re-bind version_id — so this is the complete guard.)
85
+ const targetVersion = await db.getVersion(body.version_id);
86
+ if (!targetVersion) {
87
+ return res.fail('version_not_found', { status: 404, message: `No version '${body.version_id}'. Create the task under an existing, open version.` });
88
+ }
89
+ // task 386 (idea #85): idempotency on (source, source_ref). A re-submitted
90
+ // create — a retried POST, a capture/promotion re-run — must resolve to the
91
+ // ORIGINAL task, not silently mint a duplicate (the #248-dup-of-#252 class of
92
+ // bug). Runs BEFORE the version-closed guard so re-POSTing the same ref after
93
+ // its task already shipped (version now closed) returns the existing row
94
+ // instead of a spurious 409. `source` defaults to 'manual' to match the
95
+ // createTask call below, so the lookup key is exactly what would be written.
96
+ const effectiveSource = body.source ?? 'manual';
97
+ if (body.source_ref) {
98
+ const existing = await db.findTaskBySourceRef({ source: effectiveSource, sourceRef: body.source_ref });
99
+ if (existing) {
100
+ const criteria = await db.getTaskCriteria(existing.id);
101
+ return res.status(200).json({
102
+ task: existing,
103
+ needs_migration: existing.needs_migration === true,
104
+ criteria,
105
+ idempotent: true,
106
+ });
107
+ }
108
+ }
109
+ if (targetVersion.status === 'shipped') {
110
+ return res.fail('version_closed', { status: 409, message: `Version '${body.version_id}' has shipped — it is closed history and cannot take new tasks. ` +
111
+ `Target an open version, or capture this in idea_inbox for the next planning session.` });
112
+ }
113
+ // task 438: resolve any criterion_ids BEFORE creating the task, so a bad ref
114
+ // is a clean 400 with nothing created. Scoped to this task's version, so "C8"
115
+ // is unambiguous and a numeric id from another version doesn't resolve.
116
+ let criterionIdsResolved = [];
117
+ if (Array.isArray(body.criterion_ids) && body.criterion_ids.length > 0) {
118
+ const { ids, unresolved } = await db.resolveCriterionIds(body.version_id, body.criterion_ids);
119
+ if (unresolved.length > 0) {
120
+ return res.fail('bad_criterion_ref', { status: 400, message: `These criterion refs don't match any criterion of version '${body.version_id}': ` +
121
+ `${unresolved.join(', ')}. Use a numeric done_when_criteria.id, a "Cn" position, or a criterion_id slug.`, details: { unresolved } });
122
+ }
123
+ criterionIdsResolved = ids;
124
+ }
125
+ // Resolve kind: caller wins if they specified a valid non-unclassified
126
+ // value; otherwise run the heuristic classifier on title+description.
127
+ // Confidence < 0.5 falls through to 'unclassified' for triage.
128
+ let kind = body.kind;
129
+ if (kind && !VALID_KINDS.has(kind)) {
130
+ return badKind(res);
131
+ }
132
+ if (!kind || kind === 'unclassified') {
133
+ const result = classifyTaskKind(body.title, body.description || '');
134
+ kind = result.confidence >= 0.5 ? result.kind : 'unclassified';
135
+ }
136
+ const discipline = body.discipline ?? 'unclassified';
137
+ if (!VALID_DISCIPLINES.has(discipline)) {
138
+ return res.fail('bad_discipline', 400, { valid: Array.from(VALID_DISCIPLINES) });
139
+ }
140
+ // task 1498 / ADR 0084: an explicit requires_rank must be a live rank. The
141
+ // floor auto-derives from touches + security_sensitive; createTask clamps
142
+ // any explicit value UP to that floor (never below), so this only rejects a
143
+ // malformed enum.
144
+ if (body.requires_rank != null) {
145
+ const LIVE_RANKS = new Set(db.LIVE_RANK_LADDER);
146
+ if (!LIVE_RANKS.has(body.requires_rank)) {
147
+ return res.fail('bad_requires_rank', { status: 400, message: `requires_rank must be one of ${db.LIVE_RANK_LADDER.join(' | ')}. The floor auto-derives from touches + security_sensitive; an explicit value can only raise it.`, details: { valid: db.LIVE_RANK_LADDER } });
148
+ }
149
+ }
150
+ // BV1.R53 (task 1422): validate module_key against MODULE_SCOPE_MAP.
151
+ if (body.module_key != null && !isModuleKey(body.module_key)) {
152
+ return res.fail('bad_module_key', { status: 400, message: `module_key must be one of the known module keys, or omitted. Valid keys: ${moduleKeys().join(', ')}.`, details: { valid: moduleKeys() } });
153
+ }
154
+ // V3.R48 (#267) → migration 081 (task 876): if the title/description reads
155
+ // like a schema change, flag tasks.needs_migration so the claim-time
156
+ // allocator reserves a migration number. This used to inject the literal
157
+ // 'migrations/' into touches[]; it now sets a dedicated boolean and leaves
158
+ // touches[] alone. Opt out with { auto_detect_touches: false }.
159
+ const touches = Array.isArray(body.touches) ? [...body.touches] : [];
160
+ const autoDetect = body.auto_detect_touches !== false;
161
+ const needsMigration = autoDetect && detectsMigration(body.title, body.description);
162
+ // Validate an explicit goal_id: it must exist AND belong to THIS version (a task
163
+ // can't be parented under another version's goal). Absent → the default-goal
164
+ // trigger files it under the version's catch-all goal, exactly as before.
165
+ if (body.goal_id != null) {
166
+ const goal = await db.getGoal(body.goal_id);
167
+ if (!goal || String(goal.version_id) !== String(body.version_id)) {
168
+ return res.fail('bad_goal_id', { status: 400, message: `goal_id ${body.goal_id} does not exist on version '${body.version_id}'.` });
169
+ }
170
+ }
171
+ try {
172
+ const task = await db.createTask({
173
+ versionId: body.version_id,
174
+ title: body.title,
175
+ description: body.description || '',
176
+ status: body.status || 'backlog',
177
+ touches,
178
+ estMinutes: body.est_minutes ?? null,
179
+ estCostUsd: body.est_cost_usd ?? null,
180
+ manualDegree: body.manual_degree ?? null,
181
+ priority: body.priority ?? null,
182
+ automationTag: body.automation_tag ?? null,
183
+ creditsReward: body.credits_reward ?? 0,
184
+ source: effectiveSource,
185
+ sourceRef: body.source_ref ?? null,
186
+ valueSummary: body.value_summary ?? null,
187
+ kind,
188
+ discipline,
189
+ parallelSafe: typeof body.parallel_safe === 'boolean' ? body.parallel_safe : null,
190
+ newcomerFriendly: body.newcomer_friendly === true,
191
+ securitySensitive: body.security_sensitive === true,
192
+ needsMigration,
193
+ requiresRank: body.requires_rank ?? null,
194
+ moduleKey: body.module_key ?? null,
195
+ goalId: body.goal_id ?? null,
196
+ });
197
+ // task 438: link resolved criteria (source='planning' — task-create is the
198
+ // planning vector). The ids were version-checked above, so this is a single
199
+ // idempotent batch insert; surface the linked criteria in the response so the
200
+ // caller (and planning-session) can confirm the task is attributed.
201
+ let criteria = [];
202
+ if (criterionIdsResolved.length > 0) {
203
+ await db.linkTaskCriteria(task.id, criterionIdsResolved, 'planning');
204
+ criteria = await db.getTaskCriteria(task.id);
205
+ }
206
+ // task 1677 / ADR 0096: surface the assigned credits_reward so the creator sees
207
+ // the drachmae value the task was born with — auto-assigned at creation when the
208
+ // caller supplied no positive value (0/absent flows into createTask as "unset").
209
+ // task 1957: credits_reward is an ESTIMATE, not a guaranteed payout. The hall
210
+ // renders it as "<Currency> Est." until the task settles (confirmed/shipped),
211
+ // then switches to the real ledger figure (credits_awarded from the task read).
212
+ // task 2165 — the task landed under a goal but the caller linked no
213
+ // criterion → SUGGEST the most likely done-when criterion(s) for the author
214
+ // to confirm (they link via POST /tasks/:id/criteria). Advisory only, never
215
+ // auto-linked ("suggest, human picks"). Best-effort: a suggestion hiccup
216
+ // must not fail an otherwise-successful create. The general catch-all goal
217
+ // usually has no criteria, so this is naturally empty there.
218
+ let suggested_criteria = [];
219
+ if (criteria.length === 0 && task.goal_id != null) {
220
+ try {
221
+ const goalCriteria = await db.listCriteriaForGoal(task.goal_id);
222
+ suggested_criteria = suggestCriteria(
223
+ goalCriteria,
224
+ { title: body.title, description: body.description },
225
+ { limit: 3 }
226
+ );
227
+ } catch (e) {
228
+ console.error('[gds] POST /tasks suggest-criteria (non-blocking)', e && e.message);
229
+ }
230
+ }
231
+ // task 1002539 — the orphan-task advisory (the /goal-create auto-pickup
232
+ // hook). Nothing 4xxs when a task has no goal, so surface it here instead of
233
+ // blocking. Non-blocking by construction — see goal-advisory.js.
234
+ const goal_advisory = await buildGoalAdvisory({ db, versionId: body.version_id, task });
235
+ res.status(201).json({
236
+ task,
237
+ credits_reward: task.credits_reward,
238
+ needs_migration: needsMigration,
239
+ criteria,
240
+ suggested_criteria,
241
+ goal_advisory, est_advisory: await require('../est-advisory').buildEstAdvisory({ db, task }), // task 1003148
242
+ });
243
+ } catch (err) {
244
+ console.error('[gds] POST /tasks', err);
245
+ res.fail('create_failed', { status: 500, message: 'internal error' });
246
+ }
247
+ });
248
+
249
+ // PATCH /tasks/:id — supports body.parent_task_id (V2 C2), body.kind (V2 B1),
250
+ // body.discipline (mig 014), body.parallel_safe
251
+ // (mig 028), and body.newcomer_friendly (#360 / ADR 0018 + V3.R15 #239 —
252
+ // body.touches was removed in touches[] cleanup [4/8] (task 879) along with
253
+ // the drift scanner —
254
+ // open a task to the Xenos sandbox; alias body.xenos_claimable accepted for
255
+ // pre-rename callers). Pass parent_task_id: null to detach; parallel_safe
256
+ // accepts true|false|null; newcomer_friendly is a boolean. Validation lives
257
+ // in hierarchy.js for parent; the rest are validated inline.
258
+ // rank: metic+archon — edit work (incl. newcomer_friendly flag); ADR 0090 (was ADR 0016 archon).
259
+ router.patch('/tasks/:id', auth.requireBuilder, auth.requirePermission('task.edit'), async (req, res) => {
260
+ if (validateOrRespond(req, res, {
261
+ kind: { type: 'string', maxLength: 64 },
262
+ discipline: { type: 'string', maxLength: 64 },
263
+ requires_rank: { type: 'string', maxLength: 16 }, // task 1498 / ADR 0084 — override the per-task rank floor (upward only)
264
+ module_key: { type: 'string', maxLength: 64 }, // BV1.R53 (task 1422) — module affinity label
265
+ goal_id: { type: 'integer', min: 1 }, // task 1763 — (re)assign the task's goal (must be in the task's version)
266
+ // touches[] cleanup [4/8] (task 879): touches is no longer PATCH-able —
267
+ // the drift scanner + mid-claim expansion were removed. The rest are
268
+ // checked inline below but must be DECLARED so strict-mode (R11/#1998)
269
+ // accepts them instead of rejecting them as unknown fields.
270
+ parent_task_id: {}, // number id, or null to detach (hasOwnProperty-gated below)
271
+ parallel_safe: {}, // true/false/null — checked inline
272
+ newcomer_friendly: { type: 'boolean' }, // V3.R15 — Xenos-claimable flag
273
+ xenos_claimable: { type: 'boolean' }, // pre-rename alias for newcomer_friendly
274
+ security_sensitive: { type: 'boolean' }, // task 990 — Archon-only ship broadcast
275
+ automation_tag: { type: 'string', maxLength: 64 },
276
+ needs_migration: { type: 'boolean' }, // task 1002648 — correct the create-time heuristic's flag (the claim-time allocator keys off it)
277
+ // task 1002647 (map s4.3): the task body is a load-bearing builder prompt
278
+ // and was settable only at create — no route edited it at any rank. Now
279
+ // editable under this route's task.edit permission. minLength 1: a body
280
+ // can be rewritten, never blanked (and null is refused by the type check).
281
+ // Each edit's new body lands in audit_log via the write-audit middleware.
282
+ description: { type: 'string', minLength: 1, maxLength: LIMITS.DESCRIPTION },
283
+ // task 1002614 (map D-18): DECLARED so strict-mode admits the request far
284
+ // enough to reach the deliberate status_not_patchable hint below (#514) —
285
+ // the hint had been unreachable dead code behind unknown_field since the
286
+ // R11 strict-validation sweep landed after it.
287
+ status: {},
288
+ })) return;
289
+ const id = parseId(req, res, { code: 'bad_task_id', positiveInt: true });
290
+ if (id === null) return;
291
+ const body = req.body || {};
292
+ const hasParent = Object.prototype.hasOwnProperty.call(body, 'parent_task_id');
293
+ const hasKind = Object.prototype.hasOwnProperty.call(body, 'kind');
294
+ const hasDiscipline = Object.prototype.hasOwnProperty.call(body, 'discipline');
295
+ // V3.R15 #239: newcomer_friendly is the canonical name; xenos_claimable is
296
+ // the pre-rename alias and still accepted. Either presence triggers the
297
+ // same update path.
298
+ const hasNewcomerFriendly = Object.prototype.hasOwnProperty.call(body, 'newcomer_friendly');
299
+ const hasXenosClaimable = Object.prototype.hasOwnProperty.call(body, 'xenos_claimable');
300
+ const hasNewcomerOrXenos = hasNewcomerFriendly || hasXenosClaimable;
301
+ const hasParallelSafe = Object.prototype.hasOwnProperty.call(body, 'parallel_safe');
302
+ const hasSecuritySensitive = Object.prototype.hasOwnProperty.call(body, 'security_sensitive');
303
+ const hasAutomationTag = Object.prototype.hasOwnProperty.call(body, 'automation_tag');
304
+ const hasNeedsMigration = Object.prototype.hasOwnProperty.call(body, 'needs_migration');
305
+ const hasRequiresRank = Object.prototype.hasOwnProperty.call(body, 'requires_rank');
306
+ const hasModuleKey = Object.prototype.hasOwnProperty.call(body, 'module_key');
307
+ const hasGoalId = Object.prototype.hasOwnProperty.call(body, 'goal_id');
308
+ const hasDescription = Object.prototype.hasOwnProperty.call(body, 'description');
309
+ // #514: status is a lifecycle field, not a PATCH-able column — it moves only
310
+ // through the dedicated transition routes. The #506 session tried
311
+ // `PATCH status=ready`, got the generic no_supported_fields error, and had
312
+ // to go spelunking for the promote route. Detect status explicitly and point
313
+ // the caller straight at the right endpoint.
314
+ const STATUS_HINT =
315
+ 'status is not editable via PATCH; use POST /tasks/:id/promote (backlog->ready), ' +
316
+ '/tasks/:id/confirm, /tasks/:id/ship, or /claims/:id/resolve to move a task through its lifecycle.';
317
+ if (Object.prototype.hasOwnProperty.call(body, 'status')) {
318
+ return res.fail('status_not_patchable', { status: 400, message: STATUS_HINT });
319
+ }
320
+ if (!hasParent && !hasKind && !hasDiscipline && !hasNewcomerOrXenos && !hasParallelSafe && !hasSecuritySensitive && !hasAutomationTag && !hasNeedsMigration && !hasRequiresRank && !hasModuleKey && !hasGoalId && !hasDescription) {
321
+ return res.fail('no_supported_fields', { status: 400, message: 'PATCH /tasks/:id supports parent_task_id, kind, discipline, description, parallel_safe, security_sensitive, automation_tag, needs_migration, requires_rank, module_key, goal_id, and newcomer_friendly (alias: xenos_claimable). ' + STATUS_HINT });
322
+ }
323
+ // ---- task 1002609 (G-22): settle EVERY gate and validation before the ----
324
+ // ---- FIRST write. The previous shape validated-and-wrote per field in ----
325
+ // sequence, so a body mixing a valid early field with a refused later one
326
+ // committed the early write and then 403/400'd — state changed on a request
327
+ // the caller was told was refused (a Metic sending { parallel_safe,
328
+ // security_sensitive } had parallel_safe committed before rank_forbidden).
329
+ // Authorization, type/enum checks, and the goal existence/version reads all
330
+ // run here; writes begin only after the last validation `return`.
331
+ // (Documented residual: parent_task_id validates inside hierarchy.attachParent
332
+ // at the end — its checks are atomic with its OWN write, but a refusal there
333
+ // still lands after the field writes below. No authorization gate is
334
+ // involved in that path; the handler's only rank gate is hoisted here.)
335
+ if (hasParallelSafe && body.parallel_safe !== null && typeof body.parallel_safe !== 'boolean') {
336
+ return res.fail('bad_parallel_safe', { status: 400, message: 'parallel_safe must be true, false, or null' });
337
+ }
338
+ if (hasSecuritySensitive) {
339
+ // task 990: explicit Archon-only-broadcast flag. Boolean only (no null) —
340
+ // it gates whether ship.js routes the broadcast to the Archon-only
341
+ // #security-ship-feed channel instead of the public feeds.
342
+ // ADR 0090: PATCH /tasks/:id opened to Metic+, but security_sensitive is a
343
+ // SECURITY control (broadcast suppression + it feeds the auto-derived rank
344
+ // floor, ADR 0084), not a planning field — so it stays Archon-only via this
345
+ // in-handler gate. A Metic editing any other field is unaffected; only a
346
+ // requires_rank-style escalation surface is withheld. (Setting it at create
347
+ // time is safe — a brand-new task has no existing sensitivity to clear.)
348
+ if (!auth.rankMeetsThreshold(req.builder.rank, ['archon'])) {
349
+ return res.fail('rank_forbidden', 403, { required: ['archon'], actual: req.builder.rank, reason: 'security_sensitive is an Archon-only security control (ADR 0090); a Metic may edit every other task field.' });
350
+ }
351
+ if (typeof body.security_sensitive !== 'boolean') {
352
+ return res.fail('bad_security_sensitive', { status: 400, message: 'security_sensitive must be a boolean' });
353
+ }
354
+ }
355
+ const VALID_AUTOMATION_TAGS = new Set(['overnight-safe', 'live-only', 'needs-input']);
356
+ if (hasAutomationTag) {
357
+ const tag = body.automation_tag;
358
+ if (tag !== null && (typeof tag !== 'string' || !VALID_AUTOMATION_TAGS.has(tag))) {
359
+ return res.fail('bad_automation_tag', { status: 400, message: 'automation_tag must be one of overnight-safe | live-only | needs-input, or null to clear', details: { valid: Array.from(VALID_AUTOMATION_TAGS) } });
360
+ }
361
+ }
362
+ if (hasModuleKey) {
363
+ // BV1.R53 (task 1422): assign or clear the module affinity label. null clears.
364
+ if (body.module_key !== null && !isModuleKey(body.module_key)) {
365
+ return res.fail('bad_module_key', { status: 400, message: `module_key must be one of the known module keys, or null to clear. Valid keys: ${moduleKeys().join(', ')}.`, details: { valid: moduleKeys() } });
366
+ }
367
+ }
368
+ if (hasGoalId) {
369
+ // task 1763: (re)assign the task's goal — the documented "Archon/Metic
370
+ // mechanism" to set goal_id (this route is metic+archon). A task belongs to
371
+ // exactly one goal WITHIN its own version: both the per-version general goals
372
+ // and goal-authored tasks hold this, and the /status rollup walks
373
+ // version→goal→task, so a cross-version link would drop the task out of its
374
+ // version's rollup. Refuse that (409). goal_id is never cleared to null here
375
+ // (that reintroduces an orphan) — a positive integer is required.
376
+ const goalId = body.goal_id;
377
+ if (!Number.isInteger(goalId) || goalId <= 0) {
378
+ return res.fail('bad_goal_id', { status: 400, message: 'goal_id must be a positive integer — a task belongs to exactly one goal.' });
379
+ }
380
+ try {
381
+ const task = await db.getTask(id);
382
+ if (!task) return res.fail('task_not_found', 404);
383
+ const goal = await db.getGoal(goalId);
384
+ if (!goal) return res.fail('goal_not_found', { status: 404, message: `No goal #${goalId}.` });
385
+ if (String(goal.version_id) !== String(task.version_id)) {
386
+ return res.fail('goal_version_mismatch', { status: 409, message: `Goal #${goalId} is in version '${goal.version_id}' but task #${id} is in '${task.version_id}'. A task can only join a goal in its own version.` });
387
+ }
388
+ } catch (err) {
389
+ console.error('[gds] PATCH /tasks/:id goal_id', err);
390
+ return res.fail('patch_failed', { status: 500, message: 'internal error' });
391
+ }
392
+ }
393
+ if (hasRequiresRank) {
394
+ // task 1498 / ADR 0084: the Archon override of the per-task rank floor.
395
+ // Validate the enum here; db.updateTaskRequiresRank clamps the value UP to
396
+ // the auto-derived floor (touches + security_sensitive), so a PATCH can
397
+ // only RAISE the gate — never open a sensitive task to a lower rank.
398
+ const LIVE_RANKS = new Set(db.LIVE_RANK_LADDER);
399
+ if (typeof body.requires_rank !== 'string' || !LIVE_RANKS.has(body.requires_rank)) {
400
+ return res.fail('bad_requires_rank', { status: 400, message: `requires_rank must be one of ${db.LIVE_RANK_LADDER.join(' | ')}. An override can only raise the floor; it is clamped up to the auto-derived minimum.`, details: { valid: db.LIVE_RANK_LADDER } });
401
+ }
402
+ }
403
+ if (hasKind && !VALID_KINDS.has(body.kind)) {
404
+ return badKind(res);
405
+ }
406
+ if (hasDiscipline && !VALID_DISCIPLINES.has(body.discipline)) {
407
+ return res.fail('bad_discipline', 400, { valid: Array.from(VALID_DISCIPLINES) });
408
+ }
409
+ if (hasNewcomerOrXenos) {
410
+ // V3.R15 #239: prefer newcomer_friendly (canonical); fall back to
411
+ // xenos_claimable (pre-rename alias). Both map to the same DB column.
412
+ const incoming = hasNewcomerFriendly ? body.newcomer_friendly : body.xenos_claimable;
413
+ if (typeof incoming !== 'boolean') {
414
+ const field = hasNewcomerFriendly ? 'newcomer_friendly' : 'xenos_claimable';
415
+ return res.fail(`bad_${field}`, { status: 400, message: `${field} must be a boolean` });
416
+ }
417
+ }
418
+ // ---- all gates passed: apply the writes. Order preserved from the old ----
419
+ // shape — requires_rank still runs AFTER security_sensitive so a
420
+ // same-request flag flip is already reflected in the derived floor.
421
+ let updated = null;
422
+ if (hasParallelSafe) {
423
+ try {
424
+ updated = await db.updateTaskParallelSafe(id, body.parallel_safe);
425
+ if (!updated) return res.fail('task_not_found', 404);
426
+ } catch (err) {
427
+ console.error('[gds] PATCH /tasks/:id parallel_safe', err);
428
+ return res.fail('patch_failed', { status: 500, message: 'internal error' });
429
+ }
430
+ }
431
+ if (hasSecuritySensitive) {
432
+ try {
433
+ updated = await db.updateTaskSecuritySensitive(id, body.security_sensitive);
434
+ if (!updated) return res.fail('task_not_found', 404);
435
+ } catch (err) {
436
+ console.error('[gds] PATCH /tasks/:id security_sensitive', err);
437
+ return res.fail('patch_failed', { status: 500, message: 'internal error' });
438
+ }
439
+ }
440
+ if (hasAutomationTag) {
441
+ try {
442
+ updated = await db.updateTaskAutomationTag(id, body.automation_tag);
443
+ if (!updated) return res.fail('task_not_found', 404);
444
+ } catch (err) {
445
+ console.error('[gds] PATCH /tasks/:id automation_tag', err);
446
+ return res.fail('patch_failed', { status: 500, message: 'internal error' });
447
+ }
448
+ }
449
+ if (hasNeedsMigration) {
450
+ try {
451
+ updated = await db.updateTaskNeedsMigration(id, body.needs_migration);
452
+ if (!updated) return res.fail('task_not_found', 404);
453
+ } catch (err) {
454
+ console.error('[gds] PATCH /tasks/:id needs_migration', err);
455
+ return res.fail('patch_failed', { status: 500, message: 'internal error' });
456
+ }
457
+ }
458
+ if (hasModuleKey) {
459
+ try {
460
+ updated = await db.updateTaskModuleKey(id, body.module_key ?? null);
461
+ if (!updated) return res.fail('task_not_found', 404);
462
+ } catch (err) {
463
+ console.error('[gds] PATCH /tasks/:id module_key', err);
464
+ return res.fail('patch_failed', { status: 500, message: 'internal error' });
465
+ }
466
+ }
467
+ if (hasDescription) {
468
+ try {
469
+ updated = await db.updateTaskDescription(id, body.description);
470
+ if (!updated) return res.fail('task_not_found', 404);
471
+ } catch (err) {
472
+ console.error('[gds] PATCH /tasks/:id description', err);
473
+ return res.fail('patch_failed', { status: 500, message: 'internal error' });
474
+ }
475
+ }
476
+ if (hasGoalId) {
477
+ try {
478
+ updated = await db.updateTaskGoal(id, body.goal_id);
479
+ if (!updated) return res.fail('task_not_found', 404);
480
+ } catch (err) {
481
+ console.error('[gds] PATCH /tasks/:id goal_id', err);
482
+ return res.fail('patch_failed', { status: 500, message: 'internal error' });
483
+ }
484
+ }
485
+ if (hasRequiresRank) {
486
+ try {
487
+ updated = await db.updateTaskRequiresRank(id, body.requires_rank);
488
+ if (!updated) return res.fail('task_not_found', 404);
489
+ } catch (err) {
490
+ console.error('[gds] PATCH /tasks/:id requires_rank', err);
491
+ return res.fail('patch_failed', { status: 500, message: 'internal error' });
492
+ }
493
+ }
494
+ if (hasKind) {
495
+ try {
496
+ updated = await db.updateTaskKind(id, body.kind);
497
+ if (!updated) return res.fail('task_not_found', 404);
498
+ } catch (err) {
499
+ console.error('[gds] PATCH /tasks/:id kind', err);
500
+ return res.fail('patch_failed', { status: 500, message: 'internal error' });
501
+ }
502
+ }
503
+ if (hasDiscipline) {
504
+ try {
505
+ updated = await db.updateTaskDiscipline(id, body.discipline);
506
+ if (!updated) return res.fail('task_not_found', 404);
507
+ } catch (err) {
508
+ console.error('[gds] PATCH /tasks/:id discipline', err);
509
+ return res.fail('patch_failed', { status: 500, message: 'internal error' });
510
+ }
511
+ }
512
+ if (hasNewcomerOrXenos) {
513
+ const incoming = hasNewcomerFriendly ? body.newcomer_friendly : body.xenos_claimable;
514
+ try {
515
+ updated = await db.updateTaskNewcomerFriendly(id, incoming);
516
+ if (!updated) return res.fail('task_not_found', 404);
517
+ } catch (err) {
518
+ console.error('[gds] PATCH /tasks/:id newcomer_friendly', err);
519
+ return res.fail('patch_failed', { status: 500, message: 'internal error' });
520
+ }
521
+ }
522
+ if (!hasParent) return res.json({ task: updated });
523
+ try {
524
+ const task = await hierarchy.attachParent(id, body.parent_task_id);
525
+ res.json({ task });
526
+ } catch (err) {
527
+ if (err && err.code) {
528
+ const status =
529
+ err.code === 'TASK_NOT_FOUND' ? 404 :
530
+ err.code === 'PARENT_NOT_FOUND' ? 404 :
531
+ err.code === 'SELF_PARENT' ? 409 :
532
+ err.code === 'VERSION_MISMATCH' ? 409 :
533
+ err.code === 'PARENT_HAS_PARENT' ? 409 :
534
+ err.code === 'TARGET_HAS_CHILDREN' ? 409 :
535
+ 500;
536
+ return res.fail(err.code, status, { ...err });
537
+ }
538
+ console.error('[gds] PATCH /tasks/:id', err);
539
+ res.fail('patch_failed', { status: 500, message: 'internal error' });
540
+ }
541
+ });
542
+ };