@bongos/core 1.19.1043 → 1.19.1044

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.
package/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.1043",
6
- "core_contract": "1.19.1043",
7
- "source_commit": "a7f87df67be821c4552587a83313fdd13e3a3c17",
5
+ "core_version": "1.19.1044",
6
+ "core_contract": "1.19.1044",
7
+ "source_commit": "34b337286c8c548570070f55a237ae07ff128c22",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-26T17:20:34.417Z",
9
+ "built_at": "2026-09-26T17:54:25.223Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 543,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2513,
14
+ "functional_verbatim": 2515,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 3083,
20
- "tree_sha256": "a9b86c4c4e0acc27d6554d3d5ea4240bae592dde6b1edd0a2f070d422194ca1b",
19
+ "file_count": 3085,
20
+ "tree_sha256": "9e1fcf88474d43f87fc0192c0deed9eedbb6ba08ac68bcb3d432e3acdc7ee237",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -352,12 +352,12 @@
352
352
  {
353
353
  "path": "clients/bongos-client/README.md",
354
354
  "mode": "0000644",
355
- "sha256": "7073eca28ea1e8ebab33e072c688c2b0e6cf82671849c8e7684a576796efc901"
355
+ "sha256": "1a52f7ad05de6ca7410e4899cfecba6821ead741bc437afcfdf9904baf2cb403"
356
356
  },
357
357
  {
358
358
  "path": "clients/bongos-client/bongos-client.global.js",
359
359
  "mode": "0000644",
360
- "sha256": "48b9d83d0ee81e63d5a94be73a0cb505fac99fd9f049a45d0f28e15e112602b1"
360
+ "sha256": "7bbce7057aaad2ee184924e3f5cee7c7a543785c6f54a20bee82997b7825d27f"
361
361
  },
