@bongos/core 1.19.1010 → 1.19.1011

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.1010",
6
- "core_contract": "1.19.1010",
7
- "source_commit": "2e959a08be544446bcbe4a050128862f651337f6",
5
+ "core_version": "1.19.1011",
6
+ "core_contract": "1.19.1011",
7
+ "source_commit": "24339084e27e496ab04322675456dc4d428df08a",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-24T21:57:10.279Z",
9
+ "built_at": "2026-09-24T22:18:21.675Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 523,
13
13
  "agent_docs_stubbed": 26,
14
- "functional_verbatim": 2420,
14
+ "functional_verbatim": 2426,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2969,
20
- "tree_sha256": "1ed928f07118e31d83cb36f2f7c9e00fb8eb421f4e0c174d518a6c659944bae6",
19
+ "file_count": 2975,
20
+ "tree_sha256": "ceac975ccace002c6914b47746ed15c8c76eb5f1919ebab13fafe0cf8b0c860d",
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": "e74f8574c95d4b2dbd2f6d41491346f39b59615ceeba00de62804cd16b4b5545"
355
+ "sha256": "26e3e9bd1e4dc4e7715eaabfdc155b98da64eefc2c002a18f4c5da0b5b288baa"
356
356
  },
357
357
  {
358
358
  "path": "clients/bongos-client/bongos-client.global.js",
359
359
  "mode": "0000644",
360
- "sha256": "8e70fb96ed9cc94ebde7fa33339a0a9a8bc9de57d0a16b2dd4b46a0fbdf0a68d"
360
+ "sha256": "6684688f85798c74d0c58853fa54c90e30a69c442708e191a3ef15b8f06ca0fb"
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": "f8b8ebe06392c748ac8874200b825a3c9ffa878c2f7d6f745bdc1ce5e51d95e7"
370
+ "sha256": "5b844bbe07c67c1ac42f46d8a065d59039a447ca38ca6ae9f3422c081038cd8a"
371
371
  },
372
372
  {
373
373
  "path": "clients/bongos-client/index.d.ts",
374
374
  "mode": "0000644",
375
- "sha256": "8b4264d838e666a293e8c4179ed68b07d4f8518039f7dc8f23e35842ce664163"
375
+ "sha256": "c339dfc8dc3cf407b2a30a9afed1c29aab87f55f96c7bd8c7316b999bd8c6a96"
376
376
  },
377
377
  {
378
378
  "path": "clients/bongos-client/index.mjs",
379
379
  "mode": "0000644",
380
- "sha256": "18cef2b359d26f464839c9f692a13caa9745204fddd23e8dafd4b9ad840acbe4"
380
+ "sha256": "24cf3312198537eb92a5c2f247faa826f596fae91a44705905bd80a5cdc8c7b0"
381
381
  },
