@bongos/core 1.19.286 → 1.19.288

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.
@@ -3609,6 +3609,72 @@
3609
3609
  ]
3610
3610
  }
3611
3611
  },
3612
+ "/done-when/{criterionId}": {
3613
+ "patch": {
3614
+ "operationId": "patch_done_when_criterionId",
3615
+ "tags": [
3616
+ "done-when"
3617
+ ],
3618
+ "summary": "PATCH /done-when/:criterionId",
3619
+ "description": "PATCH /done-when/:criterionId — correct a criterion's TEXT (task 1003102). Was write-once: this file exposed create, satisfy and unsatisfy and nothing else, so wrong text had no API repair path. The incident that forced it: a shell-quoting error stripped backtick-wrapped literals out of three criteria on goal 1000073 at CREATION time — the intent was recorded, the text was wrong, and the only channels were prod SQL or archiving the goal (worse: criteria are version-level rows, so an archived goal's criteria linger in the version rollup). A SATISFIED criterion is REFUSED, by name — see prose-edits.criterionProseRefusal for why that is a decision and not a limitation: a satisfied criterion is a claim someone verified, and re-wording it re-points that verification at text nobody checked. Unsatisfy → edit → re-satisfy makes the undo a recorded act. Gated like criterion CREATION — BOTH HALVES. The permission `criterion.create` is only the RANK floor; the sibling POST /versions/:id/done-when also runs authorizeCriterionCreate, the per-goal owner|manager wall (ADR 0154). Copying just the rank half would let any Metic rewrite criteria on a goal they neither own, manage, nor belong to — reusing the SAME pure gate is the only way the claim \"gated like creation\" is true. (Caught by the grader on the first cut of task 1003102: the comment said it, the code did half of it.) rank: metic+archon — matches POST /versions/:id/done-when.\n\n**Rank:** `metic+archon` — Metic or Archon rank (review/triage powers).\n\n**Permissions:** `criterion.create` (all required).",
3620
+ "x-rank": "metic+archon",
3621
+ "x-source": "modules/lifecycle/routes/done-when.js",
3622
+ "x-permissions": [
3623
+ "criterion.create"
3624
+ ],
3625
+ "parameters": [
3626
+ {
3627
+ "name": "criterionId",
3628
+ "in": "path",
3629
+ "required": true,
3630
+ "schema": {
3631
+ "type": "string"
3632
+ },
3633
+ "description": "Path parameter `criterionId`."
3634
+ }
3635
+ ],
3636
+ "requestBody": {
3637
+ "required": true,
3638
+ "content": {
3639
+ "application/json": {
3640
+ "schema": {
3641
+ "$ref": "#/components/schemas/PatchDoneWhenCriterionIdRequest"
3642
+ }
3643
+ }
3644
+ },
3645
+ "x-validated": true
3646
+ },
3647
+ "responses": {
3648
+ "200": {
3649
+ "description": "Success.",
3650
+ "content": {
3651
+ "application/json": {
3652
+ "schema": {
3653
+ "$ref": "#/components/schemas/PatchDoneWhenCriterionIdResponse"
3654
+ }
3655
+ }
3656
+ }
3657
+ },
3658
+ "400": {
3659
+ "$ref": "#/components/responses/ValidationFailed"
3660
+ },
3661
+ "401": {
3662
+ "$ref": "#/components/responses/Unauthorized"
3663
+ },
3664
+ "403": {
3665
+ "$ref": "#/components/responses/Forbidden"
3666
+ },
3667
+ "404": {
3668
+ "$ref": "#/components/responses/NotFound"
3669
+ }
3670
+ },
3671
+ "security": [
3672
+ {
3673
+ "builderSession": []
3674
+ }
3675
+ ]
3676
+ }
3677
+ },
3612
3678
  "/done-when/{criterionId}/satisfy": {
3613
3679
  "post": {
3614
3680
  "operationId": "post_done_when_criterionId_satisfy",
@@ -4212,6 +4278,49 @@
4212
4278
  "builderSession": []
4213
4279
  }
4214
4280
  ]