362
362
  {
363
363
  "path": "clients/bongos-client/examples/hello-world.mjs",
@@ -367,17 +367,17 @@
367
367
  {
368
368
  "path": "clients/bongos-client/index.cjs",
369
369
  "mode": "0000644",
370
- "sha256": "0cd41f959d6b8b2e7be0f317fc1cfe0ef83d35f48338810d5d0bc73307226c74"
370
+ "sha256": "685b1eb0d4fac786d931b5cb97523c26cf3ff4f2b5da23dbf3460e16f65cddee"
371
371
  },
372
372
  {
373
373
  "path": "clients/bongos-client/index.d.ts",
374
374
  "mode": "0000644",
375
- "sha256": "8c14cf27d91d8c9ae4c6e75b94cecc403daad87f37b8a04bc03f76b8bf11c3a4"
375
+ "sha256": "a5b32997f5d8f8c2415e194a05c208c40efde8a9a0277fa08468cbde35aeebef"
376
376
  },
377
377
  {
378
378
  "path": "clients/bongos-client/index.mjs",
379
379
  "mode": "0000644",
380
- "sha256": "e380741ea83429e405e684d629044c067ff2e2d308fb8ef2a4a94c3c37c7c120"
380
+ "sha256": "e6ebb2574a13931d3777d27d45c620dd36e02b0bd9efd1d4b1d5cf7051f60990"
381
381
  },
382
382
  {
383
383
  "path": "clients/bongos-client/package.json",
@@ -2207,12 +2207,12 @@
2207
2207
  {
2208
2208
  "path": "docs/api-reference.md",
2209
2209
  "mode": "0000644",
2210
- "sha256": "06c5bea02e1e479aac06addd9a9fb71c9da889030cf4687d1bb89ba2d8c68038"
2210
+ "sha256": "2830b600af9e930611ede0897e0da0678e92815288377254c2e29c3bb7f9ac97"
2211
2211
  },
2212
2212
  {
2213
2213
  "path": "docs/api/openapi.json",
2214
2214
  "mode": "0000644",
2215
- "sha256": "d94e6449c03eac000296fe13ebd81c066333ffc04b7d325e9b30c2ab569fd200"
2215
+ "sha256": "d2512a57a976e3947fef2bbaeb441dd8077ad04d11168524fb64127b34da338e"
2216
2216
  },
2217
2217
  {
2218
2218
  "path": "docs/architecture.md",
@@ -2772,7 +2772,7 @@
2772
2772
  {
2773
2773
  "path": "docs/module-api-changelog.md",
2774
2774
  "mode": "0000644",
2775
- "sha256": "21d0dd30bb815e977954716bff215839a0877f32f1e95dcd183635e5a0246964"
2775
+ "sha256": "376bff3138af4e48742c5f3a4a89e4fb2f34d55a1a0ce1cbe26544d807b24317"
2776
2776
  },
2777
2777
  {
2778
2778
  "path": "docs/modules-contract.md",
@@ -3904,6 +3904,11 @@
3904
3904
  "mode": "0000644",
3905
3905
  "sha256": "14924b5db9c9620e17a4cd5baa4c7821aa97db3d11711831faeb45fbfb035213"
3906
3906
  },
3907
+ {
3908
+ "path": "migrations/core_256_blocker_unlink_releases_task.sql",
3909
+ "mode": "0000644",
3910
+ "sha256": "a201e803f55458a1224bee5a48a88d311f663bcca2c5b8939e422f3ec90e4929"
3911
+ },
3907
3912
  {
3908
3913
  "path": "modules/agents/CLAUDE.md",
3909
3914
  "mode": "0000644",
@@ -5697,7 +5702,7 @@
5697
5702
  {
5698
5703
  "path": "modules/ideas/blockers.js",
5699
5704
  "mode": "0000644",
5700
- "sha256": "e372a43f0aeff5a83075d83d2db2733b1be6e7e28dc4631901772af82914c9a6"
5705
+ "sha256": "8bab6ae99fcc4b72e9d46ee51b8d1034931e22e77cf6537f0f11c63d62c46d7e"
5701
5706
  },
5702
5707
  {
5703
5708
  "path": "modules/ideas/born-of.js",
@@ -5822,7 +5827,7 @@
5822
5827
  {
5823
5828
  "path": "modules/ideas/routes/blockers.js",
5824
5829
  "mode": "0000644",
5825
- "sha256": "551b485b7d7ed9d6e8f28b0baea7154ffe9e9cee093c296a2097d585923340c7"
5830
+ "sha256": "0746d83375ce1f76c578d17aae21a8afe9ac4c07e081c519489d63028fee9147"
5826
5831
  },
5827
5832
  {
5828
5833
  "path": "modules/ideas/routes/inbox.js",
@@ -8502,12 +8507,12 @@
8502
8507
  {
8503
8508
  "path": "package-lock.json",
8504
8509
  "mode": "0000644",
8505
- "sha256": "f422ee23bd52d7f90f4bc43727d8f25dda5369ee018a9ebe8d61c8adb12a0e1f"
8510
+ "sha256": "1e36601af2ae18c0a0ece2e3a1eda00978d5cbe9b29bf89623be84fb3d1aa1c7"
8506
8511
  },
8507
8512
  {
8508
8513
  "path": "package.json",
8509
8514
  "mode": "0000644",
8510
- "sha256": "c696fd88b366bf880bbf8358a1502f26dedb4229aee867a6d73e944897529762"
8515
+ "sha256": "18a7a52341443b1ebdeac607508a0559fa54850c6f44cb9b36be4fc59cb20ed3"
8511
8516
  },
8512
8517
  {
8513
8518
  "path": "public-docs/index.html",
@@ -8527,7 +8532,7 @@
8527
8532
  {
8528
8533
  "path": "release-notes.json",
8529
8534
  "mode": "0000644",
8530
- "sha256": "f1a8435bcf9e0329846f00f25418d6fa671fb943c34521f4420b85074d15cdf6"
8535
+ "sha256": "4a6d22fce202bfef29ad1e4b2fe68f56207eaea1eb724a3f7c784321c78093de"
8531
8536
  },
8532
8537
  {
8533
8538
  "path": "scripts/bongos-mcp.js",
@@ -10597,7 +10602,7 @@
10597
10602
  {
10598
10603
  "path": "src/module-api.js",
10599
10604
  "mode": "0000644",
10600
- "sha256": "28057a8787770e36c9992cac05d5bdc4166b1442da16448cac9c335bf3784637"
10605
+ "sha256": "35c167db08eac18ea4cd0f3919855a5e2f3301ced410e556295ba5694af8cd1e"
10601
10606
  },
10602
10607
  {
10603
10608
  "path": "src/module-loader/catalog.js",
@@ -11129,6 +11134,11 @@
11129
11134
  "mode": "0000644",
11130
11135
  "sha256": "3a85b9e9bee4258aa64d26ca025d52c605f5f33938d3fdd5a31c393f1ec3f249"
11131
11136
  },
11137
+ {
11138
+ "path": "tests/blocker_unlink.mjs",
11139
+ "mode": "0000644",
11140
+ "sha256": "b1e13e7372d08f573072b47d0a41314fe33e3eb789dbf5ad4ae130efc7cc6419"
11141
+ },
11132
11142
  {
11133
11143
  "path": "tests/blockers_hall.mjs",
11134
11144
  "mode": "0000644",
@@ -5,7 +5,7 @@ A **generated**, zero-dependency typed client for the Bongos API — produced fr
5
5
  by hand; it regenerates when the spec changes, so it can never drift from the routes.
6
6
 
7
7
  - API version: **v1** (served at `/api/bongos/v1`)
8
- - 442 operations across 68 resource groups
8
+ - 443 operations across 68 resource groups
9
9
 
10
10
  ## Use it from your project
11
11
 
@@ -207,6 +207,8 @@ function createClient(opts = {}) {
207
207
  getBlockersId: (args) => request("GET", "/blockers/{id}", { hasBody: false }, args),
208
208
  // POST /blockers/{id}/link — rank: metic+archon — POST /blockers/:id/link
209
209
  postBlockersIdLink: (args) => request("POST", "/blockers/{id}/link", { hasBody: true }, args),
210
+ // DELETE /blockers/{id}/link/{taskId} — rank: metic+archon — DELETE /blockers/:id/link/:taskId
211
+ deleteBlockersIdLinkTaskId: (args) => request("DELETE", "/blockers/{id}/link/{taskId}", { hasBody: false }, args),
210
212
  // POST /blockers/{id}/resolve — rank: metic+archon — POST /blockers/:id/resolve
211
213
  postBlockersIdResolve: (args) => request("POST", "/blockers/{id}/resolve", { hasBody: true }, args),
212
214
  },
@@ -206,6 +206,8 @@ function createClient(opts = {}) {
206
206
  getBlockersId: (args) => request("GET", "/blockers/{id}", { hasBody: false }, args),
207
207
  // POST /blockers/{id}/link — rank: metic+archon — POST /blockers/:id/link
208
208
  postBlockersIdLink: (args) => request("POST", "/blockers/{id}/link", { hasBody: true }, args),
209
+ // DELETE /blockers/{id}/link/{taskId} — rank: metic+archon — DELETE /blockers/:id/link/:taskId
210
+ deleteBlockersIdLinkTaskId: (args) => request("DELETE", "/blockers/{id}/link/{taskId}", { hasBody: false }, args),
209
211
  // POST /blockers/{id}/resolve — rank: metic+archon — POST /blockers/:id/resolve
210
212
  postBlockersIdResolve: (args) => request("POST", "/blockers/{id}/resolve", { hasBody: true }, args),
211
213
  },
@@ -6,6 +6,7 @@ export declare const DEFAULT_BASE_URL: string;
6
6
  // Request-body + shared types, generated from the OpenAPI components.schemas.
7
7
  export interface ApiErrorObject { code: string; message: string; details?: unknown }
8
8
  export interface DeleteAgentsNameResponse { ok: boolean; deleted: unknown }
9
+ export interface DeleteBlockersIdLinkTaskIdResponse { ok: boolean; unlinked: boolean; released: unknown }
9
10
  export interface DeleteBoxSshKeyIdResponse { ok: boolean; deleted: unknown }
10
11
  export interface DeleteConnectionsIdResponse { outcome: unknown }
11
12
  export interface DeleteDependenciesResponse { ok: boolean }
@@ -622,6 +623,8 @@ export interface BongosClient {
622
623
  getBlockersId(args?: RequestArgs): Promise<GetBlockersIdResponse>;
623
624
  /** POST /blockers/{id}/link — rank: metic+archon */
624
625
  postBlockersIdLink(args: RequestArgs & { body: PostBlockersIdLinkRequest }): Promise<PostBlockersIdLinkResponse>;
626
+ /** DELETE /blockers/{id}/link/{taskId} — rank: metic+archon */
627
+ deleteBlockersIdLinkTaskId(args?: RequestArgs): Promise<DeleteBlockersIdLinkTaskIdResponse>;
625
628
  /** POST /blockers/{id}/resolve — rank: metic+archon */
626
629
  postBlockersIdResolve(args?: RequestArgs & { body?: PostBlockersIdResolveRequest }): Promise<ApiResponse>;
627
630
  };
@@ -203,6 +203,8 @@ export function createClient(opts = {}) {
203
203
  getBlockersId: (args) => request("GET", "/blockers/{id}", { hasBody: false }, args),
204
204
  // POST /blockers/{id}/link — rank: metic+archon — POST /blockers/:id/link
205
205
  postBlockersIdLink: (args) => request("POST", "/blockers/{id}/link", { hasBody: true }, args),
206
+ // DELETE /blockers/{id}/link/{taskId} — rank: metic+archon — DELETE /blockers/:id/link/:taskId
207
+ deleteBlockersIdLinkTaskId: (args) => request("DELETE", "/blockers/{id}/link/{taskId}", { hasBody: false }, args),
206
208
  // POST /blockers/{id}/resolve — rank: metic+archon — POST /blockers/:id/resolve
207
209
  postBlockersIdResolve: (args) => request("POST", "/blockers/{id}/resolve", { hasBody: true }, args),
208
210
  },
@@ -2456,6 +2456,70 @@
2456
2456
  ]
2457
2457
  }
2458
2458
  },
2459
+ "/blockers/{id}/link/{taskId}": {
2460
+ "delete": {
2461
+ "operationId": "delete_blockers_id_link_taskId",
2462
+ "tags": [
2463
+ "blockers"
2464
+ ],
2465
+ "summary": "DELETE /blockers/:id/link/:taskId",
2466
+ "description": "Unlink a blocker from a task — the inverse of /link (task 1003518). Both ids sit in the PATH, matching DELETE /tasks/:id/dependencies/:depId, the closest existing shape: an edge between two rows, removed by naming both. Post-core_223 a task is released only when its LAST open blocker resolves, so a wrong link is permanent: the only way out was to resolve a blocker that was never real, putting a falsehood in the blocker ledger to clear a bookkeeping artifact. This is the honest way out. Promotion is the TRIGGER's job here exactly as it is on /resolve: core_256 fires AFTER DELETE on task_blockers and re-uses core_223's predicate, so \"nothing open is left against this task\" has one definition, not two. blockers.unlinkBlockerFromTask only deletes and reads back what happened. `released` says whether the task actually moved, never a bare {ok:true} (task 1002559 — a write that reports success without saying what changed is how a dead loop hides). Metic+ (task 360 / ADR 0018) — same blocker-review grant as link and resolve. rank: metic+archon — unlink a blocker from a task; same blocker-review grant as link.\n\n**Rank:** `metic+archon` — Metic or Archon rank (review/triage powers).\n\n**Permissions:** `blocker.review` (all required).",
2467
+ "x-rank": "metic+archon",
2468
+ "x-source": "modules/ideas/routes/blockers.js",
2469
+ "x-permissions": [
2470
+ "blocker.review"
2471
+ ],
2472
+ "parameters": [
2473
+ {
2474
+ "name": "id",
2475
+ "in": "path",
2476
+ "required": true,
2477
+ "schema": {
2478
+ "type": "string"
2479
+ },
2480
+ "description": "Path parameter `id`."
2481
+ },
2482
+ {
2483
+ "name": "taskId",
2484
+ "in": "path",
2485
+ "required": true,
2486
+ "schema": {
2487
+ "type": "string"
2488
+ },
2489
+ "description": "Path parameter `taskId`."
2490
+ }
2491
+ ],
2492
+ "responses": {
2493
+ "200": {
2494
+ "description": "Success.",
2495
+ "content": {
2496
+ "application/json": {
2497
+ "schema": {
2498
+ "$ref": "#/components/schemas/DeleteBlockersIdLinkTaskIdResponse"
2499
+ }
2500
+ }
2501
+ }
2502
+ },
2503
+ "400": {
2504
+ "$ref": "#/components/responses/BadRequest"
2505
+ },
2506
+ "401": {
2507
+ "$ref": "#/components/responses/Unauthorized"
2508
+ },
2509
+ "403": {
2510
+ "$ref": "#/components/responses/Forbidden"
2511
+ },
2512
+ "404": {
2513
+ "$ref": "#/components/responses/NotFound"
2514
+ }
2515
+ },
2516
+ "security": [
2517
+ {
2518
+ "builderSession": []
2519
+ }
2520
+ ]
2521
+ }
2522
+ },
2459
2523
  "/blockers/{id}/resolve": {
2460
2524
  "post": {
2461
2525
  "operationId": "post_blockers_id_resolve",
@@ -20477,6 +20541,23 @@
20477
20541
  "deleted"
20478
20542
  ]
20479
20543
  },
20544
+ "DeleteBlockersIdLinkTaskIdResponse": {
20545
+ "type": "object",
20546
+ "properties": {
20547
+ "ok": {
20548
+ "type": "boolean"
20549
+ },
20550
+ "unlinked": {
20551
+ "type": "boolean"
20552
+ },
20553
+ "released": {}
20554
+ },
20555
+ "required": [
20556
+ "ok",
20557
+ "unlinked",
20558
+ "released"
20559
+ ]
20560
+ },
20480
20561
  "DeleteBoxSshKeyIdResponse": {
20481
20562
  "type": "object",
20482
20563
  "properties": {
@@ -28040,9 +28121,9 @@
28040
28121
  "description": "A required dependency/feature is not configured or is temporarily down."
28041
28122
  }
28042
28123
  },
28043
- "x-endpoint-count": 442,
28044
- "x-schema-count": 482,
28124
+ "x-endpoint-count": 443,
28125
+ "x-schema-count": 483,
28045
28126
  "x-undocumented-bodies": 9,
28046
- "x-response-schemas": 330,
28127
+ "x-response-schemas": 331,
28047
28128
  "x-generated-by": "scripts/gds/gen-api-docs.js"
28048
28129
  }
@@ -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. 442 endpoints across 76 route files.
5
+ > **Generated from the live route files** — the route file is authoritative. 443 endpoints across 76 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/0016-trust-boundary-server-enforced-permissions.md)): `public` < `any-builder` < `metic+archon` < `archon`.
@@ -104,7 +104,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/0016-trust
104
104
  | GET | `/api/bongos/backup/status` | `metic+archon` | — | any working builder may check or trigger a backup. |
105
105
  | POST | `/api/bongos/backup/trigger` | `metic+archon` | — | kicks off a fresh dump; same gate as the status read. |
106
106
 
107
- ## `blockers` (5)
107
+ ## `blockers` (6)
108
108
 
109
109
  | Method | Path | Rank | Body | Description |
110
110
  |---|---|---|---|---|
@@ -112,6 +112,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/0016-trust
112
112
  | POST | `/api/bongos/blockers` | `any-builder` | `title`, `body_md`, `source`, `source_ref` | FILE a blocker (contribution, not resolution). |
113
113
  | GET | `/api/bongos/blockers/:id` | `any-builder` | — | single blocker detail read. |
114
114
  | POST | `/api/bongos/blockers/:id/link` | `metic+archon` | `task_id` | Link an existing blocker to a task. |
115
+ | DELETE | `/api/bongos/blockers/:id/link/:taskId` | `metic+archon` | — | Unlink a blocker from a task — the inverse of /link (task 1003518). |
115
116
  | POST | `/api/bongos/blockers/:id/resolve` | `metic+archon` | `resolution_note` | Resolve. |
116
117
 
117
118
  ## `box` (20)
@@ -2573,5 +2573,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2573
2573
  landed since 1.19.1041 with no explicit bump. run 36254852933. (task 1002620)
2574
2574
  1.19.1043 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2575
2575
  landed since 1.19.1042 with no explicit bump. run 36258640780. (task 1002620)
2576
+ 1.19.1044 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2577
+ landed since 1.19.1043 with no explicit bump. run 36260641494. (task 1002620)
2576
2578
  ---------------------------------------------------------------------------
2577
2579
  ```
@@ -0,0 +1,84 @@
1
+ -- core_256_blocker_unlink_releases_task.sql — unlinking a blocker from a task
2
+ -- must release that task when it was the last thing holding it (task 1003518).
3
+ --
4
+ -- THE GAP. `POST /blockers/:id/link` parks a 'ready' task in 'blocked' so the
5
+ -- resolve trigger (009, tightened by core_223) has something to promote. Nothing
6
+ -- removed a link. Post-core_223 a task is released only when its LAST open
7
+ -- blocker resolves, so a wrong link — wrong task id, a duplicate filed by two
8
+ -- people, a blocker later understood to be irrelevant — was permanent. The only
9
+ -- escape was to resolve a blocker that was never real, putting a falsehood in
10
+ -- the blocker ledger to clear a bookkeeping artifact. core_223's own header
11
+ -- names this: "there is no unlink route to detach one first."
12
+ --
13
+ -- WHY A TRIGGER AND NOT THE ROUTE. tests/blocker_resolve_guard.mjs pins that
14
+ -- modules/ideas/blockers.js never writes `tasks.status = 'ready'` itself: "if it
15
+ -- ever started UPDATE-ing tasks.status, this guard would be bypassable and the
16
+ -- fix would need to live in two places." An unlink that promoted in JS would be
17
+ -- exactly that second place — a hand-copy of the predicate below, free to drift
18
+ -- from it. Putting the release here keeps ONE definition of "this task has
19
+ -- nothing open against it any more", and has a second payoff: the direct
20
+ -- `DELETE FROM task_blockers` that was the only workaround before this task now
21
+ -- does the right thing too, instead of silently leaving the task stranded.
22
+ --
23
+ -- NO `b.id <> …` EXCLUSION, AND THAT IS THE DIFFERENCE FROM core_223. That
24
+ -- migration runs in a BEFORE UPDATE trigger, where the blocker being resolved
25
+ -- still reads 'open' in the table and would count itself as an open blocker —
26
+ -- so without the exclusion nothing would ever promote. This is an AFTER DELETE
27
+ -- trigger: the link row is already gone when the body runs, so the NOT EXISTS
28
+ -- already sees exactly the blockers that remain. Adding the exclusion here would
29
+ -- be cargo-cult; omitting it is deliberate.
30
+ --
31
+ -- Deliberately NOT gating on dependencies, for core_223's reason: the
32
+ -- dep-promotion triggers (163) only ever flip 'backlog' -> 'ready', never
33
+ -- 'blocked' -> 'ready', so a task this left in 'blocked' for unshipped deps
34
+ -- would have no release path at all. The claim path re-checks DEPS_NOT_SHIPPED
35
+ -- as its second line of defence. Blocker-clearing and dep-clearing stay separate.
36
+ --
37
+ -- ONLY 'blocked' IS REWRITTEN. A task that was never parked (link arrived while
38
+ -- it was active/backlog/shipped — see LINK_BLOCKABLE_STATUSES) is left exactly
39
+ -- as it is, so an unlink can never launder a task past the metic+archon promote
40
+ -- gate.
41
+ --
42
+ -- Legacy markdown-anchor links (a task_blockers row with blocker_ref and no
43
+ -- blocker_id) are out of scope, the same scope core_223 and 176 draw: the JOIN
44
+ -- below only sees structured links, so a legacy row neither blocks nor releases.
45
+ --
46
+ -- CASCADE SAFETY. task_blockers.task_id and .blocker_id are both ON DELETE
47
+ -- CASCADE. Nothing in the repo hard-deletes a task (abandon is terminal) or a
48
+ -- blocker (resolve is), so in practice this fires only on a real unlink. If a
49
+ -- blocker ever were deleted outright, releasing the tasks it held is the correct
50
+ -- reading — the thing that was blocking them no longer exists.
51
+ --
52
+ -- Idempotent: CREATE OR REPLACE for the function, DROP IF EXISTS + CREATE for
53
+ -- the trigger (a trigger has no CREATE OR REPLACE before PG 14, and this must
54
+ -- apply cleanly on a re-run).
55
+
56
+ BEGIN;
57
+
58
+ CREATE OR REPLACE FUNCTION trg_release_task_on_blocker_unlink()
59
+ RETURNS TRIGGER AS $$
60
+ BEGIN
61
+ UPDATE tasks
62
+ SET status = 'ready', updated_at = now()
63
+ WHERE id = OLD.task_id
64
+ AND status = 'blocked'
65
+ -- … and nothing open is left against this task (core_223's predicate,
66
+ -- minus the self-exclusion an AFTER DELETE trigger does not need).
67
+ AND NOT EXISTS (
68
+ SELECT 1
69
+ FROM task_blockers tb
70
+ JOIN blockers b ON b.id = tb.blocker_id
71
+ WHERE tb.task_id = OLD.task_id
72
+ AND b.status = 'open'
73
+ );
74
+ RETURN OLD;
75
+ END;
76
+ $$ LANGUAGE plpgsql;
77
+
78
+ DROP TRIGGER IF EXISTS release_task_on_blocker_unlink ON task_blockers;
79
+ CREATE TRIGGER release_task_on_blocker_unlink
80
+ AFTER DELETE ON task_blockers
81
+ FOR EACH ROW
82
+ EXECUTE FUNCTION trg_release_task_on_blocker_unlink();
83
+
84
+ COMMIT;
@@ -147,6 +147,65 @@ async function linkBlockerToTask({ blockerId, taskId }) {
147
147
  }
148
148
  }
149
149
 
150
+ // Unlink a blocker from a task — the missing inverse of linkBlockerToTask
151
+ // (task 1003518). Linking is one-way and Metic+, so before this a wrong link
152
+ // (wrong task id, a duplicate filed by two people, a blocker later understood
153
+ // to be irrelevant) could only be undone by a direct DELETE on task_blockers.
154
+ //
155
+ // LIKE resolveBlocker, THIS ONLY OBSERVES. The release lives in a DB trigger
156
+ // (core_256), for the reason tests/blocker_resolve_guard.mjs states about the
157
+ // resolve path: a promotion written here would be a second copy of core_223's
158
+ // predicate, free to drift from it. One DELETE, then a read-back of what the
159
+ // trigger did — the same shape resolveBlocker uses for `promoted_tasks`.
160
+ //
161
+ // The three statements share a transaction so `released` cannot be a lie told
162
+ // by a concurrent writer between the delete and the read-back.
163
+ //
164
+ // `poolOverride` is injected by tests only, and is named so it cannot shadow the
165
+ // module-level `pool` this file closes over.
166
+ async function unlinkBlockerFromTask({ blockerId, taskId }, { pool: poolOverride = pool } = {}) {
167
+ const client = await poolOverride.connect();
168
+ try {
169
+ await client.query('BEGIN');
170
+ // Status BEFORE, so `released` means "this unlink moved it", not merely
171
+ // "it is ready now" — a task linked while it was never blockable would
172
+ // otherwise report a release that never happened (the task-1002559 lesson:
173
+ // a write that reports success without saying what changed hides a dead
174
+ // loop). FOR UPDATE so the row cannot move under the read-back below.
175
+ const { rows: before } = await client.query(
176
+ `SELECT status FROM tasks WHERE id = $1 FOR UPDATE`,
177
+ [taskId]
178
+ );
179
+ const { rows: removed } = await client.query(
180
+ `DELETE FROM task_blockers
181
+ WHERE task_id = $1 AND blocker_id = $2
182
+ RETURNING task_id`,
183
+ [taskId, blockerId]
184
+ );
185
+ // Answer honestly when nothing was there: a missing link is a refusal, not
186
+ // a silent success a caller would read as "detached".
187
+ if (removed.length === 0) {
188
+ await client.query('ROLLBACK');
189
+ const e = new Error('no such blocker link on that task');
190
+ e.code = 'LINK_NOT_FOUND';
191
+ throw e;
192
+ }
193
+ const { rows: after } = await client.query(
194
+ `SELECT status FROM tasks WHERE id = $1`,
195
+ [taskId]
196
+ );
197
+ await client.query('COMMIT');
198
+ const was = before[0] && before[0].status;
199
+ const now = after[0] && after[0].status;
200
+ return { unlinked: true, released: was === 'blocked' && now === 'ready' };
201
+ } catch (err) {
202
+ if (err.code !== 'LINK_NOT_FOUND') await client.query('ROLLBACK');
203
+ throw err;
204
+ } finally {
205
+ client.release();
206
+ }
207
+ }
208
+
150
209
  module.exports = {
151
210
  listOpenBlockers,
152
211
  countOpenBlockers,
@@ -154,6 +213,7 @@ module.exports = {
154
213
  createBlocker,
155
214
  resolveBlocker,
156
215
  linkBlockerToTask,
216
+ unlinkBlockerFromTask,
157
217
  blocksOnLink,
158
218
  LINK_BLOCKABLE_STATUSES,
159
219
  };
@@ -145,5 +145,46 @@ module.exports = function buildBlockersRouter() {
145
145
  }
146
146
  });
147
147
 
148
+ // Unlink a blocker from a task — the inverse of /link (task 1003518). Both ids
149
+ // sit in the PATH, matching DELETE /tasks/:id/dependencies/:depId, the closest
150
+ // existing shape: an edge between two rows, removed by naming both.
151
+ //
152
+ // Post-core_223 a task is released only when its LAST open blocker resolves,
153
+ // so a wrong link is permanent: the only way out was to resolve a blocker that
154
+ // was never real, putting a falsehood in the blocker ledger to clear a
155
+ // bookkeeping artifact. This is the honest way out.
156
+ //
157
+ // Promotion is the TRIGGER's job here exactly as it is on /resolve: core_256
158
+ // fires AFTER DELETE on task_blockers and re-uses core_223's predicate, so
159
+ // "nothing open is left against this task" has one definition, not two.
160
+ // blockers.unlinkBlockerFromTask only deletes and reads back what happened.
161
+ //
162
+ // `released` says whether the task actually moved, never a bare {ok:true}
163
+ // (task 1002559 — a write that reports success without saying what changed is
164
+ // how a dead loop hides).
165
+ // Metic+ (#360 / ADR 0018) — same blocker-review grant as link and resolve.
166
+ // rank: metic+archon — unlink a blocker from a task; same blocker-review grant as link.
167
+ router.delete('/blockers/:id/link/:taskId', api.requireBuilder, api.requirePermission('blocker.review'), async (req, res) => {
168
+ // Both ids strict positive integers (`positiveInt`), via the shared parseId
169
+ // rather than a hand-rolled check: a `7.5` would otherwise pass a bare
170
+ // isFinite and then simply miss on the DELETE, answering "not linked" for
171
+ // what is really a malformed id. The sibling link/resolve routes predate
172
+ // that option; this one has no reason to be looser than it can be.
173
+ const id = parseId(req, res, { code: 'bad_id', positiveInt: true });
174
+ if (id === null) return;
175
+ const taskId = parseId(req, res, { param: 'taskId', code: 'bad_task_id', positiveInt: true });
176
+ if (taskId === null) return;
177
+ try {
178
+ const { released } = await blockers.unlinkBlockerFromTask({ blockerId: id, taskId });
179
+ res.json({ ok: true, unlinked: true, released });
180
+ } catch (err) {
181
+ if (err.code === 'LINK_NOT_FOUND') {
182
+ return res.fail('link_not_found', { status: 404, message: 'that blocker is not linked to that task' });
183
+ }
184
+ console.error('[gds] DELETE /blockers/:id/link/:taskId', err);
185
+ res.fail('unlink_failed', { status: 500, message: 'internal error' });
186
+ }
187
+ });
188
+
148
189
  return router;
149
190
  };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1043",
3
+ "version": "1.19.1044",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.1043",
9
+ "version": "1.19.1044",
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.1043",
3
+ "version": "1.19.1044",
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",
@@ -7559,5 +7559,11 @@
7559
7559
  "id": "1004301",
7560
7560
  "text": "A task's own page now says where its code is — which version carries it, whether it is running on Bongos, whether it is released — and says plainly when a task marked done never actually landed."
7561
7561
  }
7562
+ ],
7563
+ "1.19.1044": [
7564
+ {
7565
+ "id": "1003518",
7566
+ "text": "A blocker that was linked to the wrong task — a duplicate, or a mistake — can now be unlinked, and the task goes back to being claimable if nothing else was holding it. Before this the only way out was to mark the blocker reso"
7567
+ }
7562
7568
  ]
7563
7569
  }
package/src/module-api.js CHANGED
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
71
71
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
72
72
  // the entry to that file. Look for a version's history there, not here.
73
73
  // ---------------------------------------------------------------------------
74
- const CORE_VERSION = '1.19.1043'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.1044'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
75
75
 
76
76
  // A namespaced logger so a module's log lines are attributable + consistent.
77
77
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -0,0 +1,251 @@
1
+ // tests/blocker_unlink.mjs
2
+ //
3
+ // Task 1003518 — a blocker can be UNLINKED from a task. Linking is one-way and
4
+ // Metic+, and post-core_223 a task is released only when its LAST open blocker
5
+ // resolves, so a wrong link (wrong task id, a duplicate filed twice, a blocker
6
+ // later understood to be irrelevant) was permanent: the only escape was to
7
+ // resolve a blocker that was never real, putting a falsehood in the ledger.
8
+ //
9
+ // TWO HALVES, mirroring tests/blocker_resolve_guard.mjs:
10
+ // 1. the JS is an OBSERVER — it deletes and reads back, it never promotes;
11
+ // 2. the promotion itself is core_256's trigger, pinned as SQL TEXT so a
12
+ // later edit cannot quietly drop the guard or "simplify" it into a bug.
13
+ //
14
+ // The JS half is behavioural, not shape assertions. The module-level `pool` is
15
+ // replaced through require.cache BEFORE blockers.js loads, so the default
16
+ // (zero-injection) path is the one under test — the shape a `deps.pool || pool`
17
+ // shadowing bug would break while a harness that always injects stayed green.
18
+ //
19
+ // Run: node tests/blocker_unlink.mjs
20
+
21
+ import { strict as assert } from 'node:assert';
22
+ import { readFileSync, readdirSync } from 'node:fs';
23
+ import { fileURLToPath } from 'node:url';
24
+ import { dirname, join } from 'node:path';
25
+ import { createRequire } from 'node:module';
26
+ import { makeRunner, makeSqlAwareClient } from './helpers.mjs';
27
+
28
+ const __dirname = dirname(fileURLToPath(import.meta.url));
29
+ const repoRoot = join(__dirname, '..');
30
+ const read = (...p) => readFileSync(join(repoRoot, ...p), 'utf8');
31
+ const require = createRequire(import.meta.url);
32
+ const { test, summary } = makeRunner();
33
+
34
+ // -- the fake pool, installed as the module's OWN pool ----------------------
35
+
36
+ let current = null; // the pool the stubbed module-api hands out this test
37
+ let releases = 0; // how many times client.release() was called
38
+
39
+ const apiPath = require.resolve('../src/module-api.js');
40
+ require.cache[apiPath] = {
41
+ id: apiPath,
42
+ filename: apiPath,
43
+ loaded: true,
44
+ exports: { pool: { connect: (...a) => current.connect(...a) } },
45
+ };
46
+
47
+ const blockers = require('../modules/ideas/blockers.js');
48
+
49
+ // before/after: the task's status either side of the DELETE (the trigger is what
50
+ // changes it in production, so the fake simply reports what it would have done).
51
+ // deleted: rows the DELETE returns (0 = there was no such link).
52
+ // failOn: a substring; the first matching statement throws.
53
+ function scenario({ before = 'blocked', after = 'ready', deleted = 1, failOn = null } = {}) {
54
+ let seenDelete = false;
55
+ const pool = makeSqlAwareClient((sql) => {
56
+ if (failOn && sql.includes(failOn)) throw new Error('boom');
57
+ if (/^DELETE FROM task_blockers/.test(sql)) {
58
+ seenDelete = true;
59
+ return { rows: Array.from({ length: deleted }, () => ({ task_id: 7 })) };
60
+ }
61
+ if (/^SELECT status FROM tasks/.test(sql)) {
62
+ return { rows: [{ status: seenDelete ? after : before }] };
63
+ }
64
+ return { rows: [] };
65
+ });
66
+ const realRelease = pool._client.release.bind(pool._client);
67
+ pool._client.release = () => { releases += 1; realRelease(); };
68
+ current = pool;
69
+ releases = 0;
70
+ return pool;
71
+ }
72
+
73
+ const sqlOf = (pool) => pool.queries.map((q) => String(q.sql).replace(/\s+/g, ' ').trim());
74
+ const unlink = (opts) => blockers.unlinkBlockerFromTask({ blockerId: 93, taskId: 7 }, opts);
75
+
76
+ // == 1. the JS observes =====================================================
77
+
78
+ await test('the link row is deleted, keyed on BOTH ids', async () => {
79
+ const pool = scenario();
80
+ await unlink();
81
+ const del = pool.queries.find((q) => /DELETE FROM task_blockers/.test(q.sql));
82
+ assert.ok(del, 'no DELETE was issued');
83
+ // Order matters: the SQL is (task_id, blocker_id), so a swap deletes a
84
+ // different edge — or silently none.
85
+ assert.deepEqual(del.params, [7, 93]);
86
+ });
87
+
88
+ await test('the JS never promotes — that is core_256s job', async () => {
89
+ // The same assertion blocker_resolve_guard.mjs makes about the resolve path,
90
+ // restated for unlink so the next person to "just fix it here" trips a test.
91
+ const src = read('modules', 'ideas', 'blockers.js');
92
+ assert.doesNotMatch(src, /UPDATE\s+tasks[\s\S]{0,200}?status\s*=\s*'ready'/);
93
+ });
94
+
95
+ await test('the whole unlink is one transaction', async () => {
96
+ const pool = scenario();
97
+ await unlink();
98
+ const s = sqlOf(pool);
99
+ assert.equal(s[0], 'BEGIN');
100
+ assert.equal(s[s.length - 1], 'COMMIT');
101
+ });
102
+
103
+ await test('the before-status is read FOR UPDATE so the read-back cannot be raced', async () => {
104
+ const pool = scenario();
105
+ await unlink();
106
+ const s = sqlOf(pool);
107
+ const first = s.find((q) => q.startsWith('SELECT status FROM tasks'));
108
+ assert.match(first, /FOR UPDATE/);
109
+ // …and it is read BEFORE the delete, or "did this unlink move it?" is unanswerable.
110
+ assert.ok(s.indexOf(first) < s.findIndex((q) => q.startsWith('DELETE FROM task_blockers')));
111
+ });
112
+
113
+ await test('released:true when the task went blocked -> ready', async () => {
114
+ scenario({ before: 'blocked', after: 'ready' });
115
+ assert.deepEqual(await unlink(), { unlinked: true, released: true });
116
+ });
117
+
118
+ await test('released:false while another blocker is still open', async () => {
119
+ // The trigger's NOT EXISTS fails, so the task stays blocked. The unlink still
120
+ // happened — but saying released:true here is the task-1002559 lie.
121
+ scenario({ before: 'blocked', after: 'blocked' });
122
+ assert.deepEqual(await unlink(), { unlinked: true, released: false });
123
+ });
124
+
125
+ await test('released:false when the task was never blocked in the first place', async () => {
126
+ // A link can land on a task that LINK_BLOCKABLE_STATUSES never parks (active,
127
+ // backlog, shipped). Reporting "released" for one of those would claim a
128
+ // promotion that never happened — and would be exactly what a naive
129
+ // "is it ready now?" read-back returns.
130
+ scenario({ before: 'active', after: 'active' });
131
+ assert.deepEqual(await unlink(), { unlinked: true, released: false });
132
+ });
133
+
134
+ await test('released:false for a task that was ALREADY ready before the unlink', async () => {
135
+ // THE DISCRIMINATING CASE. A legacy blocker_ref link, or one that landed while
136
+ // the task was not blockable, leaves a 'ready' task carrying a link. Removing
137
+ // it changes nothing — but a naive "is it ready NOW?" read-back reports a
138
+ // release that never happened. Only comparing against the BEFORE status can
139
+ // tell the two apart, which is why that status is read at all.
140
+ scenario({ before: 'ready', after: 'ready' });
141
+ assert.deepEqual(await unlink(), { unlinked: true, released: false });
142
+ });
143
+
144
+ await test('a link that is not there refuses with LINK_NOT_FOUND and rolls back', async () => {
145
+ const pool = scenario({ deleted: 0 });
146
+ await assert.rejects(() => unlink(), (e) => e.code === 'LINK_NOT_FOUND');
147
+ const s = sqlOf(pool);
148
+ assert.ok(s.includes('ROLLBACK'), 'must roll back');
149
+ assert.ok(!s.includes('COMMIT'), 'must not commit');
150
+ });
151
+
152
+ await test('an unexpected failure rolls back exactly once', async () => {
153
+ const pool = scenario({ failOn: 'DELETE FROM task_blockers' });
154
+ await assert.rejects(() => unlink());
155
+ const s = sqlOf(pool);
156
+ assert.equal(s.filter((q) => q === 'ROLLBACK').length, 1);
157
+ assert.ok(!s.includes('COMMIT'));
158
+ });
159
+
160
+ await test('the client is returned to the pool on EVERY path', async () => {
161
+ scenario();
162
+ await unlink();
163
+ assert.equal(releases, 1, 'success path');
164
+
165
+ scenario({ deleted: 0 });
166
+ await assert.rejects(() => unlink());
167
+ assert.equal(releases, 1, 'refusal path');
168
+
169
+ scenario({ failOn: 'DELETE FROM task_blockers' });
170
+ await assert.rejects(() => unlink());
171
+ assert.equal(releases, 1, 'error path');
172
+ });
173
+
174
+ await test('an injected pool overrides the module-level one', async () => {
175
+ scenario({ before: 'active', after: 'active' }); // module-level: would say false
176
+ let sawDelete = false;
177
+ const other = makeSqlAwareClient((sql) => {
178
+ if (/^DELETE FROM task_blockers/.test(sql)) { sawDelete = true; return { rows: [{ task_id: 7 }] }; }
179
+ if (/^SELECT status FROM tasks/.test(sql)) return { rows: [{ status: sawDelete ? 'ready' : 'blocked' }] };
180
+ return { rows: [] };
181
+ });
182
+ assert.equal((await unlink({ pool: other })).released, true, 'the injected pool must be the one used');
183
+ assert.equal(current.queries.length, 0, 'the module-level pool saw none');
184
+ });
185
+
186
+ // == 2. core_256 — the trigger that actually releases =======================
187
+
188
+ const MIG = 'core_256_blocker_unlink_releases_task.sql';
189
+ const mig = read('migrations', MIG);
190
+ const migN = mig.replace(/\s+/g, ' ');
191
+ // Comments carry the rationale; strip them when asserting on the executable
192
+ // body, so a claim in prose can never stand in for the real SQL.
193
+ const body = mig.replace(/^\s*--.*$/gm, '').replace(/\s+/g, ' ');
194
+
195
+ await test('256 installs an AFTER DELETE row trigger on task_blockers', () => {
196
+ assert.match(body, /CREATE OR REPLACE FUNCTION trg_release_task_on_blocker_unlink\(\)/);
197
+ assert.match(body, /AFTER DELETE ON task_blockers/);
198
+ assert.match(body, /FOR EACH ROW/);
199
+ });
200
+
201
+ await test('the release targets ONLY a blocked task', () => {
202
+ assert.match(body, /UPDATE tasks SET status = 'ready'/);
203
+ assert.match(body, /WHERE id = OLD\.task_id/);
204
+ // Without this, an unlink would launder any task straight to 'ready' — past
205
+ // the metic+archon promote gate.
206
+ assert.match(body, /AND status = 'blocked'/);
207
+ });
208
+
209
+ await test("core_223's NOT EXISTS over the task's remaining open blockers is carried", () => {
210
+ assert.match(body, /AND NOT EXISTS \( SELECT 1 FROM task_blockers tb JOIN blockers b ON b\.id = tb\.blocker_id/);
211
+ assert.match(body, /WHERE tb\.task_id = OLD\.task_id/);
212
+ assert.match(body, /AND b\.status = 'open'/);
213
+ });
214
+
215
+ await test("and deliberately has NO `b.id <> …` exclusion — AFTER DELETE, the row is gone", () => {
216
+ // core_223 needs that clause because it runs BEFORE UPDATE, where the blocker
217
+ // being resolved still reads 'open' and would count itself, stalling forever.
218
+ // Here the link row is already deleted when the body runs, so the NOT EXISTS
219
+ // sees only what remains. Copying the clause across would be cargo-cult.
220
+ assert.doesNotMatch(body, /b\.id <>/);
221
+ });
222
+
223
+ await test('256 does NOT also gate on dependencies — that would strand the task', () => {
224
+ // The dep-promotion triggers (163) only flip backlog -> ready, so a task held
225
+ // in 'blocked' for unshipped deps would have no release path at all.
226
+ assert.doesNotMatch(body, /task_is_fully_unblocked/);
227
+ });
228
+
229
+ await test('256 is transactional and re-appliable', () => {
230
+ assert.match(migN, /BEGIN;.*COMMIT;/);
231
+ // A trigger has no CREATE OR REPLACE before PG 14, so the drop is what makes
232
+ // a re-run clean rather than a duplicate-object error.
233
+ assert.match(body, /DROP TRIGGER IF EXISTS release_task_on_blocker_unlink ON task_blockers/);
234
+ assert.doesNotMatch(body, /DROP FUNCTION/i);
235
+ });
236
+
237
+ await test('256 is the ONLY definition of the unlink-release function', () => {
238
+ const defs = readdirSync(join(repoRoot, 'migrations'))
239
+ .filter((f) => f.endsWith('.sql'))
240
+ .filter((f) => /FUNCTION trg_release_task_on_blocker_unlink/.test(read('migrations', f)))
241
+ .sort();
242
+ // migrate.sh applies in filename order, so a second, lower-numbered
243
+ // definition landing later would silently reinstate whatever it said.
244
+ assert.deepEqual(defs, [MIG]);
245
+ });
246
+
247
+ await test('unlinkBlockerFromTask is exported', () => {
248
+ assert.equal(typeof blockers.unlinkBlockerFromTask, 'function');
249
+ });
250
+
251
+ summary();