382
382
  {
383
383
  "path": "clients/bongos-client/package.json",
@@ -2162,12 +2162,12 @@
2162
2162
  {
2163
2163
  "path": "docs/api-reference.md",
2164
2164
  "mode": "0000644",
2165
- "sha256": "71a0450dc086ef7fa89d0f37a4f7b90251f246447cb0036120b5de3913d9e581"
2165
+ "sha256": "e09fe547287b2baea31a2bf0326dbc9c123c0d85786ac3db42669b7496bfe9fe"
2166
2166
  },
2167
2167
  {
2168
2168
  "path": "docs/api/openapi.json",
2169
2169
  "mode": "0000644",
2170
- "sha256": "8876d8fb14e716aeefeeef97c4808d90e758f8ae74c63bea53eab3b5c967386c"
2170
+ "sha256": "a4b3d6be3fdd2b518f046c7db5849435d73cbaaf9b8f2247e08ca2243f52475c"
2171
2171
  },
2172
2172
  {
2173
2173
  "path": "docs/architecture.md",
@@ -2727,7 +2727,7 @@
2727
2727
  {
2728
2728
  "path": "docs/module-api-changelog.md",
2729
2729
  "mode": "0000644",
2730
- "sha256": "982c59cdf01c7f07d25fe1593a21659b31366a6b4b0b09b0350a69a23eba2407"
2730
+ "sha256": "a6d72a65cf5f1ae3f4b317e0684b642b01ad8c3944b2d4db0feddbbd49e54974"
2731
2731
  },
2732
2732
  {
2733
2733
  "path": "docs/modules-contract.md",
@@ -5637,7 +5637,7 @@
5637
5637
  {
5638
5638
  "path": "modules/ideas/module.json",
5639
5639
  "mode": "0000644",
5640
- "sha256": "a869f9dec25e003006a495b3fbee45ac99c80b306cec7f16808693d70133e717"
5640
+ "sha256": "96dc50c3ab602ea22329d3fff484113a7381cfe840d06f3ee51132ea09ea18f0"
5641
5641
  },
5642
5642
  {
5643
5643
  "path": "modules/ideas/pollers/descent-writer.js",
@@ -5674,6 +5674,11 @@
5674
5674
  "mode": "0000644",
5675
5675
  "sha256": "92c10dbffef40038dac64ea646372e3fcd1c70c5d2bda1d731ddbdcd508d9181"
5676
5676
  },
5677
+ {
5678
+ "path": "modules/ideas/routes/sky-mine.js",
5679
+ "mode": "0000644",
5680
+ "sha256": "a58dc499fd86af7499de613e61762e8adee88497dadad30f7f651aaafbbf10e3"
5681
+ },
5677
5682
  {
5678
5683
  "path": "modules/ideas/routing.js",
5679
5684
  "mode": "0000644",
@@ -5684,6 +5689,11 @@
5684
5689
  "mode": "0000644",
5685
5690
  "sha256": "eb6a5cecb3ce54aeb0c85ffb752eb12a2400d0d4f5c7d48e4e05ff3cf7baf712"
5686
5691
  },
5692
+ {
5693
+ "path": "modules/ideas/sky-mine.js",
5694
+ "mode": "0000644",
5695
+ "sha256": "00ea7dcd84d31ef6f5f052ed200abcc0f3c58267af0fc7cd8cebc567d4ffeb28"
5696
+ },
5687
5697
  {
5688
5698
  "path": "modules/ideas/templates.js",
5689
5699
  "mode": "0000644",
@@ -6027,7 +6037,7 @@
6027
6037
  {
6028
6038
  "path": "modules/lifecycle/module.json",
6029
6039
  "mode": "0000644",
6030
- "sha256": "36a03e5d8a4fc1129439356ba6daaf284d2cd2ca3d98d35f990e3215ff11198a"
6040
+ "sha256": "472161627fdeb33b628eb3d9b4d4d58eba13dc74dacee1f661fc79856496cff8"
6031
6041
  },
6032
6042
  {
6033
6043
  "path": "modules/lifecycle/pg-constraint-fail.js",
@@ -6159,6 +6169,11 @@
6159
6169
  "mode": "0000644",
6160
6170
  "sha256": "2ab5931bd936c88a1e3a4339d927ef537e998f9756fc3260671f2bf701cd7dfa"
6161
6171
  },
6172
+ {
6173
+ "path": "modules/lifecycle/routes/sky.js",
6174
+ "mode": "0000644",
6175
+ "sha256": "1f058f4bd1033666daff1babf1cf64e9ea303ef87f9cd71fba9f3ad2815c4287"
6176
+ },
6162
6177
  {
6163
6178
  "path": "modules/lifecycle/routes/task-route-helpers.js",
6164
6179
  "mode": "0000644",
@@ -6209,6 +6224,11 @@
6209
6224
  "mode": "0000644",
6210
6225
  "sha256": "e2d77bd858cc5aff1984ad6fd22f5441014cc92b9d610cacfca2a3d2861f0bc2"
6211
6226
  },
6227
+ {
6228
+ "path": "modules/lifecycle/sky.js",
6229
+ "mode": "0000644",
6230
+ "sha256": "e325fd288ff7a71c3db31d4d9756666d2a386ad25c3b9dd0a1bab3e5bec67be8"
6231
+ },
6212
6232
  {
6213
6233
  "path": "modules/lifecycle/task-classifier.js",
6214
6234
  "mode": "0000644",
@@ -8222,12 +8242,12 @@
8222
8242
  {
8223
8243
  "path": "package-lock.json",
8224
8244
  "mode": "0000644",
8225
- "sha256": "493838f2051f8f9c9d9878add2840c5f4e18914607147bbcb4bdea1fcb5559fe"
8245
+ "sha256": "2534d0c2e37f998d9e0ab7bb505469b9c10d4b22f0e65317a75a79769a344b98"
8226
8246
  },
8227
8247
  {
8228
8248
  "path": "package.json",
8229
8249
  "mode": "0000644",
8230
- "sha256": "addc61bbe9d71187f833dcebcb6cfb7136557ba6782d00270395200501ef9f49"
8250
+ "sha256": "989026ca4569325ebd370b4e8b3956a199ecb50951f13539a362894ebcabc7e7"
8231
8251
  },
8232
8252
  {
8233
8253
  "path": "public-docs/index.html",
@@ -10272,7 +10292,7 @@
10272
10292
  {
10273
10293
  "path": "src/module-api.js",
10274
10294
  "mode": "0000644",
10275
- "sha256": "be4a80ffea151ee7dcc548a28cf928376da1bcce570aa521478ea22657475905"
10295
+ "sha256": "4f83439e8988caf0153a289fc10ba2c9aca89837322ac94158d70e21dad1aaf5"
10276
10296
  },
10277
10297
  {
10278
10298
  "path": "src/module-loader/catalog.js",
@@ -14309,6 +14329,16 @@
14309
14329
  "mode": "0000644",
14310
14330
  "sha256": "db9e246f1ce7ef6306b938d325c450e31f2e834244f64dd2f0aaea96e760d987"
14311
14331
  },
14332
+ {
14333
+ "path": "tests/sky_mine.mjs",
14334
+ "mode": "0000644",
14335
+ "sha256": "799fdbebfc405f42e658a5575c5291ac7932be3fc0f16f5006e8a5656c7e8661"
14336
+ },
14337
+ {
14338
+ "path": "tests/sky_read.mjs",
14339
+ "mode": "0000644",
14340
+ "sha256": "44db7c5b3cdf12cb0f4e7dc46e6677280a4c9796664ac117f690d833c8a5ea46"
14341
+ },
14312
14342
  {
14313
14343
  "path": "tests/smoke_preflight.mjs",
14314
14344
  "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
- - 414 operations across 61 resource groups
8
+ - 416 operations across 62 resource groups
9
9
 
10
10
  ## Use it from your project
11
11
 
@@ -884,6 +884,12 @@ function createClient(opts = {}) {
884
884
  // GET /sessions/upload-health — rank: metic+archon — GET /sessions/upload-health
885
885
  getSessionsUploadHealth: (args) => request("GET", "/sessions/upload-health", { hasBody: false }, args),
886
886
  },
887
+ "sky": {
888
+ // GET /sky — rank: any-builder — GET /sky
889
+ getSky: (args) => request("GET", "/sky", { hasBody: false }, args),
890
+ // GET /sky/mine — rank: any-builder — GET /sky/mine
891
+ getSkyMine: (args) => request("GET", "/sky/mine", { hasBody: false }, args),
892
+ },
887
893
  "specialities": {
888
894
  // GET /specialities — rank: any-builder — GET /specialities
889
895
  getSpecialities: (args) => request("GET", "/specialities", { hasBody: false }, args),
@@ -883,6 +883,12 @@ function createClient(opts = {}) {
883
883
  // GET /sessions/upload-health — rank: metic+archon — GET /sessions/upload-health
884
884
  getSessionsUploadHealth: (args) => request("GET", "/sessions/upload-health", { hasBody: false }, args),
885
885
  },
886
+ "sky": {
887
+ // GET /sky — rank: any-builder — GET /sky
888
+ getSky: (args) => request("GET", "/sky", { hasBody: false }, args),
889
+ // GET /sky/mine — rank: any-builder — GET /sky/mine
890
+ getSkyMine: (args) => request("GET", "/sky/mine", { hasBody: false }, args),
891
+ },
886
892
  "specialities": {
887
893
  // GET /specialities — rank: any-builder — GET /specialities
888
894
  getSpecialities: (args) => request("GET", "/specialities", { hasBody: false }, args),
@@ -1270,6 +1270,12 @@ export interface BongosClient {
1270
1270
  /** GET /sessions/upload-health — rank: metic+archon */
1271
1271
  getSessionsUploadHealth(args?: RequestArgs): Promise<ApiResponse>;
1272
1272
  };
1273
+ "sky": {
1274
+ /** GET /sky — rank: any-builder */
1275
+ getSky(args?: RequestArgs): Promise<ApiResponse>;
1276
+ /** GET /sky/mine — rank: any-builder */
1277
+ getSkyMine(args?: RequestArgs): Promise<ApiResponse>;
1278
+ };
1273
1279
  "specialities": {
1274
1280
  /** GET /specialities — rank: any-builder */
1275
1281
  getSpecialities(args?: RequestArgs): Promise<GetSpecialitiesResponse>;
@@ -880,6 +880,12 @@ export function createClient(opts = {}) {
880
880
  // GET /sessions/upload-health — rank: metic+archon — GET /sessions/upload-health
881
881
  getSessionsUploadHealth: (args) => request("GET", "/sessions/upload-health", { hasBody: false }, args),
882
882
  },
883
+ "sky": {
884
+ // GET /sky — rank: any-builder — GET /sky
885
+ getSky: (args) => request("GET", "/sky", { hasBody: false }, args),
886
+ // GET /sky/mine — rank: any-builder — GET /sky/mine
887
+ getSkyMine: (args) => request("GET", "/sky/mine", { hasBody: false }, args),
888
+ },
883
889
  "specialities": {
884
890
  // GET /specialities — rank: any-builder — GET /specialities
885
891
  getSpecialities: (args) => request("GET", "/specialities", { hasBody: false }, args),
@@ -237,6 +237,10 @@
237
237
  "name": "sessions",
238
238
  "description": "Endpoints under `/api/bongos/sessions`."
239
239
  },
240
+ {
241
+ "name": "sky",
242
+ "description": "Endpoints under `/api/bongos/sky`."
243
+ },
240
244
  {
241
245
  "name": "specialities",
242
246
  "description": "Endpoints under `/api/bongos/specialities`."
@@ -15855,6 +15859,68 @@
15855
15859
  ]
15856
15860
  }
15857
15861
  },
15862
+ "/sky": {
15863
+ "get": {
15864
+ "operationId": "get_sky",
15865
+ "tags": [
15866
+ "sky"
15867
+ ],
15868
+ "summary": "GET /sky",
15869
+ "description": "GET /sky, the whole project sky (task 1004230, ADR 0331). The same gate as GET /goals/graph, for the same reason: the viewer decides which private goals come back with their prose, and that decision is made from the authenticated request inside the read. Deliberately NOT pinned in route-rank-check: a read with no floor above sign-in has no downgrade to catch (the /inbox/sparks precedent).\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
15870
+ "x-rank": "any-builder",
15871
+ "x-source": "modules/lifecycle/routes/sky.js",
15872
+ "responses": {
15873
+ "200": {
15874
+ "description": "Success."
15875
+ },
15876
+ "400": {
15877
+ "$ref": "#/components/responses/BadRequest"
15878
+ },
15879
+ "401": {
15880
+ "$ref": "#/components/responses/Unauthorized"
15881
+ },
15882
+ "403": {
15883
+ "$ref": "#/components/responses/Forbidden"
15884
+ }
15885
+ },
15886
+ "security": [
15887
+ {
15888
+ "builderSession": []
15889
+ }
15890
+ ]
15891
+ }
15892
+ },
15893
+ "/sky/mine": {
15894
+ "get": {
15895
+ "operationId": "get_sky_mine",
15896
+ "tags": [
15897
+ "sky"
15898
+ ],
15899
+ "summary": "GET /sky/mine",
15900
+ "description": "GET /sky/mine, every thought the SIGNED-IN builder filed with its state, size and descent, capped at 1000 (task 1004230). The builder is the caller: there is no id in the path, so nobody can read a trail as somebody else through this route. The viewer's rank rides into the read so the skill-friction gate holds here as it does on the trail. Deliberately NOT pinned in route-rank-check (the /inbox/sparks precedent: a read with no floor above sign-in has no downgrade to catch).\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
15901
+ "x-rank": "any-builder",
15902
+ "x-source": "modules/ideas/routes/sky-mine.js",
15903
+ "responses": {
15904
+ "200": {
15905
+ "description": "Success."
15906
+ },
15907
+ "400": {
15908
+ "$ref": "#/components/responses/BadRequest"
15909
+ },
15910
+ "401": {
15911
+ "$ref": "#/components/responses/Unauthorized"
15912
+ },
15913
+ "403": {
15914
+ "$ref": "#/components/responses/Forbidden"
15915
+ }
15916
+ },
15917
+ "security": [
15918
+ {
15919
+ "builderSession": []
15920
+ }
15921
+ ]
15922
+ }
15923
+ },
15858
15924
  "/specialities": {
15859
15925
  "get": {
15860
15926
  "operationId": "get_specialities",
@@ -26348,7 +26414,7 @@
26348
26414
  "description": "A required dependency/feature is not configured or is temporarily down."
26349
26415
  }
26350
26416
  },
26351
- "x-endpoint-count": 414,
26417
+ "x-endpoint-count": 416,
26352
26418
  "x-schema-count": 453,
26353
26419
  "x-undocumented-bodies": 9,
26354
26420
  "x-response-schemas": 308,
@@ -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. 414 endpoints across 68 route files.
5
+ > **Generated from the live route files** — the route file is authoritative. 416 endpoints across 70 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`.
@@ -623,6 +623,13 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/0016-trust
623
623
  | GET | `/api/bongos/sessions/search` | `metic+archon` | — | cross-builder corpus search (BFG-principal posture until 6C.1). |
624
624
  | GET | `/api/bongos/sessions/upload-health` | `metic+archon` | — | GET /sessions/upload-health — the answer to "has anyone's session upload silently stopped?", which before task 1003865 existed ONLY as a … |
625
625
 
626
+ ## `sky` (2)
627
+
628
+ | Method | Path | Rank | Body | Description |
629
+ |---|---|---|---|---|
630
+ | GET | `/api/bongos/sky` | `any-builder` | — | GET /sky, the whole project sky (task 1004230, ADR 0331). |
631
+ | GET | `/api/bongos/sky/mine` | `any-builder` | — | GET /sky/mine, every thought the SIGNED-IN builder filed with its state, size and descent, capped at 1000 (task 1004230). |
632
+
626
633
  ## `specialities` (12)
627
634
 
628
635
  | Method | Path | Rank | Body | Description |
@@ -2494,5 +2494,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2494
2494
  landed since 1.19.1008 with no explicit bump. run 36056558236. (task 1002620)
2495
2495
  1.19.1010 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2496
2496
  landed since 1.19.1009 with no explicit bump. run 36064558803. (task 1002620)
2497
+ 1.19.1011 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2498
+ landed since 1.19.1010 with no explicit bump. run 36066617514. (task 1002620)
2497
2499
  ---------------------------------------------------------------------------
2498
2500
  ```
@@ -11,6 +11,7 @@
11
11
  "routes": [
12
12
  "inbox",
13
13
  "ratify-goal",
14
+ "sky-mine",
14
15
  "blockers"
15
16
  ],
16
17
  "pollers": [
@@ -0,0 +1,34 @@
1
+ 'use strict';
2
+
3
+ // modules/ideas/routes/sky-mine.js — GET /sky/mine, the caller's whole trail in
4
+ // one read (task 1004230, sky part 2 of 5; ADR 0331).
5
+ //
6
+ // ITS OWN FILE, not more of inbox.js, for the reason ratify-goal.js gives: inbox
7
+ // is INTAKE, and this is a read of what a person's intake became. The read and
8
+ // its cap live in ../sky-mine.js so a test drives them with no express.
9
+ //
10
+ // The path is `/sky/mine` beside the lifecycle module's `/sky`: two modules, one
11
+ // surface to the page. Express matches whole paths, so neither swallows the
12
+ // other whichever mounts first — and no `:id` route exists under `/sky` to bind
13
+ // the literal `mine` (the `/inbox/:id` trap this module's CLAUDE.md records).
14
+ //
15
+ // MODULE BOUNDARY: reaches core only through `../../../src/module-api`.
16
+
17
+ const express = require('express');
18
+ const api = require('../../../src/module-api');
19
+ const skyMine = require('../sky-mine');
20
+
21
+ module.exports = function buildSkyMineRouter() {
22
+ const router = express.Router();
23
+
24
+ // rank: any-builder — GET /sky/mine, every thought the SIGNED-IN builder filed
25
+ // with its state, size and descent, capped at 1000 (task 1004230). The builder
26
+ // is the caller: there is no id in the path, so nobody can read a trail as
27
+ // somebody else through this route. The viewer's rank rides into the read so
28
+ // the skill-friction gate holds here as it does on the trail. Deliberately NOT
29
+ // pinned in route-rank-check (the /inbox/sparks precedent: a read with no floor
30
+ // above sign-in has no downgrade to catch).
31
+ router.get('/sky/mine', api.requireBuilder, api.asyncHandler('GET /sky/mine', skyMine.skyMineHandler(), { errorCode: 'sky_mine_failed' }));
32
+
33
+ return router;
34
+ };
@@ -0,0 +1,75 @@
1
+ 'use strict';
2
+
3
+ // modules/ideas/sky-mine.js — THE CALLER'S WHOLE TRAIL IN ONE READ (task
4
+ // 1004230, sky part 2 of 5; goal 1000095; ADR 0331).
5
+ //
6
+ // WHY. The ideator's sky draws every thought a person filed at once — its state,
7
+ // its size, its descent — and the trail route (`GET /inbox/by-builder/:id`) pages
8
+ // that at a hundred rows. The prototype walked the pages itself; the page should
9
+ // not. This read walks them server-side and hands back every row, the same rows
10
+ // the room shows, so the sky and the room can never disagree about a thought.
11
+ //
12
+ // THE SAME ROWS, LITERALLY. Each page is `listIdeaTrailForBuilder` — the trail's
13
+ // own projection — and the three attachers run over the whole set afterwards in
14
+ // the trail route's order: the descent (the walk), then size (what the walk
15
+ // paid), then state (which of the five objects — last, because it takes its
16
+ // share off the size just left on the row). Nothing is derived here that the
17
+ // room does not derive; ADR 0322 says the five states are derived ONCE, and this
18
+ // file keeps that true by calling the one derivation rather than adding a second.
19
+ //
20
+ // THE CAP. A thousand rows. The trail's own page cap is a hundred, so this is at
21
+ // most ten reads; a filer past a thousand thoughts gets the newest thousand and a
22
+ // `truncated: true` the page can say out loud, never a silent slice. Ten reads
23
+ // for the sky is still one request from the page, which is the point.
24
+ //
25
+ // WHOSE. The builder is the signed-in caller, from the request, never a URL
26
+ // parameter — the room reads `/me` for the same reason: a trail is a person's,
27
+ // and the route that lets you name a person is the other one.
28
+
29
+ const inbox = require('./inbox');
30
+ const { attachDescentToTrail } = require('./trail-descent');
31
+ const { attachSizeToTrail } = require('./trail-size');
32
+ const { attachStateToTrail } = require('./idea-state');
33
+
34
+ const SKY_MINE_CAP = 1000;
35
+
36
+ // readSkyMine — page the trail to the cap, then attach the three facts once
37
+ // over the whole set. `deps` lets a test stand in for each read and each
38
+ // attacher; production passes none and gets the trail's own.
39
+ async function readSkyMine({ builderId, viewerRank = null } = {}, deps = {}) {
40
+ const list = deps.listIdeaTrailForBuilder || inbox.listIdeaTrailForBuilder;
41
+ const count = deps.countIdeaTrailForBuilder || inbox.countIdeaTrailForBuilder;
42
+ const descent = deps.attachDescentToTrail || attachDescentToTrail;
43
+ const size = deps.attachSizeToTrail || attachSizeToTrail;
44
+ const state = deps.attachStateToTrail || attachStateToTrail;
45
+ const cap = Number.isInteger(deps.cap) && deps.cap > 0 ? deps.cap : SKY_MINE_CAP;
46
+ const pageSize = inbox.IDEA_TRAIL_MAX_LIMIT;
47
+
48
+ const ideas = [];
49
+ const total = await count(builderId, { viewerRank });
50
+ for (let offset = 0; offset < Math.min(total, cap); offset += pageSize) {
51
+ const limit = Math.min(pageSize, cap - offset);
52
+ const page = await list(builderId, { limit, offset, viewerRank });
53
+ ideas.push(...page);
54
+ if (page.length < limit) break;
55
+ }
56
+
57
+ await descent(ideas);
58
+ await size(ideas);
59
+ await state(ideas);
60
+
61
+ return { builder_id: builderId, ideas, total, cap, truncated: total > ideas.length };
62
+ }
63
+
64
+ // skyMineHandler — GET /sky/mine. A factory so a test drives it with a stub
65
+ // read and no database. Identity is the authenticated builder's, always.
66
+ function skyMineHandler({ read = readSkyMine } = {}) {
67
+ return async function getSkyMine(req, res) {
68
+ const builderId = Number(req.builder.id);
69
+ const viewerRank = req.builder && req.builder.rank;
70
+ const out = await read({ builderId, viewerRank });
71
+ return res.json(out);
72
+ };
73
+ }
74
+
75
+ module.exports = { readSkyMine, skyMineHandler, SKY_MINE_CAP };
@@ -27,7 +27,8 @@
27
27
  "rot",
28
28
  "project-settings",
29
29
  "artist-gate",
30
- "removal-proposals"
30
+ "removal-proposals",
31
+ "sky"
31
32
  ],
32
33
  "pollers": [
33
34
  "publish-reconciler"
@@ -0,0 +1,37 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/routes/sky.js — GET /sky, the project sky in one read (task
4
+ // 1004230, sky part 2 of 5; ADR 0331, ADR 0227 §11).
5
+ //
6
+ // WHY ITS OWN ROUTE FILE. goals.js is the goal board's surface and already
7
+ // mounts the graph and the forest; the sky is a third view over the same rows,
8
+ // and the one line here is the mount. The data, the cache and the handler live
9
+ // in ../sky.js so a test drives them with no express and no database.
10
+ //
11
+ // The path is `/sky`, top-level, never `/goals/sky`: the ideas module contributes
12
+ // `/sky/mine` (the caller's own trail), and the two are one surface to the page
13
+ // even though they are two modules' reads. Express matches whole paths, so
14
+ // `/sky` cannot swallow `/sky/mine` whichever module mounts first.
15
+ //
16
+ // MODULE BOUNDARY: reaches core only through the published doorway
17
+ // `../../../src/module-api`; `../sky` is this module's own read.
18
+
19
+ const express = require('express');
20
+ const api = require('../../../src/module-api');
21
+ const sky = require('../sky');
22
+
23
+ const log = api.logger('lifecycle');
24
+
25
+ module.exports = function buildSkyRouter() {
26
+ const router = express.Router();
27
+
28
+ // rank: any-builder — GET /sky, the whole project sky (task 1004230, ADR 0331).
29
+ // The same gate as GET /goals/graph, for the same reason: the viewer decides
30
+ // which private goals come back with their prose, and that decision is made
31
+ // from the authenticated request inside the read. Deliberately NOT pinned in
32
+ // route-rank-check: a read with no floor above sign-in has no downgrade to
33
+ // catch (the /inbox/sparks precedent).
34
+ router.get('/sky', api.requireBuilder, sky.skyHandler({ log }));
35
+
36
+ return router;
37
+ };
@@ -0,0 +1,261 @@
1
+ 'use strict';
2
+
3
+ // modules/lifecycle/sky.js — ONE READ FOR THE WHOLE PROJECT SKY (task 1004230,
4
+ // sky part 2 of 5; goal 1000095; ADR 0331, ADR 0227 §11).
5
+ //
6
+ // WHY ONE READ. The prototype's snapshot (docs/design/mocks/sky/snapshot.js)
7
+ // needed about 120 requests to assemble what one page draws: the goal graph, a
8
+ // detail read per goal for its criteria, a progress rollup per version for the
9
+ // criterion→task join, and a task list per goal. The two sky pages (parts 3 and
10
+ // 4) draw all of it at once, so they get it at once — five queries, one JSON.
11
+ //
12
+ // WHAT IT IS NOT. Not a second goal graph: `goals` and `edges` come from
13
+ // goal-graph.js's own read, so the sky's wells are the map's nodes and the sky's
14
+ // routes are the map's edges by construction, never a copy that can drift. Not
15
+ // the refused whole-corpus task map either (ADR 0227 §1): tasks come back
16
+ // compact and grouped under their goal, and the page draws them only inside one
17
+ // goal past a zoom threshold — dust below it (§11).
18
+ //
19
+ // THE JOIN THAT BITES. A criterion's tasks live in `task_criteria`; goal detail
20
+ // returns only COUNTS of them, and the version progress rollup is the one place
21
+ // the ids are joined. Rather than call that rollup once per version, the join is
22
+ // read ONCE for every goal-bearing criterion, as the same flat (criterion, task)
23
+ // rows `criterionProgress` folds — so `tests/sky_read.mjs` can hand the same rows
24
+ // to `buildCriterionProgress` and to this shaper and pin that a task's criterion
25
+ // link is identical on both.
26
+ //
27
+ // VISIBILITY (ADR 0112's leak rule, as goal-graph.js applies it). A private goal
28
+ // the viewer may not read stays a well — its counts, its edges, its ring count
29
+ // and its task count are structure, not secrets — but every piece of PROSE on it
30
+ // comes back null: title, subtitle, category, each criterion's text, each task's
31
+ // title. The graph only had a title and a category to hide; the sky carries more
32
+ // prose per goal, and hiding the title while shipping the criteria under it would
33
+ // be the leak wearing a different field name.
34
+ //
35
+ // THE CACHE. Sixty seconds, in-process, keyed by what decides visibility (the
36
+ // Archon flag, else the viewer id) — a cached payload shaped for one viewer is
37
+ // never handed to another. The response says whether it was served from cache
38
+ // and when it was taken, so a page that just filed a thought can tell why the
39
+ // sky has not moved yet.
40
+
41
+ const api = require('../../src/module-api');
42
+ const { pool } = api;
43
+ const goalGraph = require('./goal-graph');
44
+ const goalCategory = require('./goal-category');
45
+ const { isPendingReview } = require('./done-when');
46
+ const { TERMINAL_STATUSES } = require('./task-status');
47
+
48
+ const SKY_CACHE_TTL_MS = 60 * 1000;
49
+
50
+ // skyRows — the raw reads. Returns { goals, edges, extras, criteria, tasks }:
51
+ // `goals` + `edges` exactly as goal-graph.js reads them; `extras` one row per
52
+ // goal with the two columns the graph does not carry (subtitle, created_at);
53
+ // `criteria` one FLAT row per (criterion, task) pair — a criterion with no task
54
+ // arrives once with null task_* (the LEFT JOIN, the criterionProgress shape);
55
+ // `tasks` one compact row per task under a goal. deps.pool honours the test seam.
56
+ async function skyRows({ versionId = null, viewerId = null, viewerIsArchon = false } = {}, deps = {}) {
57
+ const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
58
+ const version = Array.isArray(versionId) ? (versionId[0] ?? null) : versionId;
59
+ const [graph, extras, criteria, tasks] = await Promise.all([
60
+ (deps.goalGraphRows || goalGraph.goalGraphRows)({ versionId: version, viewerId, viewerIsArchon }, deps),
61
+ activePool.query(
62
+ `SELECT g.id::text AS goal_id, g.subtitle, g.created_at
63
+ FROM goals g
64
+ WHERE ($1::text IS NULL OR g.version_id = $1)`,
65
+ [version]
66
+ ),
67
+ activePool.query(
68
+ // The criterion→task join, read once. Flat rows on purpose (see the header):
69
+ // the fold happens in shapeSky so a test can compare it with the rollup's.
70
+ `SELECT c.id::text AS id, c.goal_id::text AS goal_id, c.criterion_id, c.criterion_md,
71
+ c.satisfied, c.sort_order,
72
+ t.id::text AS task_id, t.status AS task_status
73
+ FROM done_when_criteria c
74
+ JOIN goals g ON g.id = c.goal_id
75
+ LEFT JOIN task_criteria tc ON tc.criterion_id = c.id
76
+ LEFT JOIN tasks t ON t.id = tc.task_id
77
+ WHERE c.goal_id IS NOT NULL
78
+ AND ($1::text IS NULL OR g.version_id = $1)
79
+ ORDER BY c.goal_id, c.sort_order, c.criterion_id, t.id`,
80
+ [version]
81
+ ),
82
+ activePool.query(
83
+ // Compact by construction: no description, no value summary, no notes.
84
+ // created_at is truncated to the DAY in SQL so the payload carries ten
85
+ // characters per task, not twenty-seven, and the clock draws by day anyway.
86
+ `SELECT t.id::text AS id, t.goal_id::text AS goal_id, t.title, t.status,
87
+ t.credits_reward, t.created_at::date::text AS created_on,
88
+ CASE WHEN t.source_ref ~ '^idea:[0-9]+$' THEN substring(t.source_ref from 6) END AS idea_id
89
+ FROM tasks t
90
+ JOIN goals g ON g.id = t.goal_id
91
+ WHERE ($1::text IS NULL OR g.version_id = $1)
92
+ ORDER BY t.goal_id, t.id`,
93
+ [version]
94
+ ),
95
+ ]);
96
+ return { goals: graph.goals, edges: graph.edges, extras: extras.rows, criteria: criteria.rows, tasks: tasks.rows };
97
+ }
98
+
99
+ // foldCriteria — the flat (criterion, task) rows into one entry per criterion
100
+ // with its task ids and the derived pending_review flag. The same fold
101
+ // buildCriterionProgress does, minus the cnum and the per-status counts the sky
102
+ // does not draw. Exported so the equivalence test can drive it on its own.
103
+ function foldCriteria(rows = []) {
104
+ const byId = new Map();
105
+ for (const row of rows) {
106
+ let c = byId.get(String(row.id));
107
+ if (!c) {
108
+ c = {
109
+ id: String(row.id),
110
+ goal_id: row.goal_id != null ? String(row.goal_id) : null,
111
+ criterion_id: row.criterion_id,
112
+ criterion_md: row.criterion_md,
113
+ satisfied: row.satisfied === true,
114
+ task_ids: [],
115
+ _unshipped: 0,
116
+ };
117
+ byId.set(c.id, c);
118
+ }
119
+ if (row.task_id != null) {
120
+ c.task_ids.push(String(row.task_id));
121
+ if (!TERMINAL_STATUSES.includes(row.task_status)) c._unshipped += 1;
122
+ }
123
+ }
124
+ return [...byId.values()].map((c) => {
125
+ const { _unshipped, ...rest } = c;
126
+ return {
127
+ ...rest,
128
+ pending_review: isPendingReview({ satisfied: c.satisfied, linked: c.task_ids.length, unshipped: _unshipped }),
129
+ };
130
+ });
131
+ }
132
+
133
+ // shapeSky — the wire shape. Pure, so a test pins it with no database.
134
+ //
135
+ // goals: [{ goal_id, title, subtitle, status, version_id, category, task_total,
136
+ // task_done, created_at, criteria: [{ id, criterion_id, criterion_md,
137
+ // satisfied, pending_review, task_ids }], tasks: [{ id, title, status,
138
+ // credits_reward, created_on, idea_id }] }]
139
+ // edges: [{ from, to, count }] — `from` waits on `to`, the graph's own
140
+ // counts: { goals, edges, criteria, tasks }
141
+ //
142
+ // A criterion or a task whose goal is not among the wells is DROPPED — the wells
143
+ // are the graph's nodes (goals carrying at least one task, in the version asked
144
+ // for), and a ring drawn around no well is a line pointing at nothing.
145
+ function shapeSky({ goals = [], edges = [], extras = [], criteria = [], tasks = [] } = {}, categoryById = new Map()) {
146
+ const graph = goalGraph.shapeGraph({ goals, edges }, categoryById);
147
+ const visibleById = new Map(goals.map((g) => [String(g.goal_id), g.visible !== false]));
148
+ const extraById = new Map(extras.map((x) => [String(x.goal_id), x]));
149
+ const wells = new Map(graph.nodes.map((n) => [n.goal_id, { ...n, criteria: [], tasks: [] }]));
150
+
151
+ let criteriaCount = 0;
152
+ for (const c of foldCriteria(criteria)) {
153
+ const well = c.goal_id != null ? wells.get(c.goal_id) : null;
154
+ if (!well) continue;
155
+ const visible = visibleById.get(c.goal_id) !== false;
156
+ const { goal_id, ...entry } = c;
157
+ well.criteria.push({ ...entry, criterion_md: visible ? entry.criterion_md : null });
158
+ criteriaCount += 1;
159
+ }
160
+
161
+ let taskCount = 0;
162
+ for (const t of tasks) {
163
+ const gid = t.goal_id != null ? String(t.goal_id) : null;
164
+ const well = gid != null ? wells.get(gid) : null;
165
+ if (!well) continue;
166
+ const visible = visibleById.get(gid) !== false;
167
+ well.tasks.push({
168
+ id: String(t.id),
169
+ title: visible ? t.title : null,
170
+ status: t.status,
171
+ credits_reward: t.credits_reward == null ? null : Number(t.credits_reward),
172
+ created_on: t.created_on || null,
173
+ idea_id: t.idea_id != null ? String(t.idea_id) : null,
174
+ });
175
+ taskCount += 1;
176
+ }
177
+
178
+ const out = [...wells.values()].map((w) => {
179
+ const visible = visibleById.get(w.goal_id) !== false;
180
+ const x = extraById.get(w.goal_id) || {};
181
+ const { depends_on, depended_on_by, ...node } = w;
182
+ return {
183
+ ...node,
184
+ subtitle: visible ? (x.subtitle ?? null) : null,
185
+ created_at: x.created_at instanceof Date ? x.created_at.toISOString() : (x.created_at ?? null),
186
+ };
187
+ });
188
+
189
+ return {
190
+ goals: out,
191
+ edges: graph.edges,
192
+ counts: { goals: out.length, edges: graph.edges.length, criteria: criteriaCount, tasks: taskCount },
193
+ };
194
+ }
195
+
196
+ // sky — read + shape. `categoryIndex` is injectable so the whole path runs in a
197
+ // test with a fake pool and no category table (the goal-graph.js precedent).
198
+ async function sky(opts = {}, deps = {}) {
199
+ const [rows, byId] = await Promise.all([
200
+ skyRows(opts, deps),
201
+ (deps.categoryIndex || goalCategory.categoryIndex)(deps),
202
+ ]);
203
+ return shapeSky(rows, byId);
204
+ }
205
+
206
+ // skyCache — the 60-second memo, keyed by what decides what the viewer may see.
207
+ // `now` is injectable so a test can move the clock instead of waiting a minute.
208
+ function skyCache({ ttlMs = SKY_CACHE_TTL_MS, now = Date.now } = {}) {
209
+ const entries = new Map();
210
+ return {
211
+ key({ viewerId, viewerIsArchon, versionId }) {
212
+ return `${viewerIsArchon === true ? 'archon' : `b${viewerId}`}|${versionId == null ? '' : String(versionId)}`;
213
+ },
214
+ get(key) {
215
+ const e = entries.get(key);
216
+ if (!e) return null;
217
+ if (now() - e.at > ttlMs) { entries.delete(key); return null; }
218
+ return e;
219
+ },
220
+ set(key, payload) {
221
+ const e = { at: now(), payload };
222
+ entries.set(key, e);
223
+ return e;
224
+ },
225
+ clear() { entries.clear(); },
226
+ ttlSeconds: Math.round(ttlMs / 1000),
227
+ };
228
+ }
229
+
230
+ // skyHandler — GET /sky. A factory, so a test drives it with a stub read, a
231
+ // stub cache clock and no database. Identity comes from the authenticated
232
+ // request, never the query (goal-graph.js's rule, for the same reason: a
233
+ // caller-supplied id would let anyone ask as somebody else).
234
+ function skyHandler({ read = sky, log = console, cache = skyCache() } = {}) {
235
+ return async function getSky(req, res) {
236
+ try {
237
+ const opts = {
238
+ versionId: (req.query && req.query.version) || null,
239
+ viewerId: req.builder.id,
240
+ viewerIsArchon: req.builder.rank === 'archon',
241
+ };
242
+ const key = cache.key(opts);
243
+ let entry = cache.get(key);
244
+ let hit = true;
245
+ if (!entry) {
246
+ hit = false;
247
+ entry = cache.set(key, await read(opts));
248
+ }
249
+ res.set('Cache-Control', 'private, max-age=60');
250
+ return res.json({
251
+ ...entry.payload,
252
+ cache: { hit, ttl_seconds: cache.ttlSeconds, taken_at: new Date(entry.at).toISOString() },
253
+ });
254
+ } catch (err) {
255
+ log.error('[gds] GET /sky', err);
256
+ return res.fail('sky_failed', { status: 500, message: 'internal error' });
257
+ }
258
+ };
259
+ }
260
+
261
+ module.exports = { sky, skyRows, shapeSky, foldCriteria, skyCache, skyHandler, SKY_CACHE_TTL_MS };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.1010",
3
+ "version": "1.19.1011",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.1010",
9
+ "version": "1.19.1011",
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.1010",
3
+ "version": "1.19.1011",
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",
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.1010'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
74
+ const CORE_VERSION = '1.19.1011'; // 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,138 @@
1
+ // tests/sky_mine.mjs — GET /sky/mine, the caller's whole trail in one read (task
2
+ // 1004230, sky part 2 of 5; goal 1000095; ADR 0331, ADR 0322).
3
+ //
4
+ // What this file pins: that the rows are the trail's own (the same list read,
5
+ // the same three attachers, in the trail route's order — nothing derived here);
6
+ // that the cap holds at a thousand and says so; that the builder is the
7
+ // signed-in caller and never a URL parameter; and that the route is mounted
8
+ // beside, not under, the lifecycle module's /sky.
9
+ //
10
+ // DB-free: the read is driven with stub pages and stub attachers; the handler
11
+ // with a stub read; the mount is read from the router and the manifest.
12
+ //
13
+ // Run: node tests/sky_mine.mjs
14
+ process.env.NODE_ENV = 'test';
15
+ import assert from 'node:assert/strict';
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { fileURLToPath } from 'node:url';
19
+ import { createRequire } from 'node:module';
20
+ import { makeRunner } from './helpers.mjs';
21
+
22
+ const require = createRequire(import.meta.url);
23
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
24
+ const { readSkyMine, skyMineHandler, SKY_MINE_CAP } = require(path.join(ROOT, 'modules/ideas/sky-mine.js'));
25
+ const inbox = require(path.join(ROOT, 'modules/ideas/inbox.js'));
26
+ const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules/ideas/module.json'), 'utf8'));
27
+ const LIB_SRC = fs.readFileSync(path.join(ROOT, 'modules/ideas/sky-mine.js'), 'utf8');
28
+ const ROUTE_SRC = fs.readFileSync(path.join(ROOT, 'modules/ideas/routes/sky-mine.js'), 'utf8');
29
+ const TRAIL_ROUTE_SRC = fs.readFileSync(path.join(ROOT, 'modules/ideas/routes/inbox.js'), 'utf8');
30
+
31
+ const { test, summary } = makeRunner();
32
+
33
+ // A stub trail of N ideas, paged exactly as listIdeaTrailForBuilder pages.
34
+ function stubTrail(n) {
35
+ const all = Array.from({ length: n }, (_, i) => ({ id: String(n - i), title: `Idea ${n - i}`, status: 'open' }));
36
+ const calls = { list: [], count: [], attach: [] };
37
+ const deps = {
38
+ listIdeaTrailForBuilder: async (builderId, { limit, offset, viewerRank }) => { calls.list.push({ builderId, limit, offset, viewerRank }); return all.slice(offset, offset + limit); },
39
+ countIdeaTrailForBuilder: async (builderId, { viewerRank }) => { calls.count.push({ builderId, viewerRank }); return n; },
40
+ attachDescentToTrail: async (ideas) => { calls.attach.push(['descent', ideas.length]); for (const i of ideas) i.descent_tasks = []; },
41
+ attachSizeToTrail: async (ideas) => { calls.attach.push(['size', ideas.length]); for (const i of ideas) i.size = { credits: 0, scale: 0 }; },
42
+ attachStateToTrail: async (ideas) => { calls.attach.push(['state', ideas.length]); for (const i of ideas) i.state = { object: 'protostar' }; },
43
+ };
44
+ return { deps, calls };
45
+ }
46
+
47
+ await test('the read pages the trail\'s own list at its own page size until the total, and returns every row', async () => {
48
+ const { deps, calls } = stubTrail(249);
49
+ const out = await readSkyMine({ builderId: 9, viewerRank: 'metic' }, deps);
50
+ assert.equal(out.ideas.length, 249);
51
+ assert.equal(out.total, 249);
52
+ assert.equal(out.truncated, false);
53
+ assert.equal(out.cap, SKY_MINE_CAP);
54
+ assert.equal(SKY_MINE_CAP, 1000);
55
+ assert.deepEqual(calls.list.map((c) => [c.limit, c.offset]), [[100, 0], [100, 100], [100, 200]], 'three pages of the trail\'s max page size');
56
+ assert.equal(calls.list[0].limit, inbox.IDEA_TRAIL_MAX_LIMIT, 'the page size is the trail\'s, not a number of its own');
57
+ for (const c of calls.list) { assert.equal(c.builderId, 9); assert.equal(c.viewerRank, 'metic', 'the viewer\'s rank rides into every page'); }
58
+ assert.equal(out.ideas[0].id, '249', 'newest first, as the trail orders');
59
+ });
60
+
61
+ await test('the three attachers run ONCE over the whole set, in the trail route\'s order: descent, then size, then state', async () => {
62
+ const { deps, calls } = stubTrail(150);
63
+ const out = await readSkyMine({ builderId: 9 }, deps);
64
+ assert.deepEqual(calls.attach, [['descent', 150], ['size', 150], ['state', 150]]);
65
+ assert.ok(out.ideas.every((i) => i.state && i.size && Array.isArray(i.descent_tasks)));
66
+ });
67
+
68
+ await test('the cap holds at 1000: a filer past it gets the newest thousand and truncated: true, never a silent slice', async () => {
69
+ const { deps, calls } = stubTrail(1234);
70
+ const out = await readSkyMine({ builderId: 9 }, deps);
71
+ assert.equal(out.ideas.length, 1000);
72
+ assert.equal(out.total, 1234);
73
+ assert.equal(out.truncated, true);
74
+ assert.equal(calls.list.length, 10, 'ten pages, not thirteen');
75
+ assert.equal(calls.list[9].offset, 900);
76
+ assert.equal(calls.list[9].limit, 100);
77
+ });
78
+
79
+ await test('an odd cap asks the last page for only what is left', async () => {
80
+ const { deps, calls } = stubTrail(500);
81
+ const out = await readSkyMine({ builderId: 9 }, { ...deps, cap: 250 });
82
+ assert.equal(out.ideas.length, 250);
83
+ assert.deepEqual(calls.list.map((c) => [c.limit, c.offset]), [[100, 0], [100, 100], [50, 200]]);
84
+ assert.equal(out.truncated, true);
85
+ });
86
+
87
+ await test('a short page ends the walk early (the count moved under us), and an empty trail reads nothing', async () => {
88
+ const { deps, calls } = stubTrail(0);
89
+ const out = await readSkyMine({ builderId: 9 }, deps);
90
+ assert.deepEqual(out.ideas, []);
91
+ assert.equal(calls.list.length, 0, 'no page is read for a total of zero');
92
+ assert.equal(out.truncated, false);
93
+
94
+ const { deps: d2, calls: c2 } = stubTrail(150);
95
+ d2.countIdeaTrailForBuilder = async () => 400; // the count claims more than the list has
96
+ const out2 = await readSkyMine({ builderId: 9 }, d2);
97
+ assert.equal(out2.ideas.length, 150);
98
+ assert.equal(c2.list.length, 2, 'stops on the short page rather than reading empties');
99
+ });
100
+
101
+ await test('nothing is derived here — the lib calls the trail\'s own read and the three attachers, and never touches the pool or idea-state internals', () => {
102
+ assert.match(LIB_SRC, /require\('\.\/inbox'\)/);
103
+ assert.match(LIB_SRC, /attachDescentToTrail/);
104
+ assert.match(LIB_SRC, /attachSizeToTrail/);
105
+ assert.match(LIB_SRC, /attachStateToTrail/);
106
+ assert.equal(/pool\.query|resolveIdeaState|SELECT /.test(LIB_SRC), false, 'no SQL and no second derivation (ADR 0322)');
107
+ // and the order matches the trail route's, read from its source
108
+ const order = (src) => ['attachDescentToTrail(', 'attachSizeToTrail(', 'attachStateToTrail('].map((n) => src.indexOf(n));
109
+ const trailOrder = order(TRAIL_ROUTE_SRC.slice(TRAIL_ROUTE_SRC.indexOf("'/inbox/by-builder/:id'")));
110
+ assert.ok(trailOrder[0] < trailOrder[1] && trailOrder[1] < trailOrder[2], 'the trail route runs descent, size, state');
111
+ const mine = LIB_SRC.slice(LIB_SRC.indexOf('await descent('));
112
+ assert.ok(mine.indexOf('await descent(') < mine.indexOf('await size(') && mine.indexOf('await size(') < mine.indexOf('await state('));
113
+ });
114
+
115
+ // ---- the handler ----------------------------------------------------------------------
116
+
117
+ await test('the builder is the signed-in caller — from the request, never a param or the query', async () => {
118
+ let seen = null;
119
+ const h = skyMineHandler({ read: async (o) => { seen = o; return { builder_id: o.builderId, ideas: [], total: 0, cap: 1000, truncated: false }; } });
120
+ let body = null;
121
+ await h({ builder: { id: '41', rank: 'thetes' }, params: { id: '999' }, query: { builder_id: '998' } }, { json(b) { body = b; } });
122
+ assert.deepEqual(seen, { builderId: 41, viewerRank: 'thetes' });
123
+ assert.equal(body.builder_id, 41);
124
+ });
125
+
126
+ // ---- the mount ------------------------------------------------------------------------
127
+
128
+ await test('the route is contributed by the ideas manifest at /sky/mine, gated to a signed-in builder, with no :id under /sky', () => {
129
+ assert.ok(manifest.contributes.routes.includes('sky-mine'), 'module.json contributes "sky-mine"');
130
+ assert.match(ROUTE_SRC, /router\.get\('\/sky\/mine',\s*api\.requireBuilder,/);
131
+ assert.equal(/\/sky\/:/.test(ROUTE_SRC), false, 'no parameterised /sky/:x that could bind the literal "mine"');
132
+ const factory = require(path.join(ROOT, 'modules/ideas/routes/sky-mine.js'));
133
+ const router = factory({});
134
+ const paths = router.stack.filter((l) => l.route).map((l) => l.route.path);
135
+ assert.deepEqual(paths, ['/sky/mine']);
136
+ });
137
+
138
+ summary();
@@ -0,0 +1,265 @@
1
+ // tests/sky_read.mjs — GET /sky, the whole project sky in one read (task 1004230,
2
+ // sky part 2 of 5; goal 1000095; ADR 0331, ADR 0227 §11).
3
+ //
4
+ // What this file pins is what the picture cannot show: that the wells and the
5
+ // routes ARE the goal graph's (same read, not a copy); that a private goal stays
6
+ // a well but loses every piece of prose, not only its title; that a task's
7
+ // criterion link is the SAME link the version progress rollup computes, from the
8
+ // same rows; that the payload is compact; that the cache is per viewer and
9
+ // expires; and that identity comes from the authenticated request.
10
+ //
11
+ // DB-free: the pure shaper is run directly, the SQL against a recording fake
12
+ // pool, the handler against stub collaborators (the goal_graph.mjs precedent).
13
+ //
14
+ // Run: node tests/sky_read.mjs
15
+ process.env.NODE_ENV = 'test';
16
+ import assert from 'node:assert/strict';
17
+ import fs from 'node:fs';
18
+ import path from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
20
+ import { createRequire } from 'node:module';
21
+ import { makeRunner } from './helpers.mjs';
22
+
23
+ const require = createRequire(import.meta.url);
24
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
25
+ const skyLib = require(path.join(ROOT, 'modules/lifecycle/sky.js'));
26
+ const { shapeSky, foldCriteria, skyRows, sky, skyCache, skyHandler } = skyLib;
27
+ const { buildCriterionProgress } = require(path.join(ROOT, 'modules/lifecycle/done-when.js'));
28
+ const { CUT_STATUSES, DONE_STATUSES } = require(path.join(ROOT, 'modules/lifecycle/task-status.js'));
29
+ const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules/lifecycle/module.json'), 'utf8'));
30
+ const ROUTE_SRC = fs.readFileSync(path.join(ROOT, 'modules/lifecycle/routes/sky.js'), 'utf8');
31
+
32
+ const { test, summary } = makeRunner();
33
+
34
+ const CATS = new Map([['7', { id: 7, slug: 'security', label: 'Security' }]]);
35
+ const g = (id, extra = {}) => ({ goal_id: String(id), title: `Goal ${id}`, status: 'open', version_id: 'V1', category_id: null, visible: true, task_total: 4, task_done: 1, ...extra });
36
+ const e = (from, to, count = 1) => ({ from_goal_id: String(from), to_goal_id: String(to), count });
37
+ const x = (id, extra = {}) => ({ goal_id: String(id), subtitle: `sub ${id}`, created_at: new Date('2026-09-01T00:00:00Z'), ...extra });
38
+ // A flat (criterion, task) row in the shape both the rollup and the sky read.
39
+ const cr = (id, goalId, taskId, taskStatus, extra = {}) => ({ id: String(id), goal_id: String(goalId), criterion_id: `slug-${id}`, criterion_md: `Criterion ${id}`, satisfied: false, sort_order: Number(id), task_id: taskId == null ? null : String(taskId), task_status: taskStatus ?? null, ...extra });
40
+ const t = (id, goalId, extra = {}) => ({ id: String(id), goal_id: String(goalId), title: `Task ${id}`, status: 'ready', credits_reward: 30, created_on: '2026-09-02', idea_id: null, ...extra });
41
+
42
+ // ---- shapeSky -------------------------------------------------------------------
43
+
44
+ await test('the shape: wells carry their criteria and their compact tasks; edges are the graph\'s; counts add up', () => {
45
+ const out = shapeSky({
46
+ goals: [g(1), g(2)], edges: [e(1, 2, 3)], extras: [x(1), x(2)],
47
+ criteria: [cr(10, 1, 100, 'shipped'), cr(10, 1, 101, 'ready'), cr(11, 2, null)],
48
+ tasks: [t(100, 1, { status: 'shipped', idea_id: '555' }), t(101, 1), t(200, 2)],
49
+ });
50
+ assert.deepEqual(Object.keys(out), ['goals', 'edges', 'counts']);
51
+ assert.deepEqual(out.edges, [{ from: '1', to: '2', count: 3 }]);
52
+ assert.deepEqual(out.counts, { goals: 2, edges: 1, criteria: 2, tasks: 3 });
53
+ const w1 = out.goals[0];
54
+ assert.deepEqual(Object.keys(w1).sort(), ['category', 'created_at', 'criteria', 'goal_id', 'status', 'subtitle', 'task_done', 'task_total', 'tasks', 'title', 'version_id']);
55
+ assert.equal(w1.subtitle, 'sub 1');
56
+ assert.equal(w1.created_at, '2026-09-01T00:00:00.000Z');
57
+ assert.deepEqual(w1.criteria, [{ id: '10', criterion_id: 'slug-10', criterion_md: 'Criterion 10', satisfied: false, task_ids: ['100', '101'], pending_review: false }]);
58
+ assert.deepEqual(w1.tasks[0], { id: '100', title: 'Task 100', status: 'shipped', credits_reward: 30, created_on: '2026-09-02', idea_id: '555' });
59
+ assert.equal(w1.tasks[1].idea_id, null, 'a task not born of an idea carries null, not an empty string');
60
+ assert.deepEqual(out.goals[1].criteria[0].task_ids, [], 'a criterion with no linked task is an empty ring, not a missing one');
61
+ });
62
+
63
+ await test('nothing but the compact fields rides on a task — no description, no value summary, no notes', () => {
64
+ const out = shapeSky({ goals: [g(1)], extras: [x(1)], tasks: [t(1, 1, { description: 'long prose', value_summary: 'more prose', notes: 'and more' })] });
65
+ assert.deepEqual(Object.keys(out.goals[0].tasks[0]).sort(), ['created_on', 'credits_reward', 'id', 'idea_id', 'status', 'title']);
66
+ });
67
+
68
+ await test('a ring or a task under a goal that is not a well is dropped, not drawn to nowhere', () => {
69
+ const out = shapeSky({ goals: [g(1)], extras: [x(1)], criteria: [cr(10, 99, 5, 'ready')], tasks: [t(5, 99)] });
70
+ assert.deepEqual(out.counts, { goals: 1, edges: 0, criteria: 0, tasks: 0 });
71
+ });
72
+
73
+ await test('pending_review is derived the way the rollup derives it: every linked task terminal, at least one shipped, not yet satisfied', () => {
74
+ const out = shapeSky({
75
+ goals: [g(1)], extras: [x(1)],
76
+ criteria: [cr(10, 1, 1, 'shipped'), cr(10, 1, 2, 'abandoned'), cr(11, 1, 3, 'shipped'), cr(11, 1, 4, 'active'), cr(12, 1, 5, 'abandoned'), cr(13, 1, 6, 'shipped', { satisfied: true })],
77
+ });
78
+ const by = Object.fromEntries(out.goals[0].criteria.map((c) => [c.id, c]));
79
+ assert.equal(by['10'].pending_review, true, 'shipped + abandoned: abandoned is terminal, so nothing remains');
80
+ assert.equal(by['11'].pending_review, false, 'an active task still gates it');
81
+ assert.equal(by['12'].pending_review, true, 'all-abandoned DOES surface for review — the review flag is deliberately wider than auto-close (task 1002651, ADR 0183), and the sky must not narrow it');
82
+ assert.equal(by['13'].pending_review, false, 'already satisfied');
83
+ assert.equal(by['13'].satisfied, true);
84
+ });
85
+
86
+ await test('a PRIVATE goal the viewer cannot see stays a well with its counts, rings and tasks — but EVERY piece of prose is null, not only the title', () => {
87
+ const secret = { goal_id: '1', title: 'Secret roadmap', status: 'open', version_id: 'V1', category_id: 7, visible: false, task_total: 2, task_done: 0 };
88
+ const out = shapeSky({
89
+ goals: [secret, g(2)], edges: [e(2, 1)], extras: [x(1, { subtitle: 'the secret plan' }), x(2)],
90
+ criteria: [cr(10, 1, 100, 'ready', { criterion_md: 'secret criterion text' })],
91
+ tasks: [t(100, 1, { title: 'secret task title' })],
92
+ }, CATS);
93
+ const hidden = out.goals[0];
94
+ assert.equal(hidden.title, null);
95
+ assert.equal(hidden.subtitle, null);
96
+ assert.equal(hidden.category, null);
97
+ assert.equal(hidden.task_total, 2, 'counts are structure, not secrets (ADR 0112)');
98
+ assert.equal(hidden.criteria.length, 1, 'the ring is still drawn');
99
+ assert.equal(hidden.criteria[0].criterion_md, null, 'its text is not');
100
+ assert.deepEqual(hidden.criteria[0].task_ids, ['100'], 'the link survives — an id is not prose');
101
+ assert.equal(hidden.tasks[0].title, null);
102
+ assert.equal(hidden.tasks[0].status, 'ready');
103
+ assert.deepEqual(out.edges, [{ from: '2', to: '1', count: 1 }], 'and its routes still draw, or the structure would lie');
104
+ const json = JSON.stringify(out);
105
+ for (const leak of ['Secret roadmap', 'the secret plan', 'secret criterion text', 'secret task title', 'Security']) {
106
+ assert.equal(json.includes(leak), false, `"${leak}" is nowhere in the payload`);
107
+ }
108
+ });
109
+
110
+ await test('an empty corpus is a valid, empty sky', () => {
111
+ assert.deepEqual(shapeSky({}), { goals: [], edges: [], counts: { goals: 0, edges: 0, criteria: 0, tasks: 0 } });
112
+ });
113
+
114
+ // ---- the criterion→task link agrees with the progress rollup ------------------------
115
+
116
+ await test('a task\'s criterion link is the SAME link the version progress rollup computes, from the same flat rows', () => {
117
+ // The rollup's rows carry cnum + task_title; the sky's carry goal_id + task_status.
118
+ // One set of rows, both projections, so the two folds see one truth.
119
+ const pairs = [[10, 1, 100, 'shipped'], [10, 1, 101, 'ready'], [11, 1, null, null], [12, 2, 200, 'active'], [12, 2, 201, 'shipped'], [12, 2, 202, 'abandoned']];
120
+ const rollupRows = pairs.map(([c, goal, task, status], i) => ({ cnum: c - 9, id: String(c), criterion_id: `slug-${c}`, criterion_md: `Criterion ${c}`, satisfied: false, goal_id: String(goal), task_id: task == null ? null : String(task), task_title: task == null ? null : `Task ${task}`, task_status: status }));
121
+ const skyRowsFlat = pairs.map(([c, goal, task, status]) => cr(c, goal, task, status));
122
+
123
+ const rollup = buildCriterionProgress('V1', rollupRows, 0, [{ id: '1', title: 'A', status: 'open' }, { id: '2', title: 'B', status: 'open' }]);
124
+ const folded = foldCriteria(skyRowsFlat);
125
+
126
+ const linkFromRollup = Object.fromEntries(rollup.criteria.map((c) => [c.id, c.tasks.map((tk) => String(tk.id))]));
127
+ const linkFromSky = Object.fromEntries(folded.map((c) => [c.id, c.task_ids]));
128
+ assert.deepEqual(linkFromSky, linkFromRollup);
129
+ // and the derived flag agrees too, criterion by criterion
130
+ const flagFromRollup = Object.fromEntries(rollup.criteria.map((c) => [c.id, c.pending_review]));
131
+ const flagFromSky = Object.fromEntries(folded.map((c) => [c.id, c.pending_review]));
132
+ assert.deepEqual(flagFromSky, flagFromRollup);
133
+ assert.equal(flagFromSky['12'], false, 'one active task keeps ring 12 open on both sides');
134
+ });
135
+
136
+ // ---- the SQL, against a recording pool -----------------------------------------------
137
+
138
+ function fakePool({ goalRows = [], edgeRows = [], extraRows = [], criteriaRows = [], taskRows = [] } = {}) {
139
+ const calls = [];
140
+ return {
141
+ calls,
142
+ async query(sql, params) {
143
+ calls.push({ sql, params });
144
+ if (/FROM dependencies/.test(sql)) return { rows: edgeRows };
145
+ if (/FROM done_when_criteria/.test(sql)) return { rows: criteriaRows };
146
+ if (/subtitle/.test(sql)) return { rows: extraRows };
147
+ if (/FROM tasks t/.test(sql)) return { rows: taskRows };
148
+ return { rows: goalRows };
149
+ },
150
+ };
151
+ }
152
+
153
+ await test('the wells and the routes come from goal-graph.js\'s OWN read — the viewer, the Archon flag and the shared buckets ride as its bind parameters', async () => {
154
+ const pool = fakePool();
155
+ await skyRows({ versionId: 'V1', viewerId: 42, viewerIsArchon: true }, { pool });
156
+ const goalsQ = pool.calls.find((c) => /FROM goals g\s+JOIN tasks t/.test(c.sql));
157
+ assert.ok(goalsQ, 'the graph\'s goals query ran');
158
+ assert.deepEqual(goalsQ.params, ['V1', true, 42, CUT_STATUSES, DONE_STATUSES]);
159
+ assert.ok(pool.calls.some((c) => /FROM dependencies/.test(c.sql)), 'and its edges query');
160
+ assert.equal(pool.calls.length, 5, 'five queries, however many goals, criteria or tasks there are');
161
+ for (const { sql } of pool.calls) assert.equal(/\b42\b/.test(sql), false, 'no viewer id in any SQL text');
162
+ });
163
+
164
+ await test('the criterion→task join is read ONCE for every goal-bearing criterion — one query, flat rows, never per criterion or per version', async () => {
165
+ const pool = fakePool();
166
+ await skyRows({}, { pool });
167
+ const crit = pool.calls.filter((c) => /FROM done_when_criteria/.test(c.sql));
168
+ assert.equal(crit.length, 1);
169
+ assert.match(crit[0].sql, /LEFT JOIN task_criteria tc ON tc\.criterion_id = c\.id/);
170
+ assert.match(crit[0].sql, /c\.goal_id IS NOT NULL/);
171
+ assert.match(crit[0].sql, /LEFT JOIN tasks t\s+ON t\.id = tc\.task_id/, 'a criterion with no task still arrives (the rollup\'s LEFT JOIN)');
172
+ });
173
+
174
+ await test('the task query is compact by construction: no description, no value_summary, and the date is truncated in SQL', async () => {
175
+ const pool = fakePool();
176
+ await skyRows({}, { pool });
177
+ const tq = pool.calls.find((c) => /FROM tasks t\s+JOIN goals g/.test(c.sql));
178
+ const select = tq.sql.slice(tq.sql.indexOf('SELECT'), tq.sql.indexOf('FROM'));
179
+ assert.equal(/description|value_summary|notes|handoff/.test(select), false, select);
180
+ assert.match(select, /created_at::date/);
181
+ assert.match(select, /source_ref ~ '\^idea:\[0-9\]\+\$'/, 'the idea id comes from a strict source_ref match');
182
+ });
183
+
184
+ await test('a repeated ?version= (an array) binds as its first value on every query — never a driver-level 500', async () => {
185
+ const pool = fakePool();
186
+ await skyRows({ versionId: ['V1', 'V2'] }, { pool });
187
+ for (const { params } of pool.calls) assert.equal(params[0], 'V1');
188
+ });
189
+
190
+ await test('the payload is the shaped sky, categories included, from one fake read', async () => {
191
+ const pool = fakePool({
192
+ goalRows: [{ goal_id: '1', title: 'A', status: 'open', version_id: 'V1', category_id: 7, visible: true, task_total: 1, task_done: 0 }],
193
+ extraRows: [x(1)], criteriaRows: [cr(10, 1, 100, 'ready')], taskRows: [t(100, 1)],
194
+ });
195
+ const out = await sky({ viewerId: 1 }, { pool, categoryIndex: async () => CATS });
196
+ assert.equal(out.goals[0].category.label, 'Security');
197
+ assert.deepEqual(out.goals[0].criteria[0].task_ids, ['100']);
198
+ assert.equal(out.goals[0].tasks[0].title, 'Task 100');
199
+ });
200
+
201
+ // ---- the cache and the handler -------------------------------------------------------
202
+
203
+ function run(handler, req) {
204
+ const res = { body: null, failed: null, headers: {}, set(k, v) { this.headers[k] = v; return this; }, json(b) { this.body = b; return this; }, fail(code, o) { this.failed = { code, o }; return this; } };
205
+ return handler(req, res).then(() => res);
206
+ }
207
+
208
+ await test('the handler reads identity from the authenticated request, never the query', async () => {
209
+ let seen = null;
210
+ const h = skyHandler({ read: async (o) => { seen = o; return { goals: [], edges: [], counts: {} }; }, log: { error() {} } });
211
+ const res = await run(h, { builder: { id: 7, rank: 'metic' }, query: { version: 'V2', viewerId: '999', viewerIsArchon: 'true' } });
212
+ assert.deepEqual(seen, { versionId: 'V2', viewerId: 7, viewerIsArchon: false });
213
+ assert.equal(res.body.cache.hit, false);
214
+ assert.equal(res.body.cache.ttl_seconds, 60);
215
+ assert.equal(res.headers['Cache-Control'], 'private, max-age=60', 'private — the payload is shaped for one viewer');
216
+ });
217
+
218
+ await test('the cache serves a second read within 60s without touching the database, says so, and expires after', async () => {
219
+ let now = 1_000_000;
220
+ let reads = 0;
221
+ const cache = skyCache({ now: () => now });
222
+ const h = skyHandler({ read: async () => { reads += 1; return { goals: [], edges: [], counts: {} }; }, log: { error() {} }, cache });
223
+ const req = { builder: { id: 7, rank: 'metic' }, query: {} };
224
+ const a = await run(h, req);
225
+ const b = await run(h, req);
226
+ assert.equal(reads, 1);
227
+ assert.equal(b.body.cache.hit, true);
228
+ assert.equal(b.body.cache.taken_at, a.body.cache.taken_at);
229
+ now += 61_000;
230
+ const c = await run(h, req);
231
+ assert.equal(reads, 2, 'expired — read again');
232
+ assert.equal(c.body.cache.hit, false);
233
+ });
234
+
235
+ await test('the cache is PER VIEWER — a payload shaped for one builder is never handed to another, and an Archon\'s never to a non-Archon', async () => {
236
+ const cache = skyCache({ now: () => 5 });
237
+ const seen = [];
238
+ const h = skyHandler({ read: async (o) => { seen.push(o); return { goals: [{ goal_id: '1', title: o.viewerIsArchon ? 'SEEN' : null }], edges: [], counts: {} }; }, log: { error() {} }, cache });
239
+ const archon = await run(h, { builder: { id: 1, rank: 'archon' }, query: {} });
240
+ const metic = await run(h, { builder: { id: 2, rank: 'metic' }, query: {} });
241
+ const other = await run(h, { builder: { id: 3, rank: 'metic' }, query: {} });
242
+ assert.equal(seen.length, 3, 'three viewers, three reads');
243
+ assert.equal(archon.body.goals[0].title, 'SEEN');
244
+ assert.equal(metic.body.goals[0].title, null);
245
+ assert.equal(other.body.cache.hit, false);
246
+ assert.notEqual(cache.key({ viewerId: 1, viewerIsArchon: true }), cache.key({ viewerId: 1, viewerIsArchon: false }));
247
+ assert.notEqual(cache.key({ viewerId: 2 }), cache.key({ viewerId: 2, versionId: 'V1' }), 'a version filter is its own entry');
248
+ });
249
+
250
+ await test('a failing read is a 500 with no internals in the body', async () => {
251
+ const h = skyHandler({ read: async () => { throw new Error('SELECT exploded at goals.secret_column'); }, log: { error() {} } });
252
+ const res = await run(h, { builder: { id: 7, rank: 'metic' }, query: {} });
253
+ assert.equal(res.failed.code, 'sky_failed');
254
+ assert.equal(res.failed.o.status, 500);
255
+ assert.equal(JSON.stringify(res.failed).includes('secret_column'), false);
256
+ });
257
+
258
+ // ---- the mount -----------------------------------------------------------------------
259
+
260
+ await test('the route is contributed by the lifecycle manifest and gated to a signed-in builder', () => {
261
+ assert.ok(manifest.contributes.routes.includes('sky'), 'module.json contributes "sky"');
262
+ assert.match(ROUTE_SRC, /router\.get\('\/sky',\s*api\.requireBuilder,\s*sky\.skyHandler\(/);
263
+ });
264
+
265
+ summary();