4281
+ },
4282
+ "patch": {
4283
+ "operationId": "patch_goals_id",
4284
+ "tags": [
4285
+ "goals"
4286
+ ],
4287
+ "summary": "PATCH /goals/:id",
4288
+ "description": "PATCH /goals/:id — correct a goal's own title/description (task 1003102). Handler + rationale + rank note: ../goal-edits.js. Pinned in route-rank-check.\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
4289
+ "x-rank": "any-builder",
4290
+ "x-source": "modules/lifecycle/routes/goals.js",
4291
+ "parameters": [
4292
+ {
4293
+ "name": "id",
4294
+ "in": "path",
4295
+ "required": true,
4296
+ "schema": {
4297
+ "type": "string"
4298
+ },
4299
+ "description": "Path parameter `id`."
4300
+ }
4301
+ ],
4302
+ "responses": {
4303
+ "200": {
4304
+ "description": "Success."
4305
+ },
4306
+ "400": {
4307
+ "$ref": "#/components/responses/BadRequest"
4308
+ },
4309
+ "401": {
4310
+ "$ref": "#/components/responses/Unauthorized"
4311
+ },
4312
+ "403": {
4313
+ "$ref": "#/components/responses/Forbidden"
4314
+ },
4315
+ "404": {
4316
+ "$ref": "#/components/responses/NotFound"
4317
+ }
4318
+ },
4319
+ "security": [
4320
+ {
4321
+ "builderSession": []
4322
+ }
4323
+ ]
4215
4324
  }
4216
4325
  },
4217
4326
  "/goals/{id}/archive": {
@@ -5324,30 +5433,12 @@
5324
5433
  "description": "Path parameter `id`."
5325
5434
  }
5326
5435
  ],
5327
- "requestBody": {
5328
- "required": true,
5329
- "content": {
5330
- "application/json": {
5331
- "schema": {
5332
- "$ref": "#/components/schemas/PatchGoalsIdVisibilityRequest"
5333
- }
5334
- }
5335
- },
5336
- "x-validated": true
5337
- },
5338
5436
  "responses": {
5339
5437
  "200": {
5340
- "description": "Success.",
5341
- "content": {
5342
- "application/json": {
5343
- "schema": {
5344
- "$ref": "#/components/schemas/PatchGoalsIdVisibilityResponse"
5345
- }
5346
- }
5347
- }
5438
+ "description": "Success."
5348
5439
  },
5349
5440
  "400": {
5350
- "$ref": "#/components/responses/ValidationFailed"
5441
+ "$ref": "#/components/responses/BadRequest"
5351
5442
  },
5352
5443
  "401": {
5353
5444
  "$ref": "#/components/responses/Unauthorized"
@@ -6249,7 +6340,7 @@
6249
6340
  "inbox"
6250
6341
  ],
6251
6342
  "summary": "PATCH /inbox/:id",
6252
- "description": "Metic+ (task 360 / ADR 0018): idea triage (promote/discard/merge) shapes the backlog. Criterion C2 assigns triage to Metic, so this widened from archon-only to metic+archon when the three-rank model went live. Any builder can still FILE an idea (POST /inbox above); a Xenos cannot triage. rank: metic+archon — idea triage shapes the backlog; criterion C2 / task 360 / ADR 0018.\n\n**Rank:** `metic+archon` — Metic or Archon rank (review/triage powers).\n\n**Permissions:** `idea.triage` (all required).",
6343
+ "description": "**Rank:** `metic+archon` — Metic or Archon rank (review/triage powers).\n\n**Permissions:** `idea.triage` (all required).",
6253
6344
  "x-rank": "metic+archon",
6254
6345
  "x-source": "modules/ideas/routes/inbox.js",
6255
6346
  "x-permissions": [
@@ -6308,6 +6399,69 @@
6308
6399
  ]
6309
6400
  }
6310
6401
  },
