@bongos/core 1.20.72 → 1.20.74

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.20.72",
6
- "core_contract": "1.20.72",
7
- "source_commit": "1b8205006fdecfe57f2908b856fb0df2a5f61f6e",
5
+ "core_version": "1.20.74",
6
+ "core_contract": "1.20.74",
7
+ "source_commit": "eb1d4850d76f6db32a57b9dce00da1d3eb47254f",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-10-01T15:30:26.777Z",
9
+ "built_at": "2026-10-01T16:22:03.505Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 572,
12
+ "docs_redacted": 573,
13
13
  "agent_docs_stubbed": 27,
14
- "functional_verbatim": 2800,
14
+ "functional_verbatim": 2803,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 3400,
20
- "tree_sha256": "f0893799d3b2d94852ce0f7cfa3a4189fcd1a20481dac1b9e596f23ef8971084",
19
+ "file_count": 3404,
20
+ "tree_sha256": "1c4cce121c1de975205357e900f8071000c4bdc96266e65f2a70d5edbfde42df",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -317,12 +317,12 @@
317
317
  {
318
318
  "path": "clients/bongos-client/README.md",
319
319
  "mode": "0000644",
320
- "sha256": "472c0606ff4fe4e775d56e038702960834838d8767e984427f5de29d31f9cbe9"
320
+ "sha256": "85cd4ad5c84b8ff0607a73a22af4e75fe50f2b155f2be6cda09813472ca6bbba"
321
321
  },
322
322
  {
323
323
  "path": "clients/bongos-client/bongos-client.global.js",
324
324
  "mode": "0000644",
325
- "sha256": "d641fbda06eb527c39d2eabbc7afc9f9182b03d766917e9fd84d69e86e9cc397"
325
+ "sha256": "bf3a7bf2dcf840e6f045bfe8bde50ad27a503c3b580ea1ee7d2631e384e87806"
326
326
  },