6402
+ "/inbox/{id}/body": {
6403
+ "patch": {
6404
+ "operationId": "patch_inbox_id_body",
6405
+ "tags": [
6406
+ "inbox"
6407
+ ],
6408
+ "summary": "PATCH /inbox/:id/body",
6409
+ "description": "Metic+ (task 360 / ADR 0018): idea triage (promote/discard/merge) shapes the backlog. Criterion C2 assigns triage to Metic, so this widened from archon-only to metic+archon when the three-rank model went live. Any builder can still FILE an idea (POST /inbox above); a Xenos cannot triage. rank: metic+archon — idea triage shapes the backlog; criterion C2 / task 360 / ADR 0018. PATCH /inbox/:id/body — correct an idea's own TEXT (task 1003102 / idea 1000740). A SEPARATE route rather than a new field on PATCH /inbox/:id, deliberately. That route is gated `requirePermission('idea.triage')`, and the ask here is that an idea's AUTHOR can fix their own words. Adding body_md there would have meant dropping the whole triage route to requireBuilder and re-authorizing every field in-handler — widening a pinned route's classified rank to reach one field. A new route with its own pin widens nothing: triage keeps its gate, and this one carries its own, narrower rule. AUTHOR (idea_inbox.captured_by) or a triager. The author case is the point: fixing your own typo must not require escalating to a Metic. rank: any-builder (requireBuilder) + the in-handler author|triage gate. Pinned in route-rank-check.MODULE_EXPECTED_RANKS['ideas']. asyncHandler (the doorway wrapper) owns the catch + the 500 log, so this route carries no console.error of its own — one central log site instead of a 16th copy of the same two lines in this file.\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
6410
+ "x-rank": "any-builder",
6411
+ "x-source": "modules/ideas/routes/inbox.js",
6412
+ "parameters": [
6413
+ {
6414
+ "name": "id",
6415
+ "in": "path",
6416
+ "required": true,
6417
+ "schema": {
6418
+ "type": "string"
6419
+ },
6420
+ "description": "Path parameter `id`."
6421
+ }
6422
+ ],
6423
+ "requestBody": {
6424
+ "required": true,
6425
+ "content": {
6426
+ "application/json": {
6427
+ "schema": {
6428
+ "$ref": "#/components/schemas/PatchInboxIdBodyRequest"
6429
+ }
6430
+ }
6431
+ },
6432
+ "x-validated": true
6433
+ },
6434
+ "responses": {
6435
+ "200": {
6436
+ "description": "Success.",
6437
+ "content": {
6438
+ "application/json": {
6439
+ "schema": {
6440
+ "$ref": "#/components/schemas/PatchInboxIdBodyResponse"
6441
+ }
6442
+ }
6443
+ }
6444
+ },
6445
+ "400": {
6446
+ "$ref": "#/components/responses/ValidationFailed"
6447
+ },
6448
+ "401": {
6449
+ "$ref": "#/components/responses/Unauthorized"
6450
+ },
6451
+ "403": {
6452
+ "$ref": "#/components/responses/Forbidden"
6453
+ },
6454
+ "404": {
6455
+ "$ref": "#/components/responses/NotFound"
6456
+ }
6457
+ },
6458
+ "security": [
6459
+ {
6460
+ "builderSession": []
6461
+ }
6462
+ ]
6463
+ }
6464
+ },
6311
6465
  "/inbox/{id}/ratify": {
6312
6466
  "post": {
6313
6467
  "operationId": "post_inbox_id_ratify",
@@ -14674,6 +14828,39 @@
14674
14828
  ],
14675
14829
  "additionalProperties": false
14676
14830
  },
14831
+ "PatchDoneWhenCriterionIdRequest": {
14832
+ "type": "object",
14833
+ "properties": {
14834
+ "criterion_md": {
14835
+ "type": "string",
14836
+ "minLength": 1,
14837
+ "maxLength": 10000
14838
+ }
14839
+ },
14840
+ "required": [
14841
+ "criterion_md"
14842
+ ],
14843
+ "additionalProperties": false
14844
+ },
14845
+ "PatchDoneWhenCriterionIdResponse": {
14846
+ "type": "object",
14847
+ "properties": {
14848
+ "ok": {
14849
+ "type": "boolean"
14850
+ },
14851
+ "criterion": {},
14852
+ "deltas": {},
14853
+ "unchanged": {
14854
+ "type": "boolean"
14855
+ }
14856
+ },
14857
+ "required": [
14858
+ "ok",
14859
+ "criterion",
14860
+ "deltas",
14861
+ "unchanged"
14862
+ ]
14863
+ },
14677
14864
  "PatchGoalsIdMembersBuilderIdRequest": {
14678
14865
  "type": "object",
14679
14866
  "properties": {
@@ -14703,29 +14890,35 @@
14703
14890
  "changed"
14704
14891
  ]
14705
14892
  },
14706
- "PatchGoalsIdVisibilityRequest": {
14893
+ "PatchInboxIdBodyRequest": {
14707
14894
  "type": "object",
14708
14895
  "properties": {
14709
- "visibility": {
14710
- "type": "string"
14896
+ "body_md": {
14897
+ "type": "string",
14898
+ "minLength": 1,
14899
+ "maxLength": 50000
14711
14900
  }
14712
14901
  },
14713
14902
  "required": [
14714
- "visibility"
14903
+ "body_md"
14715
14904
  ],
14716
14905
  "additionalProperties": false
14717
14906
  },
14718
- "PatchGoalsIdVisibilityResponse": {
14907
+ "PatchInboxIdBodyResponse": {
14719
14908
  "type": "object",
14720
14909
  "properties": {
14721
14910
  "ok": {
14722
14911
  "type": "boolean"
14723
14912
  },
14724
- "goal": {}
14913
+ "idea": {},
14914
+ "delta": {},
14915
+ "unchanged": {}
14725
14916
  },
14726
14917
  "required": [
14727
14918
  "ok",
14728
- "goal"
14919
+ "idea",
14920
+ "delta",
14921
+ "unchanged"
14729
14922
  ]
14730
14923
  },
14731
14924
  "PatchInboxIdRequest": {
@@ -18353,9 +18546,9 @@
18353
18546
  "description": "A required dependency/feature is not configured or is temporarily down."
18354
18547
  }
18355
18548
  },
18356
- "x-endpoint-count": 290,
18357
- "x-schema-count": 326,
18549
+ "x-endpoint-count": 293,
18550
+ "x-schema-count": 328,
18358
18551
  "x-undocumented-bodies": 8,
18359
- "x-response-schemas": 218,
18552
+ "x-response-schemas": 219,
18360
18553
  "x-generated-by": "scripts/gds/gen-api-docs.js"
18361
18554
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  # Bongos API reference
4
4
 
5
- > **Generated from the live route files** — the route file is authoritative. 290 endpoints across 47 route files.
5
+ > **Generated from the live route files** — the route file is authoritative. 293 endpoints across 47 route files.
6
6
  > Machine-readable spec: [`docs/api/openapi.json`](api/openapi.json) (OpenAPI 3.1). Rendered docs site: **`/docs`** (e.g. `cloudbongos.com/docs`).
7
7
 
8
8
  Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>.md)): `public` < `any-builder` < `metic+archon` < `archon`.
@@ -172,10 +172,11 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
172
172
  | GET | `/api/bongos/discord/channels/plan` | `metic+archon` | — | GET /api/bongos/discord/channels/plan — dry run. |
173
173
  | POST | `/api/bongos/discord/channels/reconcile` | `metic+archon` | — | POST /api/bongos/discord/channels/reconcile — apply the config to Discord now. |
174
174
 
175
- ## `done-when` (3)
175
+ ## `done-when` (4)
176
176
 
177
177
  | Method | Path | Rank | Body | Description |
178
178
  |---|---|---|---|---|
179
+ | PATCH | `/api/bongos/done-when/:criterionId` | `metic+archon` | `criterion_md` | PATCH /done-when/:criterionId — correct a criterion's TEXT (task 1003102). |
179
180
  | POST | `/api/bongos/done-when/:criterionId/satisfy` | `metic+archon` | `satisfied_by_task_id` | Auth (Metic+): confirm a criterion — flip it to satisfied. |