327
327
  {
328
328
  "path": "clients/bongos-client/examples/hello-world.mjs",
@@ -332,17 +332,17 @@
332
332
  {
333
333
  "path": "clients/bongos-client/index.cjs",
334
334
  "mode": "0000644",
335
- "sha256": "73b143d7faa4d930ab82f9f31b178816cc5a8ea87754de7e0575441905d4562a"
335
+ "sha256": "44274ba25258fdab686c0a7cc0874ed45b985024c061719b8720bfb406224be6"
336
336
  },
337
337
  {
338
338
  "path": "clients/bongos-client/index.d.ts",
339
339
  "mode": "0000644",
340
- "sha256": "05ba628ec9c3761d8814af291cd5b11a02dd279c6e8df232b20ca632cc1e4336"
340
+ "sha256": "a21a4ea4ba80966475f02785193ee0f5f5c9ce8644836073bea468bf08a21295"
341
341
  },
342
342
  {
343
343
  "path": "clients/bongos-client/index.mjs",
344
344
  "mode": "0000644",
345
- "sha256": "11a7eb441c5533244c695a4c60fe7b170e87eb4081af77703e796e8370e971e5"
345
+ "sha256": "64b14dca138ecf2e7ebc04c45354eabeff3caad37031eac547b1afe506d447fb"
346
346
  },
347
347
  {
348
348
  "path": "clients/bongos-client/package.json",
@@ -2254,25 +2254,30 @@
2254
2254
  "mode": "0000644",
2255
2255
  "sha256": "378354dd5ca8a1305b0c2b5a913baa5288da0014bcff8f6fb59fc1b28b654f59"
2256
2256
  },
2257
+ {
2258
+ "path": "docs/adr/0359-nobody-sets-a-module-score-by-hand-metic-can-ask-for-a-re-check.md",
2259
+ "mode": "0000644",
2260
+ "sha256": "b9861113cddb698710176c8533b07078b5b63e1995adbb3dafb537c1bc69186a"
2261
+ },
2257
2262
  {
2258
2263
  "path": "docs/adr/README.md",
2259
2264
  "mode": "0000644",
2260
- "sha256": "25fdede6b346cb7e64a91f4d9a95449f30829343c152742c1d98f99d864798ec"
2265
+ "sha256": "f4523673c55a6528efa2589ecc997b0192e15318755f0008a82b6d49e5023eb0"
2261
2266
  },
2262
2267
  {
2263
2268
  "path": "docs/api-reference.md",
2264
2269
  "mode": "0000644",
2265
- "sha256": "a24995419c2daa05deca5f2ed06656801c313f7499039a739b1bfa2cbc87e595"
2270
+ "sha256": "c8f3ce40db00caa1f508fd15e8bfff78bbc17ee50ca6968d2349379ef4a1c1f6"
2266
2271
  },
2267
2272
  {
2268
2273
  "path": "docs/api/openapi.json",
2269
2274
  "mode": "0000644",
2270
- "sha256": "63946432b10d130a8f1749ced38bb733449ea4ed8039c9794287d3a269249b0c"
2275
+ "sha256": "fc15bc8d73b5092c16bc0b43c173f18a7915dd67e0d96c268d677b3c2ecc4c19"
2271
2276
  },
2272
2277
  {
2273
2278
  "path": "docs/architecture.md",
2274
2279
  "mode": "0000644",
2275
- "sha256": "de7c4fc7c1133b726322910dc8db8992f17c0511df4f441c47671c791f182199"
2280
+ "sha256": "12f04eb7e79afdc31515209431664a489d189cecc890816d0abbf7d02accf2cc"
2276
2281
  },
2277
2282
  {
2278
2283
  "path": "docs/branding-contract.md",
@@ -2802,7 +2807,7 @@
2802
2807
  {
2803
2808
  "path": "docs/module-api-changelog.md",
2804
2809
  "mode": "0000644",
2805
- "sha256": "35268be856fb1c445d7092f01b251ccd8c891bc4d5037548a581891151833adf"
2810
+ "sha256": "45e9c2b5bd53525b3eca32b497de9c69da21100f6eb95fc658614bb79b7f395c"
2806
2811
  },
2807
2812
  {
2808
2813
  "path": "docs/modules-contract.md",
@@ -4762,7 +4767,7 @@
4762
4767
  {
4763
4768
  "path": "modules/government/catalog.js",
4764
4769
  "mode": "0000644",
4765
- "sha256": "f3d82f60997e944eb7108c36bafa9d9be8469c5980b5460abfdf410ffa601dea"
4770
+ "sha256": "7892e387042b020fbfe0dc16a5ea134b67aa2abfd97d5e72e09f5e8b529a1a04"
4766
4771
  },
4767
4772
  {
4768
4773
  "path": "modules/government/charter.js",
@@ -4919,6 +4924,11 @@
4919
4924
  "mode": "0000644",
4920
4925
  "sha256": "6eabf8b78f84922a44bdcfacf9e5dd4c2c5ead593b81125c2cc9ca8f13f844d9"
4921
4926
  },
4927
+ {
4928
+ "path": "modules/government/migrations/government_021_module_assessment_rerun.sql",
4929
+ "mode": "0000644",
4930
+ "sha256": "0f1a25b6bf6d0ecfbbb5a38fc3cf41fcd5689b42943397a663581c22a9634835"
4931
+ },
4922
4932
  {
4923
4933
  "path": "modules/government/module.json",
4924
4934
  "mode": "0000644",
@@ -9457,12 +9467,12 @@
9457
9467
  {
9458
9468
  "path": "package-lock.json",
9459
9469
  "mode": "0000644",
9460
- "sha256": "3c57608794638c93c7dae2ccb49459646d64d836e842a05748f0da78f57a1c90"
9470
+ "sha256": "cdd9bc54a6d46c2bf23d2cbe2f53ab328278496636f2f589eeccc7f403e179c7"
9461
9471
  },
9462
9472
  {
9463
9473
  "path": "package.json",
9464
9474
  "mode": "0000644",
9465
- "sha256": "6570d6e6a26b59165f041e6bac7201b855bc0876f9ea9a45bf4733da1ab2e4ac"
9475
+ "sha256": "bfafaf25062d15f53265ed155a90de3e640290b95145fcec676b3e62e936682e"
9466
9476
  },
9467
9477
  {
9468
9478
  "path": "public-docs/index.html",
@@ -9482,7 +9492,7 @@
9482
9492
  {
9483
9493
  "path": "release-notes.json",
9484
9494
  "mode": "0000644",
9485
- "sha256": "0f8c8e892bac50f1d519f86871c6a9eb2c93bad81984a6000546ec945d28f1bf"
9495
+ "sha256": "14086dee98a7516d597d2232f2c0f08816e6ccc76f7eee7754f003cb86960af5"
9486
9496
  },
9487
9497
  {
9488
9498
  "path": "scripts/bongos-mcp.js",
@@ -11517,7 +11527,7 @@
11517
11527
  {
11518
11528
  "path": "src/bongos/route-rank-check.js",
11519
11529
  "mode": "0000644",
11520
- "sha256": "c31e753d353b6c8c6b1f4e9af80a31afc995568aea9fd4ad8ab408e093a6f786"
11530
+ "sha256": "8290d110cfcb492ac241061e8dda955b594217f39128e6685578fab27e46e969"
11521
11531
  },
11522
11532
  {
11523
11533
  "path": "src/bongos/routes.js",
@@ -11602,7 +11612,7 @@
11602
11612
  {
11603
11613
  "path": "src/bongos/routes/modules.js",
11604
11614
  "mode": "0000644",
11605
- "sha256": "65dcf7b704d1fa321c128b36f3147bf23f54b976f60980450b2ed8a8583a302a"
11615
+ "sha256": "0e36ce8c06b446a7b5b78577aa3ec63b1cfa03f023963847a6eee7b8ef84e20a"
11606
11616
  },
11607
11617
  {
11608
11618
  "path": "src/bongos/routes/my-sessions.js",
@@ -11677,7 +11687,7 @@
11677
11687
  {
11678
11688
  "path": "src/module-api.js",
11679
11689
  "mode": "0000644",
11680
- "sha256": "54662cdfd15931333470431aefb25deff3da4015340e78448781d2909c7004cc"
11690
+ "sha256": "941d3e63ecc3d74e53428855942c85dc9d013da1e09e0ec4d40213dcb37dbd82"
11681
11691
  },
11682
11692
  {
11683
11693
  "path": "src/module-loader/catalog.js",
@@ -14734,6 +14744,11 @@
14734
14744
  "mode": "0000644",
14735
14745
  "sha256": "c3c74387af6972760f6701efb80aaa588634965528d79d4ca881bca051e534e5"
14736
14746
  },
14747
+ {
14748
+ "path": "tests/module_assess_price_parity.mjs",
14749
+ "mode": "0000644",
14750
+ "sha256": "78f36a8bbbd948c4485dfd80df55e1ee053af1b510b4d98607c0d2545bcfa66c"
14751
+ },
14737
14752
  {
14738
14753
  "path": "tests/module_assess_score.mjs",
14739
14754
  "mode": "0000644",
@@ -14834,6 +14849,11 @@
14834
14849
  "mode": "0000644",
14835
14850
  "sha256": "59d14575351c6b6529c6aff8e661ce915b8159988ad13585eea752c4bde4d9b6"
14836
14851
  },
14852
+ {
14853
+ "path": "tests/module_store_reassess.mjs",
14854
+ "mode": "0000644",
14855
+ "sha256": "07df2a5f55e1bbe41c99e24533e3fcb008be6c6cb0d0e0a3e1160a106baefe77"
14856
+ },
14837
14857
  {
14838
14858
  "path": "tests/module_store_registry_migration.mjs",
14839
14859
  "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
- - 486 operations across 73 resource groups
8
+ - 487 operations across 73 resource groups
9
9
 
10
10
  ## Use it from your project
11
11
 
@@ -1085,6 +1085,8 @@ function createClient(opts = {}) {
1085
1085
  postStoreModulesKeyVersions: (args) => request("POST", "/store/modules/{key}/versions", { hasBody: true }, args),
1086
1086
  // GET /store/modules/{key}/versions/{version}/howto — rank: any-builder — GET /store/modules/:key/versions/:version/howto
1087
1087
  getStoreModulesKeyVersionsVersionHowto: (args) => request("GET", "/store/modules/{key}/versions/{version}/howto", { hasBody: false }, args),
1088
+ // POST /store/modules/{key}/versions/{version}/reassess — rank: metic+archon — POST /store/modules/:key/versions/:version/reassess
1089
+ postStoreModulesKeyVersionsVersionReassess: (args) => request("POST", "/store/modules/{key}/versions/{version}/reassess", { hasBody: true }, args),
1088
1090
  // GET /store/modules/{key}/versions/{version}/tarball — rank: any-builder — GET /store/modules/:key/versions/:version/tarball
1089
1091
  getStoreModulesKeyVersionsVersionTarball: (args) => request("GET", "/store/modules/{key}/versions/{version}/tarball", { hasBody: false }, args),
1090
1092
  // GET /store/modules/latest — rank: any-builder — GET /store/modules/latest
@@ -1084,6 +1084,8 @@ function createClient(opts = {}) {
1084
1084
  postStoreModulesKeyVersions: (args) => request("POST", "/store/modules/{key}/versions", { hasBody: true }, args),
1085
1085
  // GET /store/modules/{key}/versions/{version}/howto — rank: any-builder — GET /store/modules/:key/versions/:version/howto
1086
1086
  getStoreModulesKeyVersionsVersionHowto: (args) => request("GET", "/store/modules/{key}/versions/{version}/howto", { hasBody: false }, args),
1087
+ // POST /store/modules/{key}/versions/{version}/reassess — rank: metic+archon — POST /store/modules/:key/versions/:version/reassess
1088
+ postStoreModulesKeyVersionsVersionReassess: (args) => request("POST", "/store/modules/{key}/versions/{version}/reassess", { hasBody: true }, args),
1087
1089
  // GET /store/modules/{key}/versions/{version}/tarball — rank: any-builder — GET /store/modules/:key/versions/:version/tarball
1088
1090
  getStoreModulesKeyVersionsVersionTarball: (args) => request("GET", "/store/modules/{key}/versions/{version}/tarball", { hasBody: false }, args),
1089
1091
  // GET /store/modules/latest — rank: any-builder — GET /store/modules/latest
@@ -471,6 +471,8 @@ export interface PostStoreModulesKeyAcquireResponse { ok: boolean; module_key: u
471
471
  export interface PostStoreModulesKeyAcquiredRequest { version: string }
472
472
  export interface PostStoreModulesKeyAcquiredResponse { ok: boolean; entitlement: unknown }
473
473
  export interface PostStoreModulesKeyVersionsResponse { ok: boolean; created_module: unknown; version: unknown }
474
+ export interface PostStoreModulesKeyVersionsVersionReassessRequest { reason?: string }
475
+ export interface PostStoreModulesKeyVersionsVersionReassessResponse { ok: boolean; queued: boolean; module_key: unknown; version: unknown; version_id: unknown }
474
476
  export interface PostTaskRecommendationsRequest { task_id: number; recommended_to: number; reason: string }
475
477
  export interface PostTaskRecommendationsResponse { ok: boolean; recommendation: unknown }
476
478
  export interface PostTasksIdAttestGateRequest { head_sha: string }
@@ -1532,6 +1534,8 @@ export interface BongosClient {
1532
1534
  postStoreModulesKeyVersions(args?: RequestArgs): Promise<PostStoreModulesKeyVersionsResponse>;
1533
1535
  /** GET /store/modules/{key}/versions/{version}/howto — rank: any-builder */
1534
1536
  getStoreModulesKeyVersionsVersionHowto(args?: RequestArgs): Promise<GetStoreModulesKeyVersionsVersionHowtoResponse>;
1537
+ /** POST /store/modules/{key}/versions/{version}/reassess — rank: metic+archon */
1538
+ postStoreModulesKeyVersionsVersionReassess(args?: RequestArgs & { body?: PostStoreModulesKeyVersionsVersionReassessRequest }): Promise<PostStoreModulesKeyVersionsVersionReassessResponse>;
1535
1539
  /** GET /store/modules/{key}/versions/{version}/tarball — rank: any-builder */
1536
1540
  getStoreModulesKeyVersionsVersionTarball(args?: RequestArgs): Promise<ApiResponse>;
1537
1541
  /** GET /store/modules/latest — rank: any-builder */
@@ -1081,6 +1081,8 @@ export function createClient(opts = {}) {
1081
1081
  postStoreModulesKeyVersions: (args) => request("POST", "/store/modules/{key}/versions", { hasBody: true }, args),
1082
1082
  // GET /store/modules/{key}/versions/{version}/howto — rank: any-builder — GET /store/modules/:key/versions/:version/howto
1083
1083
  getStoreModulesKeyVersionsVersionHowto: (args) => request("GET", "/store/modules/{key}/versions/{version}/howto", { hasBody: false }, args),
1084
+ // POST /store/modules/{key}/versions/{version}/reassess — rank: metic+archon — POST /store/modules/:key/versions/:version/reassess
1085
+ postStoreModulesKeyVersionsVersionReassess: (args) => request("POST", "/store/modules/{key}/versions/{version}/reassess", { hasBody: true }, args),
1084
1086
  // GET /store/modules/{key}/versions/{version}/tarball — rank: any-builder — GET /store/modules/:key/versions/:version/tarball
1085
1087
  getStoreModulesKeyVersionsVersionTarball: (args) => request("GET", "/store/modules/{key}/versions/{version}/tarball", { hasBody: false }, args),
1086
1088
  // GET /store/modules/latest — rank: any-builder — GET /store/modules/latest
@@ -0,0 +1,40 @@
1
+ # ADR 0359 — Nobody sets a module's score by hand; a Metic+ person can ask for a re-check
2
+
3
+ - **Status:** accepted
4
+ - **Date:** 2026-10-01
5
+ - **Task:** [task 1003796](https://cloudbongos.com/builders#/task/1003796) (goal 1000091 — working area 5, Module distribution & economy; criterion `wa5-quality-assessed`)
6
+ - **Amends:** [ADR 0343](0343-a-module-score-is-a-security-gate-then-an-average-of-visible-parts.md) D6 (the Metic+ score override) and [ADR 0347](0347-every-store-module-ships-a-how-to.md) D5 ("the Metic+ override applies to Docs like any part").
7
+ - **Keeps:** ADR 0343 D6's delist (task 1003797) and "every action is recorded"; ADR 0347 D6's "Metic+ re-runs it" for a Docs grade that could not run.
8
+ - **Decided with:** the owner (Will, Archon), 2026-10-01: "I don't think someone should be able to override a module's score. Maybe trigger a regrade but not change it." Metic and Archon may trigger it; the permission grant migration was approved the same day.
9
+
10
+ ## Context
11
+
12
+ ADR 0343 D6 put a Metic+ override on top of the computed score, because an automated score will sometimes be wrong and a human-review-only panel does not scale. Task 1003796 was to build it. Before any code was written the owner ruled the other way: a score a person can type in is a score a person can sell, and the reason field does not change that.
13
+
14
+ ## Decision
15
+
16
+ ### D1 — No route sets, raises or lowers a score
17
+
18
+ A module version's score only ever comes from its signals, composed by `scripts/gds/module-assess-score.js`. There is no override route, and the `kind = 'override'` rows `core_265` allows for are never written. The column and its CHECKs stay (removing them is a migration with nothing to gain); a test asserts no store route writes `module_assessment_scores`.
19
+
20
+ ### D2 — A Metic+ person can ask for a re-check, with a reason
21
+
22
+ `POST /api/bongos/store/modules/:key/versions/:version/reassess { reason }` queues the same assessment a publish runs (`assessVersion`): Tests `pending` if there is no Tests result yet, the Security gate, the Docs grader, then the score. It answers 202, or 503 when the assessment queue is full. A reason is required and nothing else is accepted in the body, so a score cannot ride along. The audit middleware records who asked and why.
23
+
24
+ This is how a wrong score gets corrected: if a signal was wrong because a check failed to run (the grader was down, npm could not be reached), the re-check runs it again. If a check itself is wrong, that is a bug in the check, fixed in code for every module.
25
+
26
+ ### D3 — Metic and Archon, through one new atom
27
+
28
+ The route is gated by `module.assessment.rerun` (floor `metic`, not a system key) in `modules/government/catalog.js`, granted to `metic` and `archon` by `government_021_module_assessment_rerun.sql`. It is Metic+ and not open to every builder because each re-check spends a Docs grader call (up to 5¢, ADR 0347 D6) and re-runs the npm audit.
29
+
30
+ ## Consequences
31
+
32
+ - An author who thinks their score is wrong asks a Metic+ person to re-check it, or reports a bug in the check. Neither path lets anyone pick the number.
33
+ - A re-check can lower a score as well as raise it: it is whatever the checks say now.
34
+ - The delist (task 1003797) is unchanged: it hides a module from the store, it does not change a score.
35
+
36
+ ## Rejected
37
+
38
+ - **The override as ADR 0343 D6 planned it.** The owner's call (above).
39
+ - **An override that only lowers a score.** It is still a person choosing the number.
40
+ - **Re-checks for every builder.** Each one costs money; Metic+ keeps that spend with trusted people.
@@ -477,3 +477,4 @@ These 20 numbers are each shared by exactly two files. They are **accepted histo
477
477
  | 0356 | [**A recruit invite expires 30 days after it is sent, decided at read time** ([task 1002969](https://cloudbongos.com/builders#/task/1002969), goal 1000110 — privacy spec D4, owner-signed). **D1:** one definition, `src/bongos/invite-expiry.js` (`INVITE_EXPIRY_DAYS` + the SQL), read by the sign-in gate, the waiting-page probe, the onboarding routes and, through the doorway's `inviteExpiry`, the hub. Nothing is stored and nothing sweeps. **D2:** only `kind='invite'` rows expire; approved applications and goal invites do not. **D3:** amends ADR 0335 D2.5: the gate reads `kind` only inside the expiry unit, which can close a door and never open one. **D4:** an expired invite is refused, marked `expired` + `expires_at` in the queues, reported `expired` by the status probe (the CLI stops), and replaced by a re-invite's fresh row. **D5:** the hub hides a `pending` notice past 30 days and a re-invite restamps it. Split out: `seen_at` (task 1004443), the decline relay (task 1004444).](0356-a-recruit-invite-expires-30-days-after-it-is-sent-read-time.md) | platform identity / admission / invites |
478
478
  | 0357 | [**The update rule set on /deploy is the one the sweep follows** ([task 1004468](https://cloudbongos.com/builders#/task/1004468), goal 1000090 — BONGOS-V2, owner's ruling 2026-09-30). Amends [ADR 0136](0136-update-channel-subscription-policy.md)'s rejected DB-column alternative. **D1:** for a roster entry whose slug matches a `provisioning_instances` row, the unattended sweep uses that row's `update_channel` (set on /deploy, task 1004445). **D2:** the roster's `channel` is only the fallback — no matching row, or the database unreadable; enrollment and topology stay in the roster. **D3:** the sweep logs each entry's channel and its source, naming an overridden roster value. **D4:** read over psql from the sweep's own `DATABASE_URL`/`PGDATABASE`, never an entry's `env`. **D5:** nothing is copied; a row never changed is `patch`. Delivery to cloudbongos.com waits on the frozen sweep checkout (task 1004307). Rejected: roster stays in charge; stricter-of-the-two; copying roster values into rows.](0357-the-update-rule-set-on-deploy-is-the-one-the-sweep-follows.md) | distribution / updates |
479
479
  | 0358 | [**A hall wears a look, and its night takes a tint of it** ([task 1004421](https://cloudbongos.com/builders#/task/1004421), goal 1000121 — BV2.PS06; owner D2 + D8, 2026-09-30). Amends [ADR 0219](0219-a-look-is-a-branding-pack-the-style-library.md) (a look could not carry a dark ground) and DESIGN.md's pure-black rule, for halls only. **D1:** the night tint is a formula over the thirteen — the four dark grounds take 5–6% of the look's accent (src/night-tint.js), emitted after the sheet as both dark twins; no token added. **D2:** on by default, and only a chosen look turns it on — a hall with no look is unchanged. **D3:** the apex and hub stay pure black (ADR 0204). **D4:** a custom look is ONE accent for both modes, its lightness nudged until every pair the hall paints with it reads (lookAdjustAccent, client copy test-pinned). **D5:** look + night_tint join the provisioning settings vocabulary; the accent and logo address ride as companion env; the instance resolves the look from the style library. **D6:** a logo is PNG/JPEG/WebP ≤256 KB, magic-sniffed, never SVG, served content-addressed; it is the hall mark and the favicon. Rejected: per-look dark palettes, two custom accents, refusing an unreadable accent, sending the palette over env.](0358-a-hall-wears-a-look-and-its-night-takes-a-tint.md) | hall / branding / design world |
480
+ | 0359 | [**Nobody sets a module's score by hand; a Metic+ person can ask for a re-check** ([task 1003796](https://cloudbongos.com/builders#/task/1003796) · goal 1000091, criterion `wa5-quality-assessed`). Amends [ADR 0343](0343-a-module-score-is-a-security-gate-then-an-average-of-visible-parts.md) D6 and [ADR 0347](0347-every-store-module-ships-a-how-to.md) D5 on the owner's ruling: there is no score override. A score only ever comes from its signals. Instead `POST /store/modules/:key/versions/:version/reassess { reason }`, gated by the new `module.assessment.rerun` atom (metic floor, granted to metic + archon by `government_021`), queues the same assessment a publish runs. A reason is required and nothing else is accepted. The delist (task 1003797) is unchanged.](0359-nobody-sets-a-module-score-by-hand-metic-can-ask-for-a-re-check.md) | module store / assessment / permissions |
@@ -20132,6 +20132,81 @@
20132
20132
  ]
20133
20133
  }
20134
20134
  },
20135
+ "/store/modules/{key}/versions/{version}/reassess": {
20136
+ "post": {
20137
+ "operationId": "post_store_modules_key_versions_version_reassess",
20138
+ "tags": [
20139
+ "store"
20140
+ ],
20141
+ "summary": "POST /store/modules/:key/versions/:version/reassess",
20142
+ "description": "POST /api/bongos/store/modules/:key/versions/:version/reassess — a Metic+ person asks for a published version's assessment to run again: the Security gate, the Docs grader, then the score (task 1003796, ADR 0359). There is deliberately no way to SET a score: the owner ruled a person may trigger a re-check, never change the number. A reason is required; the audit middleware records it with who asked. Answers 202 — the run is queued like a publish's.\n\n**Rank:** `metic+archon` — Metic or Archon rank (review/triage powers).\n\n**Permissions:** `module.assessment.rerun` (all required).",
20143
+ "x-rank": "metic+archon",
20144
+ "x-source": "src/bongos/routes/modules.js",
20145
+ "x-permissions": [
20146
+ "module.assessment.rerun"
20147
+ ],
20148
+ "parameters": [
20149
+ {
20150
+ "name": "key",
20151
+ "in": "path",
20152
+ "required": true,
20153
+ "schema": {
20154
+ "type": "string"
20155
+ },
20156
+ "description": "Path parameter `key`."
20157
+ },
20158
+ {
20159
+ "name": "version",
20160
+ "in": "path",
20161
+ "required": true,
20162
+ "schema": {
20163
+ "type": "string"
20164
+ },
20165
+ "description": "Path parameter `version`."
20166
+ }
20167
+ ],
20168
+ "requestBody": {
20169
+ "required": false,
20170
+ "content": {
20171
+ "application/json": {
20172
+ "schema": {
20173
+ "$ref": "#/components/schemas/PostStoreModulesKeyVersionsVersionReassessRequest"
20174
+ }
20175
+ }
20176
+ },
20177
+ "x-validated": true
20178
+ },
20179
+ "responses": {
20180
+ "200": {
20181
+ "description": "Success.",
20182
+ "content": {
20183
+ "application/json": {
20184
+ "schema": {
20185
+ "$ref": "#/components/schemas/PostStoreModulesKeyVersionsVersionReassessResponse"
20186
+ }
20187
+ }
20188
+ }
20189
+ },
20190
+ "400": {
20191
+ "$ref": "#/components/responses/ValidationFailed"
20192
+ },
20193
+ "401": {
20194
+ "$ref": "#/components/responses/Unauthorized"
20195
+ },
20196
+ "403": {
20197
+ "$ref": "#/components/responses/Forbidden"
20198
+ },
20199
+ "404": {
20200
+ "$ref": "#/components/responses/NotFound"
20201
+ }
20202
+ },
20203
+ "security": [
20204
+ {
20205
+ "builderSession": []
20206
+ }
20207
+ ]
20208
+ }
20209
+ },
20135
20210
  "/store/modules/{key}/versions/{version}/tarball": {
20136
20211
  "get": {
20137
20212
  "operationId": "get_store_modules_key_versions_version_tarball",
@@ -30083,6 +30158,37 @@
30083
30158
  "version"
30084
30159
  ]
30085
30160
  },
30161
+ "PostStoreModulesKeyVersionsVersionReassessRequest": {
30162
+ "type": "object",
30163
+ "properties": {
30164
+ "reason": {
30165
+ "type": "string",
30166
+ "maxLength": 2000
30167
+ }
30168
+ },
30169
+ "additionalProperties": false
30170
+ },
30171
+ "PostStoreModulesKeyVersionsVersionReassessResponse": {
30172
+ "type": "object",
30173
+ "properties": {
30174
+ "ok": {
30175
+ "type": "boolean"
30176
+ },
30177
+ "queued": {
30178
+ "type": "boolean"
30179
+ },
30180
+ "module_key": {},
30181
+ "version": {},
30182
+ "version_id": {}
30183
+ },
30184
+ "required": [
30185
+ "ok",
30186
+ "queued",
30187
+ "module_key",
30188
+ "version",
30189
+ "version_id"
30190
+ ]
30191
+ },
30086
30192
  "PostTaskRecommendationsRequest": {
30087
30193
  "type": "object",
30088
30194
  "properties": {
@@ -31044,9 +31150,9 @@
31044
31150
  "description": "A required dependency/feature is not configured or is temporarily down."
31045
31151
  }
31046
31152
  },
31047
- "x-endpoint-count": 486,
31048
- "x-schema-count": 514,
31153
+ "x-endpoint-count": 487,
31154
+ "x-schema-count": 516,
31049
31155
  "x-undocumented-bodies": 12,
31050
- "x-response-schemas": 344,
31156
+ "x-response-schemas": 345,
31051
31157
  "x-generated-by": "scripts/gds/gen-api-docs.js"
31052
31158
  }
@@ -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. 486 endpoints across 89 route files.
5
+ > **Generated from the live route files** — the route file is authoritative. 487 endpoints across 89 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`.
@@ -769,7 +769,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/0016-trust
769
769
  | GET | `/api/bongos/sso/pubkey` | `public` | — | GET /sso/pubkey — the hub's Ed25519 assertion-verification key (public). |
770
770
  | POST | `/api/bongos/sso/token` | `public` | `client_id`, `client_secret`, `code`, `redirect_uri` | POST /sso/token — server-to-server code redemption. |
771
771
 
772
- ## `store` (8)
772
+ ## `store` (9)
773
773
 
774
774
  | Method | Path | Rank | Body | Description |
775
775
  |---|---|---|---|---|
@@ -779,6 +779,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/0016-trust
779
779
  | GET | `/api/bongos/store/modules/:key/entitlement` | `any-builder` | — | |
780
780
  | POST | `/api/bongos/store/modules/:key/versions` | `metic+archon` | _undocumented_ | POST /api/bongos/store/modules/:key/versions — publish one module version to the store (task 1004271, ADR 0338 D1). |
781
781
  | GET | `/api/bongos/store/modules/:key/versions/:version/howto` | `any-builder` | — | GET /api/bongos/store/modules/:key/versions/:version/howto — the how-to a version shipped with (task 1004366, ADR 0347 D1/D3), read from … |
782
+ | POST | `/api/bongos/store/modules/:key/versions/:version/reassess` | `metic+archon` | `reason` | POST /api/bongos/store/modules/:key/versions/:version/reassess — a Metic+ person asks for a published version's assessment to run again: … |
782
783
  | GET | `/api/bongos/store/modules/:key/versions/:version/tarball` | `any-builder` | — | |
783
784
  | GET | `/api/bongos/store/modules/latest` | `any-builder` | — | The newest published version of each named module — what `bongos upgrade` compares an instance's installed store modules against to repor… |
784
785
 
@@ -273,7 +273,7 @@ module_assessment_scores(id, version_id, kind IN (computed|override), overall 0-
273
273
  -- and is a new row on the computed history, never an edit (D6)
274
274
  ```
275
275
 
276
- Signals that fill it: **Tests** — `scripts/gds/module-assess-tests.js <version id>` (task 1003791) unpacks the published tarball to `<core root>/.module-assess-<run>/<key>/` (gitignored, removed after), runs each `tests/*.mjs` in its own node process with a timeout and a credential-free environment — regardless of `isModuleEnabled`, which skips every default-off catalog module in the unit gate — and appends one `tests` row: `scored` = % of test files passing (sample_size = files), `no_data` = declares no tests, `not_scored` = tarball unreadable. The store path **refuses unless `MODULE_TEST_SANDBOX=1`** — a published module's tests run only in a separate testing environment with no secrets on disk, never on the control plane (owner, 2026-09-30). `--dir modules/<key>` is a DB-free dry run on your own checkout. On publish the version's Tests part is recorded `pending` until that environment runs it. **Security** — `scripts/gds/module-assess-security.js <version id>` (task 1003792, ADR 0343 D2 gate) appends one `security` row, `passed`/`failed` only: fails on a publish-denylist file, a `module.json` dependency fetched outside the registry (an allowlist: a plain npm name with a plain semver range or dist-tag, anything else fails and is never handed to npm), or a high/critical `npm audit` advisory (resolved metadata-only with `--ignore-scripts`; nothing of the module runs, so it is control-plane safe). An audit that cannot run is `not_scored` — the gate stays shut. Floating ranges and the `maintenance` posture (ADR 0166) are noted in `detail`, never failed on. **Docs** — `scripts/gds/module-assess-docs.js <version id>` (task 1004367, ADR 0347 D5/D6) grades the version's `HOWTO.md`, read from its verified tarball (never the optional `howto.artifactUrl`), with one Claude Sonnet 5 call through the `grade` port's cached `runSubagent` (no tools, the file fenced as untrusted, cut to 40,000 characters to stay under 5¢), retried once. It appends a `pending` row, then `scored` (the average of four 0–100 criteria — purpose, newcomer could use it, runnable example, limits stated — each with a reason, kept in `detail`) or `not_scored` (outage, malformed reply twice, no `HOWTO.md`, bad tarball). Each call's spend goes to `cost_log` via the `reward` port (source `module-docs-grader`). Price is never in the prompt. `--file <HOWTO.md>` is a DB-free dry run. **Score** — `scripts/gds/module-assess-score.js` (task 1003794) composes a version's newest signal per part into a `module_assessment_scores` row: `security_passed` from the gate (NULL = not run), `overall` = the plain average of the SCORED parts among Tests, Install, Reliability and Tester feedback (a part with no data is left out, never zero; none → NULL), Docs shown but never averaged, `is_new` until Install or Reliability is scored, plus the signal ids and a readable `formula`. A recompose that would repeat the current score writes nothing. All three signal CLIs recompose after recording, and the store's publish route queues `assessVersion` after answering the author (one at a time per process, at most 20 waiting; past that it is logged as not assessed): Tests `pending` (only if no Tests result exists), the Security gate, Docs, then the score. `node scripts/gds/module-assess-version.js <id>` re-runs it by hand (`scripts/gds/module-assess-version.js` holds the publish-time half, apart from the composer so the signal CLIs recompose without a require cycle).
276
+ Signals that fill it: **Tests** — `scripts/gds/module-assess-tests.js <version id>` (task 1003791) unpacks the published tarball to `<core root>/.module-assess-<run>/<key>/` (gitignored, removed after), runs each `tests/*.mjs` in its own node process with a timeout and a credential-free environment — regardless of `isModuleEnabled`, which skips every default-off catalog module in the unit gate — and appends one `tests` row: `scored` = % of test files passing (sample_size = files), `no_data` = declares no tests, `not_scored` = tarball unreadable. The store path **refuses unless `MODULE_TEST_SANDBOX=1`** — a published module's tests run only in a separate testing environment with no secrets on disk, never on the control plane (owner, 2026-09-30). `--dir modules/<key>` is a DB-free dry run on your own checkout. On publish the version's Tests part is recorded `pending` until that environment runs it. **Security** — `scripts/gds/module-assess-security.js <version id>` (task 1003792, ADR 0343 D2 gate) appends one `security` row, `passed`/`failed` only: fails on a publish-denylist file, a `module.json` dependency fetched outside the registry (an allowlist: a plain npm name with a plain semver range or dist-tag, anything else fails and is never handed to npm), or a high/critical `npm audit` advisory (resolved metadata-only with `--ignore-scripts`; nothing of the module runs, so it is control-plane safe). An audit that cannot run is `not_scored` — the gate stays shut. Floating ranges and the `maintenance` posture (ADR 0166) are noted in `detail`, never failed on. **Docs** — `scripts/gds/module-assess-docs.js <version id>` (task 1004367, ADR 0347 D5/D6) grades the version's `HOWTO.md`, read from its verified tarball (never the optional `howto.artifactUrl`), with one Claude Sonnet 5 call through the `grade` port's cached `runSubagent` (no tools, the file fenced as untrusted, cut to 40,000 characters to stay under 5¢), retried once. It appends a `pending` row, then `scored` (the average of four 0–100 criteria — purpose, newcomer could use it, runnable example, limits stated — each with a reason, kept in `detail`) or `not_scored` (outage, malformed reply twice, no `HOWTO.md`, bad tarball). Each call's spend goes to `cost_log` via the `reward` port (source `module-docs-grader`). Price is never in the prompt. `--file <HOWTO.md>` is a DB-free dry run. **Score** — `scripts/gds/module-assess-score.js` (task 1003794) composes a version's newest signal per part into a `module_assessment_scores` row: `security_passed` from the gate (NULL = not run), `overall` = the plain average of the SCORED parts among Tests, Install, Reliability and Tester feedback (a part with no data is left out, never zero; none → NULL), Docs shown but never averaged, `is_new` until Install or Reliability is scored, plus the signal ids and a readable `formula`. A recompose that would repeat the current score writes nothing. All three signal CLIs recompose after recording, and the store's publish route queues `assessVersion` after answering the author (one at a time per process, at most 20 waiting; past that it is logged as not assessed): Tests `pending` (only if no Tests result exists), the Security gate, Docs, then the score. `node scripts/gds/module-assess-version.js <id>` re-runs it by hand (`scripts/gds/module-assess-version.js` holds the publish-time half, apart from the composer so the signal CLIs recompose without a require cycle). **No score is ever set by hand** (owner, ADR 0359): a Metic+ person can only ask for a re-check, `POST /store/modules/:key/versions/:version/reassess { reason }` (atom `module.assessment.rerun`, metic + archon via `government_021`), which queues the same `assessVersion` a publish runs and answers 202.
277
277
 
278
278
  Code: `src/bongos/module-entitlements.js` (`grantEntitlement`, `recordAcquired`, `revokeEntitlement`, `checkEntitlement`, `listEntitlements`). Read routes (own-scoped, `requireBuilder`): `GET /store/entitlements`, `GET /store/modules/:key/entitlement`. Install (task 1003785) grants a free module through `POST /store/modules/:key/acquire`; the buy action (area 8) will grant a paid one.
279
279
 
@@ -2801,5 +2801,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
2801
2801
  landed since 1.20.70 with no explicit bump. run 36881290177. (task 1002620)
2802
2802
  1.20.72 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2803
2803
  landed since 1.20.71 with no explicit bump. run 36884685441. (task 1002620)
2804
+ 1.20.73 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2805
+ landed since 1.20.72 with no explicit bump. run 36886320087. (task 1002620)
2806
+ 1.20.74 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2807
+ landed since 1.20.73 with no explicit bump. run 36891334916. (task 1002620)
2804
2808
  ---------------------------------------------------------------------------
2805
2809
  ```
@@ -216,6 +216,9 @@ const PERMISSIONS = [
216
216
  { key: 'goal.scope.widen', system: false, floor: 'metic', guards: 'POST /goals/:id/scope (same-tier; cross-tier = goal.scope.manage, system)' },
217
217
  { key: 'module.enable', system: false, floor: 'metic', guards: 'POST /modules/:key/enable' },
218
218
  { key: 'module.submit', system: false, floor: 'metic', guards: 'POST /modules/:key/submit, GET /modules/submissions' },
219
+ // Re-run a store version's automatic assessment (task 1003796, ADR 0359). Never a
220
+ // hand-set score: the owner ruled a person may ask for a re-check, not change the number.
221
+ { key: 'module.assessment.rerun', system: false, floor: 'metic', guards: 'POST /store/modules/:key/versions/:version/reassess' },
219
222
  { key: 'security.report.file', system: false, floor: 'metic', guards: 'POST /security/reports, GET /security/adr' },
220
223
  { key: 'llm_cache.use', system: false, floor: 'metic', guards: 'POST /llm-cache/{lookup,store}' },
221
224
  { key: 'backup.manage', system: false, floor: 'metic', guards: 'GET /backup/status, POST /backup/trigger' },
@@ -0,0 +1,36 @@
1
+ -- government_021_module_assessment_rerun.sql — grant the new
2
+ -- `module.assessment.rerun` atom to Metic and Archon (task 1003796, ADR 0359).
3
+ --
4
+ -- WHAT IT IS. A Metic+ person may ask the store to re-run a published version's
5
+ -- automatic assessment (the Security gate, the Docs grader, then the score). It
6
+ -- never sets a score by hand: the owner ruled (2026-10-01) that nobody may change
7
+ -- a module's score, only trigger a re-check. A re-run costs about 5 cents (one
8
+ -- Docs grader call), which is why it is Metic+ and not open to every builder.
9
+ --
10
+ -- WHY THIS FILE EXISTS. RANK_SEED is derived from the catalog's floors, and the
11
+ -- drift guard (tests/government_seed.mjs) compares it against the union of the
12
+ -- run-once seed plus every later grant migration, per rank. A metic-floor key is
13
+ -- held by metic AND archon, so both are granted here.
14
+ --
15
+ -- Additive only: one new permission key granted to two ranks; nothing is revoked.
16
+ -- Approved by the owner (Will, Archon) on 2026-10-01.
17
+ -- Idempotent (ON CONFLICT DO NOTHING); forward-safe (INSERT only).
18
+ --
19
+ -- The `SELECT '<rank>', unnest(ARRAY[…])` shape is load-bearing: the drift guard
20
+ -- scans for it literally.
21
+
22
+ BEGIN;
23
+
24
+ INSERT INTO government_rank_permissions (rank_key, permission_key)
25
+ SELECT 'metic', unnest(ARRAY[
26
+ 'module.assessment.rerun'
27
+ ])
28
+ ON CONFLICT (rank_key, permission_key) DO NOTHING;
29
+
30
+ INSERT INTO government_rank_permissions (rank_key, permission_key)
31
+ SELECT 'archon', unnest(ARRAY[
32
+ 'module.assessment.rerun'
33
+ ])
34
+ ON CONFLICT (rank_key, permission_key) DO NOTHING;
35
+
36
+ COMMIT;
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.20.72",
3
+ "version": "1.20.74",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.20.72",
9
+ "version": "1.20.74",
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.20.72",
3
+ "version": "1.20.74",
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",
@@ -8561,5 +8561,17 @@
8561
8561
  "id": "1004367",
8562
8562
  "text": "Every module published to the store now gets its how-to guide read and scored by AI for clarity, with a short reason for each part so authors know what to fix. If the AI can't run, the guide shows 'not scored' rather than a ze"
8563
8563
  }
8564
+ ],
8565
+ "1.20.73": [
8566
+ {
8567
+ "id": "1003795",
8568
+ "text": "There is now an automatic check that free modules are scored exactly like paid ones. If anyone ever makes price affect a module's score, the check fails and points at the line."
8569
+ }
8570
+ ],
8571
+ "1.20.74": [
8572
+ {
8573
+ "id": "1003796",
8574
+ "text": "Nobody can change a module's score by hand. Senior builders can ask for a module to be checked again, with a reason, and the score updates from the fresh check."
8575
+ }
8564
8576
  ]
8565
8577
  }
@@ -237,6 +237,9 @@ const EXPECTED_RANKS = {
237
237
  // pins above — a silent downgrade puts the defense inventory below the trust line.
238
238
  // Newly VISIBLE in BV1.R109b (declared multi-line, so previously unpinnable).
239
239
  'GET /security/adr': 'metic+archon',
240
+ // Re-running a store version's assessment (task 1003796, ADR 0359) spends a Docs
241
+ // grader call and re-opens the Security gate, so it is pinned to its own atom.
242
+ 'POST /store/modules/:key/versions/:version/reassess': 'perm:module.assessment.rerun',
240
243
  };
241
244
 
242
245
  // The authz-core route pins (ADR 0174). Paths and the gating atom were renamed
@@ -402,6 +402,33 @@ module.exports = function buildModulesRouter() {
402
402
  setImmediate(() => moduleAssessVersion.queueAssessment(result.version.id, { log }));
403
403
  }));
404
404
 
405
+ // POST /api/bongos/store/modules/:key/versions/:version/reassess — a Metic+ person
406
+ // asks for a published version's assessment to run again: the Security gate, the
407
+ // Docs grader, then the score (task 1003796, ADR 0359). There is deliberately no
408
+ // way to SET a score: the owner ruled a person may trigger a re-check, never change
409
+ // the number. A reason is required; the audit middleware records it with who
410
+ // asked. Answers 202 — the run is queued like a publish's.
411
+ router.post('/store/modules/:key/versions/:version/reassess', auth.requireBuilder, auth.requirePermission('module.assessment.rerun'),
412
+ asyncHandler('POST /store/modules/:key/versions/:version/reassess', async (req, res) => {
413
+ if (validateOrRespond(req, res, { reason: { type: 'string', maxLength: 2000 } })) return;
414
+ const { key, version } = req.params;
415
+ if (!KEY_RE.test(key) || !parseVersion(version)) {
416
+ return res.fail('bad_request', { status: 400, message: 'A module key is lowercase kebab-case and a version is exact X.Y.Z.' });
417
+ }
418
+ const reason = String(req.body?.reason || '').trim();
419
+ if (!reason) {
420
+ return res.fail('reason_required', { status: 400, message: 'Say why this version should be checked again, in reason. A re-check runs the automatic assessment; it never sets a score by hand.' });
421
+ }
422
+ const r = await resolveReadable(key, version);
423
+ if (!r.ok) return res.fail(r.code, { status: r.status, message: r.message });
424
+ const queued = moduleAssessVersion.queueAssessment(r.version.id, { log });
425
+ if (!queued) {
426
+ return res.fail('assessment_queue_full', { status: 503, message: 'The assessment queue is full. Try again in a few minutes.' });
427
+ }
428
+ log.info({ versionId: r.version.id, builderId: req.builder.id }, `re-assessment of ${key} ${version} queued`);
429
+ res.status(202).json({ ok: true, queued: true, module_key: key, version, version_id: r.version.id });
430
+ }));
431
+
405
432
  // GET /api/bongos/store/entitlements and /store/modules/:key/entitlement — the
406
433
  // read side of the entitlement record (task 1003813, ADR 0338 D1). Own-scoped
407
434
  // like /me/sessions: the holder is ALWAYS the caller (holder_kind 'builder',
package/src/module-api.js CHANGED
@@ -75,7 +75,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
75
75
  // MAJOR (see allowBoxScope below): passes the request through untouched.
76
76
  function deprecatedNoopMiddleware(_req, _res, next) { next(); }
77
77
 
78
- const CORE_VERSION = '1.20.72'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
78
+ const CORE_VERSION = '1.20.74'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
79
79
 
80
80
  // A namespaced logger so a module's log lines are attributable + consistent.
81
81
  // Usage: const log = api.logger('discord'); log.info('mounted');
@@ -0,0 +1,174 @@
1
+ // tests/module_assess_price_parity.mjs — free modules are assessed identically to
2
+ // paid ones: price neither buys nor excuses a score (task 1003795; owner decision
3
+ // on criterion wa5-quality-assessed; ADR 0343 D6).
4
+ //
5
+ // Two proofs, like the update-parity test proves its own criterion:
6
+ // 1. STATIC — no file on the assessment path (scripts/gds/module-assess-*.js)
7
+ // names a price, a currency amount, free/paid or an entitlement in its code
8
+ // (comments are allowed to explain the rule). A price-conditional branch
9
+ // added anywhere there fails this test, naming the file and line.
10
+ // 2. BEHAVIOUR — the same signals give the same score for a paid version and
11
+ // for the three ways a module can be free: a zero price, no price set, and a
12
+ // core-bundled module. The fake pool holds every case's price state and
13
+ // refuses any query that reads it.
14
+ //
15
+ // Run: node tests/module_assess_price_parity.mjs
16
+
17
+ import { strict as assert } from 'node:assert';
18
+ import { test } from 'node:test';
19
+ import { createRequire } from 'node:module';
20
+ import fs from 'node:fs';
21
+ import path from 'node:path';
22
+ import { fileURLToPath } from 'node:url';
23
+
24
+ const require = createRequire(import.meta.url);
25
+ const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
26
+ const { composeScore, recomposeScore } = require('../scripts/gds/module-assess-score.js');
27
+ const { assessVersion } = require('../scripts/gds/module-assess-version.js');
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // 1. static — the assessment path never reads price
31
+ // ---------------------------------------------------------------------------
32
+
33
+ const PRICE_WORD = /\b(price\w*|isFree\w*|free|paid|cents|credits|store_module_prices|entitlement\w*|purchase\w*)\b/i;
34
+
35
+ // The code lines of a JS source, comments blanked (line numbers kept).
36
+ function codeLines(src) {
37
+ const noBlock = src.replace(/\/\*[\s\S]*?\*\//g, (m) => m.replace(/[^\n]/g, ' '));
38
+ return noBlock.split('\n').map((l) => l.replace(/(^|[^:'"`\\])\/\/.*$/, '$1'));
39
+ }
40
+ function priceHits(src) {
41
+ return codeLines(src).flatMap((l, i) => (PRICE_WORD.test(l) ? [`${i + 1}: ${l.trim()}`] : []));
42
+ }
43
+
44
+ const ASSESS_DIR = path.join(root, 'scripts', 'gds');
45
+ const assessFiles = fs.readdirSync(ASSESS_DIR).filter((f) => /^module-assess-.*\.js$/.test(f)).sort();
46
+
47
+ test('the scan sees the whole assessment path', () => {
48
+ for (const f of ['module-assess-score.js', 'module-assess-security.js', 'module-assess-tests.js', 'module-assess-version.js']) {
49
+ assert.ok(assessFiles.includes(f), `${f} is on the assessment path`);
50
+ }
51
+ });
52
+
53
+ test('the scanner catches a price branch and ignores a comment about price', () => {
54
+ assert.equal(priceHits('// Nothing here reads price: free and paid are assessed identically\nconst x = 1;').length, 0);
55
+ assert.equal(priceHits('/* free modules\n are not excused */ const y = 2;').length, 0);
56
+ assert.deepEqual(priceHits('const a = 1;\nif (ver.price_cents === 0) score += 10;'), ['2: if (ver.price_cents === 0) score += 10;']);
57
+ assert.equal(priceHits("pool.query('SELECT * FROM store_module_prices')").length, 1);
58
+ assert.equal(priceHits('const bonus = isFreePrice(p) ? 0 : 5;').length, 1);
59
+ assert.equal(priceHits("const u = 'https://x.test/a'; if (paid) x();").length, 1, 'a URL in a string does not hide the rest of the line');
60
+ });
61
+
62
+ test('no assessment file names a price in its code', () => {
63
+ const found = assessFiles.flatMap((f) => priceHits(fs.readFileSync(path.join(ASSESS_DIR, f), 'utf8')).map((h) => `scripts/gds/${f}:${h}`));
64
+ assert.deepEqual(found, [], `price must never gate or excuse a score (ADR 0343 D6):\n${found.join('\n')}`);
65
+ });
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // 2. behaviour — paid, zero-price, no-price and core-bundled score the same
69
+ // ---------------------------------------------------------------------------
70
+
71
+ const CASES = [
72
+ { id: 1, module_key: 'paid-mod', version: '1.0.0', price: { price_cents: 500, price_credits: 50 } },
73
+ { id: 2, module_key: 'zero-mod', version: '1.0.0', price: { price_cents: 0, price_credits: 0 } },
74
+ { id: 3, module_key: 'unpriced-mod', version: '1.0.0', price: null },
75
+ { id: 4, module_key: 'economy', version: '1.0.0', price: null, core_bundled: true },
76
+ ];
77
+
78
+ // core_265's append-only rows in memory, every case's price state alongside, and
79
+ // a refusal for any query that reads price.
80
+ function fakeDb() {
81
+ const signals = [];
82
+ const scores = [];
83
+ const sqlSeen = [];
84
+ let clock = 0;
85
+ const db = {
86
+ signals, scores, sqlSeen,
87
+ async query(sql, params = []) {
88
+ sqlSeen.push(sql);
89
+ if (PRICE_WORD.test(sql)) throw new Error(`the assessment read price: ${sql.slice(0, 120)}`);
90
+ if (/^SELECT id, module_key, version FROM store_module_versions WHERE id/.test(sql)) {
91
+ const v = CASES.find((c) => String(c.id) === String(params[0]));
92
+ return { rows: v ? [{ id: v.id, module_key: v.module_key, version: v.version }] : [] };
93
+ }
94
+ if (/SELECT DISTINCT ON \(part\)/.test(sql)) {
95
+ const cur = {};
96
+ for (const s of signals.filter((x) => String(x.version_id) === String(params[0]))) {
97
+ if (!cur[s.part] || s.t > cur[s.part].t || (s.t === cur[s.part].t && s.id > cur[s.part].id)) cur[s.part] = s;
98
+ }
99
+ return { rows: Object.values(cur) };
100
+ }
101
+ if (/FROM module_assessment_scores/.test(sql) && /^SELECT/.test(sql.trim())) {
102
+ const mine = scores.filter((x) => String(x.version_id) === String(params[0]) && x.kind === 'computed');
103
+ return { rows: mine.length ? [mine[mine.length - 1]] : [] };
104
+ }
105
+ if (/INSERT INTO module_assessment_scores/.test(sql)) {
106
+ const row = { id: scores.length + 1, version_id: params[0], kind: 'computed', overall: params[1], security_passed: params[2], is_new: params[3], formula: params[4], signal_ids: params[5].map(String) };
107
+ scores.push(row); return { rows: [row] };
108
+ }
109
+ if (/INSERT INTO module_assessment_signals/.test(sql) && /'pending'/.test(sql)) {
110
+ if (signals.some((s) => String(s.version_id) === String(params[0]) && s.part === 'tests')) return { rows: [] };
111
+ signals.push({ id: signals.length + 1, version_id: params[0], part: 'tests', outcome: 'pending', score: null, t: ++clock });
112
+ return { rows: [] };
113
+ }
114
+ throw new Error(`unexpected SQL: ${sql.slice(0, 80)}`);
115
+ },
116
+ };
117
+ db.add = (versionId, part, outcome, score = null) => {
118
+ const row = { id: signals.length + 1, version_id: versionId, part, outcome, score, t: ++clock };
119
+ signals.push(row); return row;
120
+ };
121
+ return db;
122
+ }
123
+
124
+ // The answer with the version id taken out, so cases can be compared.
125
+ const shape = (s) => ({ overall: s.overall, security_passed: s.security_passed, is_new: s.is_new, formula: s.formula });
126
+
127
+ test('composeScore takes no price: the same signals give the same score whatever is attached', () => {
128
+ const signals = {
129
+ security: { id: 1, part: 'security', outcome: 'passed', score: null },
130
+ tests: { id: 2, part: 'tests', outcome: 'scored', score: 70 },
131
+ install: { id: 3, part: 'install', outcome: 'scored', score: 90 },
132
+ };
133
+ const base = composeScore(signals);
134
+ for (const c of CASES) {
135
+ // Price riding along on the rows must change nothing.
136
+ const withPrice = Object.fromEntries(Object.entries(signals).map(([k, r]) => [k, { ...r, ...(c.price || {}), core_bundled: !!c.core_bundled }]));
137
+ assert.deepEqual(composeScore(withPrice), base, c.module_key);
138
+ }
139
+ });
140
+
141
+ for (const [label, signalsFor] of [
142
+ ['scored parts', (db, id) => { db.add(id, 'security', 'passed'); db.add(id, 'tests', 'scored', 64); db.add(id, 'reliability', 'scored', 88); }],
143
+ ['a failed gate', (db, id) => { db.add(id, 'security', 'failed'); db.add(id, 'tests', 'scored', 99); }],
144
+ ['no data yet', (db, id) => { db.add(id, 'tests', 'no_data'); }],
145
+ ]) {
146
+ test(`recompose: paid, zero-price, no-price and core-bundled score the same (${label})`, async () => {
147
+ const db = fakeDb();
148
+ const out = [];
149
+ for (const c of CASES) {
150
+ signalsFor(db, c.id);
151
+ const res = await recomposeScore(c.id, { db });
152
+ out.push([c.module_key, shape(res.score)]);
153
+ }
154
+ for (const [key, s] of out.slice(1)) assert.deepEqual(s, out[0][1], `${key} scores exactly like the paid module`);
155
+ });
156
+ }
157
+
158
+ test('assessVersion on publish runs the same steps for every case, and never reads price', async () => {
159
+ const db = fakeDb();
160
+ const steps = [];
161
+ const recordSecurity = async (id, { db: d }) => { steps.push(['security', id]); return { ok: true, signal: d.add(id, 'security', 'passed') }; };
162
+ const recordDocs = async (id, { db: d }) => { steps.push(['docs', id]); return { ok: true, signal: d.add(id, 'docs', 'scored', 50) }; };
163
+ const out = [];
164
+ for (const c of CASES) {
165
+ const res = await assessVersion(c.id, { db, recordSecurity, recordDocs });
166
+ assert.equal(res.ok, true, c.module_key);
167
+ out.push(shape(res.score));
168
+ }
169
+ for (const s of out.slice(1)) assert.deepEqual(s, out[0]);
170
+ const perCase = (id) => steps.filter(([, v]) => v === id).map(([k]) => k).join(',');
171
+ for (const c of CASES.slice(1)) assert.equal(perCase(c.id), perCase(CASES[0].id), `${c.module_key} goes through the same checks`);
172
+ assert.ok(db.sqlSeen.length > 0);
173
+ assert.ok(db.sqlSeen.every((q) => !PRICE_WORD.test(q)), 'no assessment query touched price');
174
+ });
@@ -0,0 +1,120 @@
1
+ // tests/module_store_reassess.mjs — a Metic+ person can ask for a store version's
2
+ // assessment to run again, and can never set a score by hand (task 1003796,
3
+ // ADR 0359). Drives the real POST /store/modules/:key/versions/:version/reassess
4
+ // handler off the real router (no port, no real DB — pool.js is replaced in the
5
+ // require cache, the module_store_howto.mjs harness), with the assessment queue
6
+ // stubbed so nothing runs npm or a model.
7
+ //
8
+ // Run: node tests/module_store_reassess.mjs
9
+
10
+ import { strict as assert } from 'node:assert';
11
+ import { test } from 'node:test';
12
+ import { createRequire } from 'node:module';
13
+ import fs from 'node:fs';
14
+ import path from 'node:path';
15
+ import { fileURLToPath } from 'node:url';
16
+
17
+ const require = createRequire(import.meta.url);
18
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
19
+
20
+ const state = { modules: { weather: { module_key: 'weather', title: 'Weather', author_id: 42, status: 'listed' } }, versions: [{ id: 7, module_key: 'weather', version: '1.2.0', tarball_sha256: 'x', artifact_path: 'weather/weather-1.2.0.tgz', published_at: '2026-09-30T00:00:00Z' }], sql: [] };
21
+ const query = async (sql, params = []) => {
22
+ state.sql.push(sql);
23
+ if (/FROM store_modules WHERE module_key = \$1/.test(sql)) {
24
+ const m = state.modules[params[0]]; return { rows: m ? [m] : [] };
25
+ }
26
+ if (/FROM store_module_versions WHERE module_key = \$1 AND version = \$2/.test(sql)) {
27
+ return { rows: state.versions.filter((v) => v.module_key === params[0] && v.version === params[1]) };
28
+ }
29
+ return { rows: [] };
30
+ };
31
+ const poolPath = require.resolve('../src/bongos/pool.js');
32
+ require.cache[poolPath] = { id: poolPath, filename: poolPath, loaded: true, exports: { pool: { connect: async () => ({ query, release() {} }), query } } };
33
+
34
+ const moduleAssessVersion = require('../scripts/gds/module-assess-version.js');
35
+ const queued = [];
36
+ let queueAccepts = true;
37
+ moduleAssessVersion.queueAssessment = (id) => { queued.push(id); return queueAccepts; };
38
+ const buildModulesRouter = require('../src/bongos/routes/modules.js');
39
+
40
+ const PATH = '/store/modules/:key/versions/:version/reassess';
41
+ function handlerFor(router) {
42
+ const layer = router.stack.find((l) => l.route && l.route.path === PATH && l.route.methods.post);
43
+ assert.ok(layer, `no POST ${PATH} on the router`);
44
+ const stack = layer.route.stack;
45
+ return { handle: stack[stack.length - 1].handle, depth: stack.length };
46
+ }
47
+ async function invoke(handle, { key = 'weather', version = '1.2.0', body = {} } = {}) {
48
+ const res = {
49
+ statusCode: 200, body: undefined,
50
+ json(b) { this.body = b; return this; },
51
+ status(c) { this.statusCode = c; return this; },
52
+ set() { return this; },
53
+ fail(code, opts, details) { const o = typeof opts === 'number' ? { status: opts, details } : (opts || {}); this.statusCode = o.status; this.body = { error: code, ...o }; return this; },
54
+ };
55
+ let nextErr = null;
56
+ await handle({ params: { key, version }, body, builder: { id: 3 } }, res, (err) => { nextErr = err; });
57
+ if (nextErr) throw nextErr;
58
+ return res;
59
+ }
60
+
61
+ const { handle, depth } = handlerFor(buildModulesRouter());
62
+
63
+ test('the route is gated by its own Metic+ atom, granted to metic and archon', () => {
64
+ assert.ok(depth >= 3, 'requireBuilder + requirePermission sit in front of the handler');
65
+ const src = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/modules.js'), 'utf8');
66
+ assert.match(src, /router\.post\('\/store\/modules\/:key\/versions\/:version\/reassess', auth\.requireBuilder, auth\.requirePermission\('module\.assessment\.rerun'\)/);
67
+ const { byKey } = require('../modules/government/catalog.js');
68
+ const p = byKey('module.assessment.rerun');
69
+ assert.equal(p.floor, 'metic');
70
+ assert.equal(p.system, false);
71
+ const mig = fs.readFileSync(path.join(ROOT, 'modules/government/migrations/government_021_module_assessment_rerun.sql'), 'utf8');
72
+ for (const rank of ['metic', 'archon']) assert.match(mig, new RegExp(`SELECT '${rank}', unnest\\(ARRAY\\[\\s*'module\\.assessment\\.rerun'`));
73
+ });
74
+
75
+ test('a re-check with a reason is queued and answers 202', async () => {
76
+ queued.length = 0;
77
+ const res = await invoke(handle, { body: { reason: 'the grader was down when this was published' } });
78
+ assert.equal(res.statusCode, 202);
79
+ assert.deepEqual(res.body, { ok: true, queued: true, module_key: 'weather', version: '1.2.0', version_id: 7 });
80
+ assert.deepEqual(queued, [7]);
81
+ });
82
+
83
+ test('no reason, no re-check', async () => {
84
+ queued.length = 0;
85
+ for (const body of [{}, { reason: '' }, { reason: ' ' }]) {
86
+ const res = await invoke(handle, { body });
87
+ assert.equal(res.statusCode, 400);
88
+ assert.equal(res.body.error, 'reason_required');
89
+ }
90
+ assert.equal(queued.length, 0);
91
+ });
92
+
93
+ test('a score can never be set by hand: a score in the body is refused, and nothing writes a score row', async () => {
94
+ queued.length = 0;
95
+ state.sql.length = 0;
96
+ const res = await invoke(handle, { body: { reason: 'r', overall: 100 } });
97
+ assert.equal(res.statusCode, 400, 'only `reason` is accepted');
98
+ assert.equal(queued.length, 0);
99
+ const ok = await invoke(handle, { body: { reason: 'r' } });
100
+ assert.equal(ok.statusCode, 202);
101
+ assert.deepEqual(queued, [7], 'it only queues the automatic assessment');
102
+ assert.ok(state.sql.every((q) => !/module_assessment_scores|module_assessment_signals/.test(q)), 'the route itself writes no score or signal');
103
+ const src = fs.readFileSync(path.join(ROOT, 'src/bongos/routes/modules.js'), 'utf8');
104
+ assert.doesNotMatch(src, /module_assessment_scores/, 'no store route writes a score row');
105
+ });
106
+
107
+ test('a bad key or version, an unknown version, and a full queue are refused', async () => {
108
+ queued.length = 0;
109
+ assert.equal((await invoke(handle, { key: 'Bad_Key', body: { reason: 'r' } })).statusCode, 400);
110
+ assert.equal((await invoke(handle, { version: '1.2', body: { reason: 'r' } })).statusCode, 400);
111
+ const missing = await invoke(handle, { version: '9.9.9', body: { reason: 'r' } });
112
+ assert.equal(missing.statusCode, 404);
113
+ assert.equal(missing.body.error, 'version_not_found');
114
+ assert.equal(queued.length, 0);
115
+ queueAccepts = false;
116
+ const full = await invoke(handle, { body: { reason: 'r' } });
117
+ queueAccepts = true;
118
+ assert.equal(full.statusCode, 503);
119
+ assert.equal(full.body.error, 'assessment_queue_full');
120
+ });