180
181
  | POST | `/api/bongos/done-when/:criterionId/unsatisfy` | `metic+archon` | — | Auth: flip a criterion back to unsatisfied (rare; for corrections). |
181
182
  | GET | `/api/bongos/done-when/pending-review` | `metic+archon` | — | Auth (Metic+): the criterion review queue — every criterion auto-flagged "met — pending review" (≥1 linked task, all shipped, not yet con… |
@@ -196,13 +197,14 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
196
197
  | GET | `/api/bongos/github/repos` | `any-builder` | — | acts only on the caller's OWN GitHub identity (the token they granted). |
197
198
  | POST | `/api/bongos/github/repos` | `any-builder` | `name` | creates a repo in the caller's OWN account. |
198
199
 
199
- ## `goals` (24)
200
+ ## `goals` (25)
200
201
 
201
202
  | Method | Path | Rank | Body | Description |
202
203
  |---|---|---|---|---|
203
204
  | GET | `/api/bongos/goals` | `any-builder` | — | GET /goals?version=&status= — list goals (read). |
204
205
  | POST | `/api/bongos/goals` | `metic+archon` | `version_id`, `title`, `description`, `scope_modules`, `succeeds_goal_id` | POST /goals — create a goal. |
205
206
  | GET | `/api/bongos/goals/:id` | `any-builder` | — | GET /goals/:id — one goal with its members + linked criteria (read). |
207
+ | PATCH | `/api/bongos/goals/:id` | `any-builder` | — | PATCH /goals/:id — correct a goal's own title/description (task 1003102). |
206
208
  | POST | `/api/bongos/goals/:id/archive` | `metic+archon` | — | POST /goals/:id/archive — retire a goal (open\|achieved → archived). |
207
209
  | GET | `/api/bongos/goals/:id/conflicts` | `any-builder` | — | GET /goals/:id/conflicts — the server-side task-conflict view (task 1736). |
208
210
  | POST | `/api/bongos/goals/:id/conflicts/resolve` | `any-builder` | `action`, `note`, `touches`, `first_task_id`, `then_task_id`, `task_id` | POST /goals/:id/conflicts/resolve — the owner/manager resolution actions for a detected conflict (task 1736; authority model ADR 0106): s… |
@@ -221,7 +223,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
221
223
  | GET | `/api/bongos/goals/:id/suggest-criteria` | `any-builder` | — | GET /goals/:id/suggest-criteria?title=&description=&limit= — task 2165. |
222
224
  | POST | `/api/bongos/goals/:id/tasks` | `any-builder` | `title`, `description`, `touches`, `est_minutes`, `priority`, `kind`, `discipline`, `value_summary`, `parent_task_id` | POST /goals/:id/tasks — bounded task authoring within a goal's scope (BV1.R60 / ADR 0086 §3). |
223
225
  | POST | `/api/bongos/goals/:id/transfer` | `any-builder` | `builder_id` | POST /goals/:id/transfer — hand the goal to a new owner (task 1735). |
224
- | PATCH | `/api/bongos/goals/:id/visibility` | `any-builder` | `visibility` | PATCH /goals/:id/visibility — make a goal public\|private (task 1952 / ADR 0112). |
226
+ | PATCH | `/api/bongos/goals/:id/visibility` | `any-builder` | — | PATCH /goals/:id/visibility — make a goal public\|private (task 1952 / ADR 0112). |
225
227
  | GET | `/api/bongos/goals/inbox` | `any-builder` | — | GET /goals/inbox — the caller's private-goals inbox (task 1952 / ADR 0112): { invitations: invites addressed to me (accept/decline); join… |
226
228
  | POST | `/api/bongos/goals/reorder` | `metic+archon` | `version_id`, `ordered_ids` | POST /goals/reorder — Archon drag-to-reorder: set the priority order of every goal WITHIN one version (task 1760). |
227
229
 
@@ -255,14 +257,15 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
255
257
  |---|---|---|---|---|
256
258
  | GET | `/api/bongos/healthz` | `public` | — | liveness probe; status.example.com hits this anonymously. |
257
259
 
258
- ## `inbox` (7)
260
+ ## `inbox` (8)
259
261
 
260
262
  | Method | Path | Rank | Body | Description |
261
263
  |---|---|---|---|---|
262
264
  | GET | `/api/bongos/inbox` | `any-builder` | — | read open ideas; skill-friction rows filtered by viewer rank. |
263
265
  | POST | `/api/bongos/inbox` | `any-builder` | `title`, `body_md`, `kind`, `suggested_version`, `suggested_priority`, `goal_id`, `grade`, `template_key`, `field_values` | for ordinary ideas; metic+ for kind='criterion-proposal'. |
264
266
  | GET | `/api/bongos/inbox/:id` | `any-builder` | — | single idea read for the "#NNN" deep-link resolver (task 1047). |
265
- | PATCH | `/api/bongos/inbox/:id` | `metic+archon` | `status`, `kind`, `promoted_to_task_id`, `reviewed_at`, `merged_into_idea_id`, `triage_note` | Metic+ (task 360 / ADR 0018): idea triage (promote/discard/merge) shapes the backlog. |
267
+ | PATCH | `/api/bongos/inbox/:id` | `metic+archon` | `status`, `kind`, `promoted_to_task_id`, `reviewed_at`, `merged_into_idea_id`, `triage_note` | |
268
+ | PATCH | `/api/bongos/inbox/:id/body` | `any-builder` | `body_md` | Metic+ (task 360 / ADR 0018): idea triage (promote/discard/merge) shapes the backlog. |
266
269
  | POST | `/api/bongos/inbox/:id/ratify` | `metic+archon` | `notes_md` | V3.R31 (task 252) — Archon ratifies a criterion-proposal idea, atomically: - INSERT done_when_criteria row on the target version - UPDATE… |
267
270
  | POST | `/api/bongos/inbox/:id/resubmit` | `any-builder` | `field_values`, `template_key` | RESUBMIT a Full Idea after a rejection (R13 of goal 1000069, criterion C2: an idea never dies and never stalls). |
268
271
  | GET | `/api/bongos/inbox/awaiting-nod` | `metic+archon` | — | GET /inbox/awaiting-nod — the NOD QUEUE (task 1003066, BV1.R05). |
@@ -546,6 +546,48 @@ async function countOpenIdeas({ viewerRank = null } = {}) {
546
546
  // open), then merge C into A, whose status still reads 'open'. So the chain
547
547
  // guard tests the TARGET COLUMN, not the target's status, and a target write
548
548
  // must carry status='merged'.
549
+ // updateIdeaBody — correct an idea's PROSE (task 1003102 / idea 1000740).
550
+ //
551
+ // PATCH /inbox/:id accepts status, kind, promoted_to_task_id, reviewed_at,
552
+ // merged_into_idea_id and triage_note — every field ABOUT the idea, and not the
553
+ // idea itself. So a correction to the text had to live in a triage_note beside
554
+ // the words it corrected, rather than in them.
555
+ //
556
+ // Returns { idea, delta } — delta is { field, from, to, from_len, to_len } with
557
+ // clipped excerpts, or null when the text is unchanged. The global write-audit
558
+ // middleware records only the REQUEST body (the NEW text), so an edit to durable
559
+ // prose would otherwise leave no trace of what it replaced.
560
+ //
561
+ // BOUNDARY-FORCED DUPLICATION (ADR 0091 §2): the same excerpt/delta shaping lives
562
+ // in modules/lifecycle/prose-edits.js for goals + criteria. A module may not
563
+ // require a sibling module, and this is 6 lines of formatting — copied
564
+ // deliberately and noted at both sites rather than pushed into the doorway.
565
+ const BODY_AUDIT_EXCERPT = 120;
566
+ async function updateIdeaBody(id, bodyMd) {
567
+ const ideaId = Number(id);
568
+ if (!Number.isFinite(ideaId)) throw new Error('updateIdeaBody: numeric id required');
569
+ const { rows: before } = await pool.query(`SELECT ${IDEA_COLS} FROM idea_inbox WHERE id = $1`, [ideaId]);
570
+ if (before.length === 0) return null;
571
+ const clip = (v) => {
572
+ const t = v == null ? '' : String(v).replace(/\s+/g, ' ').trim();
573
+ return t.length > BODY_AUDIT_EXCERPT ? `${t.slice(0, BODY_AUDIT_EXCERPT - 1)}…` : t;
574
+ };
575
+ const prev = before[0].body_md;
576
+ if (prev === bodyMd) return { idea: before[0], delta: null };
577
+ const { rows } = await pool.query(
578
+ `UPDATE idea_inbox SET body_md = $2 WHERE id = $1 RETURNING ${IDEA_COLS}`,
579
+ [ideaId, bodyMd]
580
+ );
581
+ return {
582
+ idea: rows[0],
583
+ delta: {
584
+ field: 'body_md', from: clip(prev), to: clip(bodyMd),
585
+ from_len: prev == null ? 0 : String(prev).length,
586
+ to_len: bodyMd == null ? 0 : String(bodyMd).length,
587
+ },
588
+ };
589
+ }
590
+
549
591
  async function updateIdea(id, { status, kind, promoted_to_task_id, reviewed_at, merged_into_idea_id, triage_note } = {}) {
550
592
  if (merged_into_idea_id !== undefined && merged_into_idea_id !== null) {
551
593
  if (status !== 'merged') {
@@ -1056,6 +1098,7 @@ module.exports = {
1056
1098
  getIdeaGrade,
1057
1099
  // mutate
1058
1100
  updateIdea,
1101
+ updateIdeaBody,
1059
1102
  setIdeaGrade,
1060
1103
  setIdeaCompleteness,
1061
1104
  // idea → task lineage (task 1002997 / BV1.R173) — the one-idea-many-tasks
@@ -448,6 +448,81 @@ module.exports = function buildInboxRouter() {
448
448
  // archon-only to metic+archon when the three-rank model went live. Any
449
449
  // builder can still FILE an idea (POST /inbox above); a Xenos cannot triage.
450
450
  // rank: metic+archon — idea triage shapes the backlog; criterion C2 / #360 / ADR 0018.
451
+ // PATCH /inbox/:id/body — correct an idea's own TEXT (task 1003102 / idea 1000740).
452
+ //
453
+ // A SEPARATE route rather than a new field on PATCH /inbox/:id, deliberately.
454
+ // That route is gated `requirePermission('idea.triage')`, and the ask here is
455
+ // that an idea's AUTHOR can fix their own words. Adding body_md there would have
456
+ // meant dropping the whole triage route to requireBuilder and re-authorizing
457
+ // every field in-handler — widening a pinned route's classified rank to reach
458
+ // one field. A new route with its own pin widens nothing: triage keeps its gate,
459
+ // and this one carries its own, narrower rule.
460
+ //
461
+ // AUTHOR (idea_inbox.captured_by) or a triager. The author case is the point:
462
+ // fixing your own typo must not require escalating to a Metic.
463
+ // rank: any-builder (requireBuilder) + the in-handler author|triage gate.
464
+ // Pinned in route-rank-check.MODULE_EXPECTED_RANKS['ideas'].
465
+ // asyncHandler (the doorway wrapper) owns the catch + the 500 log, so this
466
+ // route carries no console.error of its own — one central log site instead of
467
+ // a 16th copy of the same two lines in this file.
468
+ router.patch('/inbox/:id/body', api.requireBuilder, api.asyncHandler('PATCH /inbox/:id/body', async (req, res) => {
469
+ if (validateOrRespond(req, res, {
470
+ body_md: { required: true, type: 'string', minLength: 1, maxLength: LIMITS.DESCRIPTION },
471
+ })) return;
472
+ const id = Number(req.params.id);
473
+ if (!Number.isFinite(id)) return res.fail('bad_idea_id', 400);
474
+ const existing = await inbox.getIdea(id);
475
+ if (!existing) return res.fail('idea_not_found', 404);
476
+ // Ordered + fail-closed: authority first, and it reveals nothing about the
477
+ // idea a non-author could not already read from the inbox.
478
+ const isAuthor = existing.captured_by != null
479
+ && String(existing.captured_by) === String(req.builder.id);
480
+ // The doorway exposes requirePermission as MIDDLEWARE, not a callable
481
+ // predicate, and this decision is in-handler (it depends on the row), so the
482
+ // triager arm is expressed as the rank floor that `idea.triage` resolves to
483
+ // — the same floor PATCH /inbox/:id classifies at (metic+archon). Stated
484
+ // rather than hidden: if the permission's floor ever moves, this must move
485
+ // with it, and tests/prose_edits.mjs pins the pair.
486
+ // rankMeetsThreshold takes the ALLOWED-rank ARRAY (the floor is its
487
+ // lowest-authority member), not a single rank — passing a string makes it
488
+ // throw on .reduce, which is exactly the trap a dead fallback would have hidden.
489
+ const canTriage = api.rankMeetsThreshold(req.builder.rank, ['metic', 'archon']);
490
+ if (!isAuthor && !canTriage) {
491
+ return res.fail('not_idea_author', { status: 403, message: 'Only the idea\'s author (or a triager) may correct its text.' });
492
+ }
493
+ const result = await inbox.updateIdeaBody(id, req.body.body_md);
494
+ if (!result) return res.fail('idea_not_found', 404);
495
+ if (result.delta) {
496
+ const d = result.delta;
497
+ // Through api.logger — the seam this file already uses (see
498
+ // /inbox/awaiting-nod) — not a raw console call.
499
+ api.logger('ideas').info(`idea ${id} body edited by builder ${req.builder.id}${isAuthor ? ' (author)' : ' (triage)'}: "${d.from}" -> "${d.to}" (${d.from_len}->${d.to_len} chars)`);
500
+ // ...AND persist it. Task item 4 asks for an audit ROW on all three prose
501
+ // surfaces, and a log line is not one: not queryable, and it rotates away.
502
+ // The goal + criterion paths get this from lifecycle's recordProseEdit; a
503
+ // module may not require a sibling module, so this writes the same shape
504
+ // through the doorway's own insertAuditLog — boundary-forced duplication
505
+ // (ADR 0091 §2), noted at both sites. Best-effort: the edit already
506
+ // committed, so a failed audit write must not fail the request — but it is
507
+ // logged as an AUDIT GAP rather than swallowed, because a silent gap is the
508
+ // failure mode this task exists to close.
509
+ try {
510
+ await api.insertAuditLog({
511
+ builderId: req.builder.id,
512
+ route: `idea:${id}:prose-edit`,
513
+ method: 'PATCH',
514
+ requestBodyRedacted: JSON.stringify({ deltas: [d] }),
515
+ responseStatus: 200,
516
+ ip: req.ip || null,
517
+ userAgent: req.get ? req.get('user-agent') : null,
518
+ });
519
+ } catch (auditErr) {
520
+ api.logger('ideas').error(`AUDIT GAP: idea ${id} body edit landed but its audit row failed: ${auditErr && auditErr.message}`);
521
+ }
522
+ }
523
+ return res.json({ ok: true, idea: result.idea, delta: result.delta, unchanged: !result.delta });
524
+ }, { errorCode: 'idea_body_update_failed' }));
525
+
451
526
  router.patch('/inbox/:id', api.requireBuilder, api.requirePermission('idea.triage'), async (req, res) => {
452
527
  if (validateOrRespond(req, res, {
453
528
  // Enum kept light — inbox.updateIdea returns a structured BAD_STATUS
@@ -3863,7 +3863,7 @@ async function shipTask({ taskId, builderId }, deps = {}) {
3863
3863
  justUnlocked = [];
3864
3864
  }
3865
3865
  }
3866
-
3866
+ await doneWhen.closeCompletedWorkForShip(client, taskId); // task 1002069/ADR 0183 — in-txn criterion+goal close
3867
3867
  return {
3868
3868
  shipRow: rows[0], graduationBuilder, rankGraduation, actualShipperId,
3869
3869
  closedClaimCount: closedClaims.length,