@bongos/core 1.19.733 → 1.19.735

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/.bongos-core.json +61 -36
  2. package/config/branding.neutral.json +3 -2
  3. package/docs/adr/0161-publish-on-merge.md +3 -0
  4. package/docs/adr/0282-a-module-that-spends-for-its-callers-declares-a-payer-and-the-core-refuses-it-without-one.md +141 -0
  5. package/docs/adr/README.md +1 -0
  6. package/docs/module-api-changelog.md +4 -0
  7. package/docs/modules-contract.md +22 -0
  8. package/modules/agents/lib/spend-ceiling.js +170 -0
  9. package/modules/agents/migrations/agents_002_payer.sql +87 -0
  10. package/modules/agents/module.json +2 -1
  11. package/modules/agents/routes/agents.js +62 -1
  12. package/modules/agents/spawn.js +21 -3
  13. package/modules/provisioning/provisioning.js +17 -1
  14. package/modules/provisioning/tests/provisioning.mjs +8 -8
  15. package/modules/ui-design/kit/serve.js +1 -0
  16. package/package-lock.json +2 -2
  17. package/package.json +1 -1
  18. package/scripts/gds/bump-version.js +14 -5
  19. package/scripts/gds/exec-path-guard.js +3 -3
  20. package/scripts/gds/package-core.js +31 -10
  21. package/src/branding.js +13 -0
  22. package/src/module-api.js +1 -1
  23. package/src/module-loader/loader.js +6 -0
  24. package/src/module-loader/manifest-schema.js +30 -0
  25. package/src/modules.js +107 -2
  26. package/tests/agents_spend_guard.mjs +272 -0
  27. package/tests/agents_write_routes.mjs +51 -1
  28. package/tests/bump_version.mjs +78 -1
  29. package/tests/currency_label.mjs +9 -4
  30. package/tests/leak_scan_fail_closed.mjs +108 -0
  31. package/tests/provision_settings_apply.mjs +11 -5
  32. package/tests/provisioning_settings.mjs +11 -7
  33. package/tests/provisioning_settings_apply.mjs +4 -4
  34. package/tests/provisioning_settings_env.mjs +5 -1
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.733",
6
- "core_contract": "1.19.733",
7
- "source_commit": "1cf8536fa296cc05a9c0fc2a1cff4667ab628843",
5
+ "core_version": "1.19.735",
6
+ "core_contract": "1.19.735",
7
+ "source_commit": "6688485db7975049f67d21b58eac72ab6a93875e",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-09-13T15:46:24.724Z",
9
+ "built_at": "2026-09-13T16:18:04.615Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 477,
12
+ "docs_redacted": 478,
13
13
  "agent_docs_stubbed": 24,
14
- "functional_verbatim": 2104,
14
+ "functional_verbatim": 2108,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2605,
20
- "tree_sha256": "df29652d8de78cc03652254e561648215caacb4e49b7c6a73a7eb502d9c2e049",
19
+ "file_count": 2610,
20
+ "tree_sha256": "4b125203b2c2f21ff8db51bae409a2e72e3e7099c38bc1e17a85f48573b17831",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/ask-for-help/SKILL.md",
@@ -402,7 +402,7 @@
402
402
  {
403
403
  "path": "config/branding.neutral.json",
404
404
  "mode": "0000644",
405
- "sha256": "c27b0de4bebf151865b8bd84c0ce937b040122f9e5b95c20c227296c72897c22"
405
+ "sha256": "b56024a95ccf9e236bf8248b7d1f96c4510e3feac55c2885497ecd8f841bdb7b"
406
406
  },
407
407
  {
408
408
  "path": "config/design-tokens.neutral.json",
@@ -1292,7 +1292,7 @@
1292
1292
  {
1293
1293
  "path": "docs/adr/0161-publish-on-merge.md",
1294
1294
  "mode": "0000644",
1295
- "sha256": "29c93f846b172fd46469ef91da385594bf3caca1c20bc5bcbb877baa2dbddc21"
1295
+ "sha256": "c96d62b3b9eb4945cd7e55ccc361c230f5c5ca559d187d6156c330e0f9f30e49"
1296
1296
  },
1297
1297
  {
1298
1298
  "path": "docs/adr/0162-review-gates-retired-rank-consistent-ci-only.md",
@@ -1914,10 +1914,15 @@
1914
1914
  "mode": "0000644",
1915
1915
  "sha256": "2697c7cdca6d518d7ca7d84cf354ca92f264bf641e0b178bfa038f83878d5963"
1916
1916
  },
1917
+ {
1918
+ "path": "docs/adr/0282-a-module-that-spends-for-its-callers-declares-a-payer-and-the-core-refuses-it-without-one.md",
1919
+ "mode": "0000644",
1920
+ "sha256": "1cd5710392b4226c27af4ce4f661ee6228f80ce16e9f9adb1fb43c3ebb9b5d24"
1921
+ },
1917
1922
  {
1918
1923
  "path": "docs/adr/README.md",
1919
1924
  "mode": "0000644",
1920
- "sha256": "d9ed374094550369c39db924d759880415e1296fbd07f38aae289ee26f7d70f4"
1925
+ "sha256": "44bb02316957a38c19f8187aaeef3e3828202348b53e3fc45b6592022a9021a0"
1921
1926
  },
1922
1927
  {
1923
1928
  "path": "docs/api-reference.md",
@@ -2482,12 +2487,12 @@
2482
2487
  {
2483
2488
  "path": "docs/module-api-changelog.md",
2484
2489
  "mode": "0000644",
2485
- "sha256": "05bbf0c615af2c986b0bad8963870a0420b3f0dee092bbbcbcaf9794c1f607a1"
2490
+ "sha256": "3d409c55101525a3f86cf54a60169bc3d82c6926118b9234d98487c44128d7b7"
2486
2491
  },
2487
2492
  {
2488
2493
  "path": "docs/modules-contract.md",
2489
2494
  "mode": "0000644",
2490
- "sha256": "a7be53f787c9225eaf7f02ba6c20d6ad284618c207ee58a61037e6bf291c0914"
2495
+ "sha256": "df10344b033f489baceb3c54a27ee3088da7d18cd05bb68abf12a11defe23bae"
2491
2496
  },
2492
2497
  {
2493
2498
  "path": "docs/onboarding/browser-terminal-guide.md",
@@ -3574,6 +3579,11 @@
3574
3579
  "mode": "0000644",
3575
3580
  "sha256": "b9e298e98c6fc3b29854728e33963cef43d5b289064a24a2c639a14d8a3a1b92"
3576
3581
  },
3582
+ {
3583
+ "path": "modules/agents/lib/spend-ceiling.js",
3584
+ "mode": "0000644",
3585
+ "sha256": "97bfcea1cd580df4765bff92a1b26f56bfe467501bfd1c566ff7df9ec4ac746b"
3586
+ },
3577
3587
  {
3578
3588
  "path": "modules/agents/lib/validate.js",
3579
3589
  "mode": "0000644",
@@ -3584,20 +3594,25 @@
3584
3594
  "mode": "0000644",
3585
3595
  "sha256": "a35cb06d31f05acdc9bee40a977346d258acda79174944361710f3733dd69a2f"
3586
3596
  },
3597
+ {
3598
+ "path": "modules/agents/migrations/agents_002_payer.sql",
3599
+ "mode": "0000644",
3600
+ "sha256": "b576e8518bc8c9af9d5a1a86ace812f292fc2df1225cdfce8578d11703350391"
3601
+ },
3587
3602
  {
3588
3603
  "path": "modules/agents/module.json",
3589
3604
  "mode": "0000644",
3590
- "sha256": "af487dc7d134b45f2c1616b1a06cb017106529510e5ebc9d15d7dbb9f72d5fe5"
3605
+ "sha256": "bdfe0b71f372806fd600cc50ac87c9486ca79f09f69be4e95b5deacd54779d84"
3591
3606
  },
3592
3607
  {
3593
3608
  "path": "modules/agents/routes/agents.js",
3594
3609
  "mode": "0000644",
3595
- "sha256": "261914d81f3b6cfb9bdb73dbcddf97924577d9ffd4ec61e9ca28d9ce623dd404"
3610
+ "sha256": "8bfdaf26077e1bc005b6b2b764e0912ac7e5becb373a8a4371cb3cd6fa7c163f"
3596
3611
  },
3597
3612
  {
3598
3613
  "path": "modules/agents/spawn.js",
3599
3614
  "mode": "0000644",
3600
- "sha256": "6aa77a39408386f5a08a62204a8cf8436420c61a8a9d7d8253c1b18a2ad88847"
3615
+ "sha256": "6d656333f03de1c7e1f798101dc7fcc6ddcf595b9dcb0001a110682f7e6e91f8"
3601
3616
  },
3602
3617
  {
3603
3618
  "path": "modules/autonomy/CLAUDE.md",
@@ -6232,7 +6247,7 @@
6232
6247
  {
6233
6248
  "path": "modules/provisioning/provisioning.js",
6234
6249
  "mode": "0000644",
6235
- "sha256": "29e41ecaa47288135a550ebf37870b61d6108c1cac12ab6f9a7869d2dc430d27"
6250
+ "sha256": "18cb9676bd343e49873e2a3cface6d491aa59a840215531184b0d1863f3de271"
6236
6251
  },
6237
6252
  {
6238
6253
  "path": "modules/provisioning/rate-limit.js",
@@ -6272,7 +6287,7 @@
6272
6287
  {
6273
6288
  "path": "modules/provisioning/tests/provisioning.mjs",
6274
6289
  "mode": "0000644",
6275
- "sha256": "c4709d54ba5080b45dc604f6bbe542806a034a5db4e6718a0b8ed88a62466c2a"
6290
+ "sha256": "904b1a6f8c704e606e7f93937465b14840857c287d31abda9d4772c528501f92"
6276
6291
  },
6277
6292
  {
6278
6293
  "path": "modules/provisioning/ui/.gitkeep",
@@ -7207,7 +7222,7 @@
7207
7222
  {
7208
7223
  "path": "modules/ui-design/kit/serve.js",
7209
7224
  "mode": "0000644",
7210
- "sha256": "baf97a7441e12c6d07a61f2118d3b7a75deec1d83cbd135307a264cf447f9c5b"
7225
+ "sha256": "1f10b36a4e3c0b214451382b548f06709dbac34163bfb1e9e67c72b6b3992acc"
7211
7226
  },
7212
7227
  {
7213
7228
  "path": "modules/ui-design/kit/tells.js",
@@ -7517,12 +7532,12 @@
7517
7532
  {
7518
7533
  "path": "package-lock.json",
7519
7534
  "mode": "0000644",
7520
- "sha256": "d991c248b006d2066c11468c5749e58951463ecab027b50991ff4ca28279f294"
7535
+ "sha256": "210fb895f45fb7015cd76403ab23dabddbbc53d4fe45c6f2fd3ae4c313daeb18"
7521
7536
  },
7522
7537
  {
7523
7538
  "path": "package.json",
7524
7539
  "mode": "0000644",
7525
- "sha256": "ed94a0e7dc7bdd1e48ebd1f9521b9aeb93a8e0289e9928393cf49461fd46dafa"
7540
+ "sha256": "6fe0a516197ae1a54ee1d809bc4faa941ae9ff9e3b681adc4184953dd756d518"
7526
7541
  },
7527
7542
  {
7528
7543
  "path": "public-docs/index.html",
@@ -7752,7 +7767,7 @@
7752
7767
  {
7753
7768
  "path": "scripts/gds/bump-version.js",
7754
7769
  "mode": "0000644",
7755
- "sha256": "05866e29dd673180fd35fb6ae314b2b335670ff4f37049d737a8ad7c9d3c4acc"
7770
+ "sha256": "18cdfd285c4037da0f60d53bf8736aa6e6185022c79533ed81a4c9aa9a370392"
7756
7771
  },
7757
7772
  {
7758
7773
  "path": "scripts/gds/capture.js",
@@ -7952,7 +7967,7 @@
7952
7967
  {
7953
7968
  "path": "scripts/gds/exec-path-guard.js",
7954
7969
  "mode": "0000644",
7955
- "sha256": "e568575a922de8c4538f11fa0e67ca0a97ab75317ff59b6aea27e6020a87313c"
7970
+ "sha256": "6250c525e105540d33006b9c7e0335b9045186f7a73b714b359322a2e1ac3394"
7956
7971
  },
7957
7972
  {
7958
7973
  "path": "scripts/gds/feedback-latest.js",
@@ -8312,7 +8327,7 @@
8312
8327
  {
8313
8328
  "path": "scripts/gds/package-core.js",
8314
8329
  "mode": "0000644",
8315
- "sha256": "e927900fc296f5cf840425978e77232cd5cdc75de825144255050c8a5ba2f538"
8330
+ "sha256": "225bf45e1e0b297e25b140263a6bd2b616d63a9ab2ee0517edfcf71630cef5aa"
8316
8331
  },
8317
8332
  {
8318
8333
  "path": "scripts/gds/paste-token.js",
@@ -9302,7 +9317,7 @@
9302
9317
  {
9303
9318
  "path": "src/branding.js",
9304
9319
  "mode": "0000644",
9305
- "sha256": "5d457edbdade4ae1778243cb2bed90ce84cda95126d855fd5d647e80cd982c96"
9320
+ "sha256": "e997ae10b6e3813d554b05e2eefe16bd65d05bac697efcdb3d4673961c8b18f2"
9306
9321
  },
9307
9322
  {
9308
9323
  "path": "src/build-info.js",
@@ -9322,7 +9337,7 @@
9322
9337
  {
9323
9338
  "path": "src/module-api.js",
9324
9339
  "mode": "0000644",
9325
- "sha256": "83f3cb9f3f8992a2b8accb6b66190e82095bfcbe1ca2fb9bb40a471a194b380a"
9340
+ "sha256": "1bb00c54f48017dd7f83cf8c8d51e8c0acc0f80cb939a5e304a9e70c3fe2a836"
9326
9341
  },
9327
9342
  {
9328
9343
  "path": "src/module-loader/catalog.js",
@@ -9332,12 +9347,12 @@
9332
9347
  {
9333
9348
  "path": "src/module-loader/loader.js",
9334
9349
  "mode": "0000644",
9335
- "sha256": "5e417760b49f19d48af3dbd72a90edb53b6a9710ce54ca33052f4fbe86cb9229"
9350
+ "sha256": "e888dbdf466ac906a7a29859c9851a105a963a52142bdbd998e8202e5b822670"
9336
9351
  },
9337
9352
  {
9338
9353
  "path": "src/module-loader/manifest-schema.js",
9339
9354
  "mode": "0000644",
9340
- "sha256": "fd2ceac4ee3c974f68fa2f28503e9a5655a7c67b5ad2ad08fb377f5949101256"
9355
+ "sha256": "79545af1d76bcd937183c36a821fe15702dd28f94b95583a17fb2fa9e0495c84"
9341
9356
  },
9342
9357
  {
9343
9358
  "path": "src/module-loader/provenance.js",
@@ -9357,7 +9372,7 @@
9357
9372
  {
9358
9373
  "path": "src/modules.js",
9359
9374
  "mode": "0000644",
9360
- "sha256": "c487fb1516380cd7f1de2f57feb550e487172deed247248c15f35bbbf4f48472"
9375
+ "sha256": "85557da8a52b5050640bc44d8962cdc19db42928d8d2d373f191f1d542df0cce"
9361
9376
  },
9362
9377
  {
9363
9378
  "path": "src/platform-server.js",
@@ -9449,6 +9464,11 @@
9449
9464
  "mode": "0000644",
9450
9465
  "sha256": "8cc261471a36e3c447df57b46082997b6a50da3c8ff4b214787ae55ab27cb9ef"
9451
9466
  },
9467
+ {
9468
+ "path": "tests/agents_spend_guard.mjs",
9469
+ "mode": "0000644",
9470
+ "sha256": "8bed5bc6e737f17038b3fba4f5c73a0478b0dd581b506e5a0bbd31754ff8a758"
9471
+ },
9452
9472
  {
9453
9473
  "path": "tests/agents_sync.mjs",
9454
9474
  "mode": "0000644",
@@ -9462,7 +9482,7 @@
9462
9482
  {
9463
9483
  "path": "tests/agents_write_routes.mjs",
9464
9484
  "mode": "0000644",
9465
- "sha256": "5c413774b0757553ed3d55a9f8d3555c607207cda4065d443412a34ad4d601dc"
9485
+ "sha256": "51274ba95e93269568b1df338fbadd9345bebbe8e09484d821682dd78a72b120"
9466
9486
  },
9467
9487
  {
9468
9488
  "path": "tests/api_cli_flags.mjs",
@@ -9872,7 +9892,7 @@
9872
9892
  {
9873
9893
  "path": "tests/bump_version.mjs",
9874
9894
  "mode": "0000644",
9875
- "sha256": "0d96f5dd67d786816a4373073ec44341e9ca7dfa1aff092518da969c74b65c0d"
9895
+ "sha256": "1a96e58f266d58dfc0f5fc9381df4b3348171b5cfe81e91161d884b34208ee6c"
9876
9896
  },
9877
9897
  {
9878
9898
  "path": "tests/canonical_profile_url.mjs",
@@ -10167,7 +10187,7 @@
10167
10187
  {
10168
10188
  "path": "tests/currency_label.mjs",
10169
10189
  "mode": "0000644",
10170
- "sha256": "020496a0b3d9f01e75e810c5b39a669155884d1bbfbd8fd7f200dbf35076bae4"
10190
+ "sha256": "45c8919daeacc77a8a08ce72768a11a7ca47ad5bd65a9e815d5ec823255f743e"
10171
10191
  },
10172
10192
  {
10173
10193
  "path": "tests/cutover_codemod.mjs",
@@ -11354,6 +11374,11 @@
11354
11374
  "mode": "0000644",
11355
11375
  "sha256": "f6097301300cdb559d3620a5c602b763b70f1acc14816a7e70e1cdb61bb42d95"
11356
11376
  },
11377
+ {
11378
+ "path": "tests/leak_scan_fail_closed.mjs",
11379
+ "mode": "0000644",
11380
+ "sha256": "e5968a1df544680db05c66eeec803ebdbbbe98932bfec31a5287617c6dc01466"
11381
+ },
11357
11382
  {
11358
11383
  "path": "tests/lib_sh_resolution.mjs",
11359
11384
  "mode": "0000644",
@@ -11957,7 +11982,7 @@
11957
11982
  {
11958
11983
  "path": "tests/provision_settings_apply.mjs",
11959
11984
  "mode": "0000644",
11960
- "sha256": "0038fdf483254b0145b5b181a8b44d3f9c31305d24dc7c5178f4cf3ab060fad8"
11985
+ "sha256": "6de5d3cd1e0e94e9246eca9602b946f45cdd89dca1ab2b7ab32fa0a7c1b0627d"
11961
11986
  },
11962
11987
  {
11963
11988
  "path": "tests/provisioning_callback_page.mjs",
@@ -12012,17 +12037,17 @@
12012
12037
  {
12013
12038
  "path": "tests/provisioning_settings.mjs",
12014
12039
  "mode": "0000644",
12015
- "sha256": "b9004dd8a8c550b8d841e07e61092ccff477406aef31c2e4db4f6046d86b9e65"
12040
+ "sha256": "f95648577be4a9333e1009bc49bfa480e2ca65400a1978060b534c6e4d86e2f7"
12016
12041
  },
12017
12042
  {
12018
12043
  "path": "tests/provisioning_settings_apply.mjs",
12019
12044
  "mode": "0000644",
12020
- "sha256": "607b84fdafaf55e337870071b5c1d843933b910e937417ba9c93710edcdb6e5c"
12045
+ "sha256": "d40e0a6bbeccc54943149281597727a7fa61b737894e08959449cc7c76ccf7fb"
12021
12046
  },
12022
12047
  {
12023
12048
  "path": "tests/provisioning_settings_env.mjs",
12024
12049
  "mode": "0000644",
12025
- "sha256": "dcfcfbe76eb50a17306fb3b4c3ddb1aefbd1b3e12fd3a1803b06afe3c4c5ad7d"
12050
+ "sha256": "b1404ee72a14b72940f46746f532b5b2abc645cdef6b65e4d93ed85f7657444b"
12026
12051
  },
12027
12052
  {
12028
12053
  "path": "tests/provisioning_slug_available.mjs",
@@ -27,13 +27,14 @@
27
27
  "idp": false
28
28
  },
29
29
  "project": {
30
- "_note": "Per-project policy the PLATFORM decides and this instance OBEYS (task 1003139, goal 1000058). Stored on the platform's provisioning_instances.settings; written here as <PREFIX>_PLATFORM_VISIBILITY / <PREFIX>_JOINABILITY / <PREFIX>_VISIBILITY by the provisioning runner, which re-writes web.env and restarts the service when the owner changes one. The defaults are TODAY's behavior, so an instance that never receives a push — a self-hosted one, or one provisioned before this channel — keeps behaving exactly as it does now. platformVisibility (who may READ this instance's own pages): public | gated. joinability (HOW someone becomes a member): open | apply | invite_only. visibility (the join door, ADR 0182 D1 — how this project stands on the platform's map): public | private | stealth; it does NOT decide whether the project is on the map, publishability does. joinGrant (what a PUBLIC join GRANTS): view | apply | full — the ceiling joinability may only narrow (ADR 0182 D6 / ADR 0245); `full` narrows nothing, which is why it is the default. artistGate (ADR 0241 — how hard this project's artist reviews bite): off | advisory | strict, written as <PREFIX>_ARTIST_GATE. off files no artist review tasks at all; advisory files them and blocks nothing; strict files them and lets an unresolved one hold the DEPLOY — this instance's core pin may not move while one is open. It never holds a builder's ship, which is what keeps it compatible with ADR 0162. It is the one key here whose default is NOT today's behaviour: `strict` is the owner's ruling (ADR 0241 §4), safe as a default because the worst case is a pin that waits, never a stranded builder, and the project owner holds the release valve. artistGateSince (<PREFIX>_ARTIST_GATE_SINCE) is its companion stamp — the moment the gate was last RAISED to strict, so the hold is not retroactive (ADR 0241 §5); empty means no cutoff, which is the right answer for a project that has been strict since it stood up.",
30
+ "_note": "Per-project policy the PLATFORM decides and this instance OBEYS (task 1003139, goal 1000058). Stored on the platform's provisioning_instances.settings; written here as <PREFIX>_PLATFORM_VISIBILITY / <PREFIX>_JOINABILITY / <PREFIX>_VISIBILITY by the provisioning runner, which re-writes web.env and restarts the service when the owner changes one. The defaults are TODAY's behavior, so an instance that never receives a push — a self-hosted one, or one provisioned before this channel — keeps behaving exactly as it does now. platformVisibility (who may READ this instance's own pages): public | gated. joinability (HOW someone becomes a member): open | apply | invite_only. visibility (the join door, ADR 0182 D1 — how this project stands on the platform's map): public | private | stealth; it does NOT decide whether the project is on the map, publishability does. joinGrant (what a PUBLIC join GRANTS): view | apply | full — the ceiling joinability may only narrow (ADR 0182 D6 / ADR 0245); `full` narrows nothing, which is why it is the default. artistGate (ADR 0241 — how hard this project's artist reviews bite): off | advisory | strict, written as <PREFIX>_ARTIST_GATE. off files no artist review tasks at all; advisory files them and blocks nothing; strict files them and lets an unresolved one hold the DEPLOY — this instance's core pin may not move while one is open. It never holds a builder's ship, which is what keeps it compatible with ADR 0162. It is the one key here whose default is NOT today's behaviour: `strict` is the owner's ruling (ADR 0241 §4), safe as a default because the worst case is a pin that waits, never a stranded builder, and the project owner holds the release valve. spendPayer (<PREFIX>_SPEND_PAYER, task 1003884) is WHO PAYS when a module spends money on behalf of whoever called it: none | project | builder. It is the one key here that can refuse a module outright — src/modules.js will not enable a module whose manifest declares `spend.requiresPayer` while this project's join door is `open` and no payer is named, because an account created seconds ago could otherwise spend the instance's money with nobody to bill. The refusal happens at config load, so the module's routes never mount; the warning names the two values that lift it. Its default IS today's behaviour (`none`), which costs nothing because the only module declaring metered spend ships default:false. artistGateSince (<PREFIX>_ARTIST_GATE_SINCE) is its companion stamp — the moment the gate was last RAISED to strict, so the hold is not retroactive (ADR 0241 §5); empty means no cutoff, which is the right answer for a project that has been strict since it stood up.",
31
31
  "platformVisibility": "public",
32
32
  "joinability": "apply",
33
33
  "visibility": "public",
34
34
  "joinGrant": "full",
35
35
  "artistGate": "strict",
36
- "artistGateSince": ""
36
+ "artistGateSince": "",
37
+ "spendPayer": "none"
37
38
  },
38
39
  "repo": {
39
40
  "owner": "",
@@ -24,6 +24,9 @@ The consumption side (ADR 0136's subscription) was never the problem; supply was
24
24
  2. **CI bumps PATCH only.** When a merge lands without a human bump, `scripts/gds/bump-version.js` bumps the patch and writes the changelog line (shape-preserving; pinned by `tests/bump_version.mjs`). A **minor or major stays a deliberate in-task human edit** — it is a statement about the doorway surface, and it decides what patch-channel subscribers receive unattended (ADR 0136). CI is not allowed to make that call.
25
25
  3. **Token custody reverses, knowingly.** A standing npm **write** Automation token (`NPM_TOKEN`) and a fine-grained **contents:write** PAT (`RELEASE_PUSH_TOKEN`, for the bump commit — the default `GITHUB_TOKEN` cannot push past the required `unit` check) live as GitHub Actions secrets. This is exactly the surface 0137 declined; the owner accepted it with the incidents as evidence. **Superseded for the npm half at task 1003202 (2026-08-25):** the write token is gone — the lane publishes via OIDC trusted publishing (ADR 0180), so no long-lived npm credential exists to steal. `RELEASE_PUSH_TOKEN` remains, as the bump commit still needs contents:write. Mitigations: the lane is **default-inert** behind the repo variable `PUBLISH_ON_MERGE=1` (an owner kill-switch symmetrical to `gating.js`), packages publish `restricted`, and the no-leak gate structurally cannot upload a leaking artifact.
26
26
  4. **Idempotent by version-compare.** The bump commit re-triggers `unit` → `publish`, which then finds HEAD's version already on npm and no-ops. npm's duplicate-version rejection is the backstop; concurrent merges queue behind a no-cancel concurrency group.
27
+ 5. **The bump commit is pushed only after the tarball is on the registry** (amended at task 1003914, 2026-09-13). The bump is *committed* before `package-core` runs — it stamps the version found at the ref — but the **push is the last step**, after `npm publish` returns. Originally it ran first, which made a pack or publish failure permanently lossy: `main` declared a version the registry never had, and the cascade terminator (point 4) then no-opped every following run, so only a fresh work merge could move on — bumping *past* the hole. Versions 1.19.716–723 were minted and lost exactly that way during a three-hour leak-gate fault (task 1003873). Pushing last makes the failure discard a purely local commit, and the next run retries the **same** version.
28
+
29
+ The inverse failure — published, push then rejected because `main` moved — is closed by promoting point 4's backstop into a recovery path: the publish step treats *"this version is already on the registry"* as **success** and falls through to the record step, committing the release line `main` was missing. The tolerance is deliberately narrow (only the publish-over-existing-version refusal; auth and transport failures still fail the run), and the rejection is **not** retried with `git rebase` — the tarball was built from the pre-rebase tree, so rebasing would leave `source_commit` in the artifact manifest naming a SHA that is not on `main`. A loud, rare, self-healing failure beats a quiet provenance lie.
27
30
 
28
31
  ## Consequences
29
32
 
@@ -0,0 +1,141 @@
1
+ # 0282 — A module that spends money for its callers declares so, and the core refuses to enable it where anyone can sign up and nobody is on the hook
2
+
3
+ - **Status:** Accepted
4
+ - **Date:** 2026-09-13
5
+ - **Tasks:** [#1003884](https://cloudbongos.com/builders#/task/1003884) (owner decision, 2026-09-13: the agents module must not be enabled on a publicly-enrollable instance until agent spend is attributed to a payer and paid)
6
+ - **Supersedes in practice:** [#1001393](https://cloudbongos.com/builders#/task/1001393) (*Autonomy groundwork: server-side spend ceiling that works off-droplet*, abandoned with goal 1000028) — its finding outlived its goal, and §4 below is the answer it asked for
7
+ - **Related:** 0083 (modules declare what they are; the core never special-cases one by name), 0016 (authority is resolved server-side, never from a committed file), 0247 / 0182 D6 (the composed join door), 0241 (the precedent for a per-project policy that can hold something)
8
+
9
+ ## Context
10
+
11
+ `modules/agents` fires a subagent on demand. One fire costs roughly $0.90, takes 60–120 seconds,
12
+ and `POST /agents/:name/invoke` answers **202** — the caller does not even wait for it. The route
13
+ is gated at `requireBuilder` and nothing else, deliberately: which agents exist at all is an
14
+ operator decision, and a v1 agent is hard-read-only by plumbing.
15
+
16
+ That reasoning bounds **capability**. It does not bound **spend**, and the module says so in its
17
+ own code. `lib/fire-budget.js` opens by declaring what it is not:
18
+
19
+ > A CEILING, NOT A BUDGET. This bounds the blast radius of a loop; it does not decide what agents
20
+ > may cost. […] Do not grow this into a budget — a ceiling that silently became the accounting
21
+ > would be a second, wrong answer to "what did agents cost".
22
+
23
+ It then defers the real cap to `agents_runs.cost_usd`. That cap did not exist.
24
+
25
+ The exposure is the composition of three separate facts, each reasonable alone:
26
+
27
+ 1. a fire is expensive and asynchronous;
28
+ 2. the invoke route admits any **any-builder**;
29
+ 3. with the join door `open`, any GitHub account **is** an any-builder seconds after sign-up.
30
+
31
+ `modules.enabled.agents` is `false` on cloudbongos.com (confirmed live 2026-09-13), so nothing was
32
+ leaking. This ADR is what keeps that true on every instance, including ones nobody is watching.
33
+
34
+ ## Decision
35
+
36
+ ### 1. The module declares metered spend; the core never names the module
37
+
38
+ `module.json` gains an optional block:
39
+
40
+ ```json
41
+ "spend": { "requiresPayer": true }
42
+ ```
43
+
44
+ It is the module saying *my surfaces can spend real money on behalf of whoever calls them*.
45
+
46
+ The declaration has to live there rather than in the resolver because fitness Check 10 forbids the
47
+ kernel from naming a discovered module key — `src/modules.js` may never write `if (key ===
48
+ 'agents')`. That constraint is not an obstacle here, it is the better design: the guard is true of
49
+ **any** module that says this about itself, so the next metered module inherits it by declaring
50
+ one line rather than by someone remembering to widen a list.
51
+
52
+ The key set is closed, for the reason `contributes` and `maintenance` have closed key sets: a
53
+ misspelled `requirespayer` would silently declare **no** spend, and that is the direction that
54
+ costs money.
55
+
56
+ ### 2. The refusal is at config load, and it is a wall rather than a precedence level
57
+
58
+ `src/modules.js` refuses to enable such a module when the project's **composed join door** is
59
+ `open` (ADR 0247 — the door, never the `joinability` knob alone) **and** no payer is configured.
60
+ The module simply does not resolve on; its routes never mount and its migrations never run.
61
+
62
+ *Why not at first invoke.* A per-request check would let the module mount, advertise its routes,
63
+ run its migrations and read as enabled everywhere the UI reads `modules.enabled` — and then refuse
64
+ one caller at a time. That is a broken feature, not a closed door.
65
+
66
+ *Why it outranks env.* `<PREFIX>_MODULE_<KEY>` is the highest-precedence way to turn a module on
67
+ and exactly the knob a hurried deploy reaches for. The refusal is applied **after** config, env and
68
+ default alike. A wall that env could outrank would be a suggestion.
69
+
70
+ *Why an unreadable posture refuses.* Everywhere else in that file an unreadable pack falls back to
71
+ the value that changes nothing. Here "I could not tell whether strangers can sign up" has to mean
72
+ "assume they can". The cost of being wrong is one module resolving off — and since every module
73
+ defaults to off, an instance that never enabled one notices nothing.
74
+
75
+ ### 3. Who pays is a per-project setting, and a run records the answer it fired under
76
+
77
+ `spend_payer` joins `SETTINGS_VOCAB` — `none` | `project` | `builder` — and rides the same env
78
+ transport as its neighbours (ADR 0241's precedent), because the guard reads it before the first
79
+ require.
80
+
81
+ `agents_runs` gains `payer_kind` + `payer_builder_id` (`agents_002_payer.sql`). **Stored, not
82
+ derived.** The payer is derivable today from the setting plus `requested_by_builder_id`, and that
83
+ is the trap: the setting is a live knob, so deriving would make last month's bill change when an
84
+ owner flips it. A ledger whose past moves is not a ledger — the same reason the row stores the
85
+ concrete `model` rather than the tier that resolved it.
86
+
87
+ `none` is **not** a third kind of payer. A run always has a real one, and `none` attributes to the
88
+ project, because that is who the money actually came from. `none` does its work at config load
89
+ instead. So on a **closed-door** instance the module keeps working exactly as it does today and the
90
+ project's own ceiling bounds it; `none` is only ever fatal where it is also dangerous.
91
+
92
+ An **event** fire has no caller, so `builder` cannot apply to one — nobody asked for it and there
93
+ is nobody to bill. It falls to the project, never to "no payer", which would otherwise make the
94
+ event path the single unbounded route.
95
+
96
+ ### 4. The ceiling is a second wall, and it fails closed — the 1001393 finding, honoured
97
+
98
+ `lib/spend-ceiling.js` sums `agents_runs.cost_usd` for the payer over the calendar month and
99
+ refuses at the cap. It is deliberately **not** `fire-budget.js` and must never be merged into it:
100
+
101
+ | | bounds | reads |
102
+ |---|---|---|
103
+ | `fire-budget` | a runaway loop, in real time | its own sliding window, per **builder** |
104
+ | `spend-ceiling` | the **bill** | `agents_runs.cost_usd`, per **payer** |
105
+
106
+ A rate ceiling cannot bound a bill (ten fires an hour is ~$650/month/builder) and a spend ceiling
107
+ cannot bound a loop (it reads spend that has already landed). The ceiling records **nothing** — the
108
+ ledger row is the tally, and a second tally is precisely the disagreeing answer `fire-budget`'s
109
+ header refuses to become.
110
+
111
+ Task 1001393 found the autonomy spend-brake **structurally blind**: it read month-to-date spend
112
+ from a database unreachable from where the spender ran, so the precheck **failed open** and the
113
+ brake never tripped once. A guard that answers "I don't know" with "go ahead" is not a guard. So
114
+ this one refuses on every unknown: no cap configured, a non-positive or non-finite cap, no payer, a
115
+ ledger read that threw, and a sum that is absent or unparseable. Writing that last case down is not
116
+ paranoia — the first draft admitted the fire on a null sum, because `Number(null)` is `0`, and a
117
+ test written for exactly this clause is what caught it.
118
+
119
+ The refusal is **402**, distinct from `fire-budget`'s 429: "this month's money is gone" and "too
120
+ fast, try later" are different operator problems, and one status for both hides which.
121
+
122
+ **The overshoot is stated, not hidden.** A fire is checked before it runs and its cost lands after
123
+ it finishes, so in-flight fires are invisible to the read. Overshoot is bounded by concurrent fires
124
+ × ~$0.90 — and what bounds the concurrent count is `fire-budget`. Each wall's blind spot is the
125
+ other's window, which is the second reason both exist.
126
+
127
+ ## Consequences
128
+
129
+ - The agents module can be enabled on a closed-door instance exactly as before, now with an
130
+ attributed ledger and a $25/month default cap (`BONGOS_AGENTS_SPEND_CAP_USD`).
131
+ - Enabling it on an open-door instance requires one deliberate act: the owner names a payer. The
132
+ warning at config load names the module, the variable and the two words that lift it.
133
+ - A default cap of $25 will be too small for some instance, and that instance will discover it as a
134
+ 402 with the number in the message. That is the right direction to be wrong in: a generous
135
+ default is how an unbounded bill arrives.
136
+ - The constraint that a run which cost money must name a payer is added `NOT VALID`, so it binds
137
+ every write from here on without failing the migration of a self-hosted instance whose history
138
+ predates the column. Those rows stay honestly un-attributed and the ceiling counts none of them.
139
+ - **What this does not do:** nobody is billed. `builder` attributes spend to a person; it does not
140
+ collect from them. Turning attribution into a charge is a later decision, and it now has a ledger
141
+ to read.
@@ -373,3 +373,4 @@ This keeps the decision history honest and traceable.
373
373
  | 0279 | [**An upgrade is proven by the served version, not by a health check** ([task 1002884](https://cloudbongos.com/builders#/task/1002884) · goal 1000090 — *Working area 4, Bongos Core distribution*; from idea 1000682). On 2026-08-11 the auto-upgrade sweep printed `✓ upgrade complete — core 1.19.13 → 1.19.56`, wrote a success row to `core_upgrades` and exited 0 while live kept serving **1.19.13**. Three shipped checks formed a closed loop that could not see the failure they existed to catch: the `systemctl restart` failed with `Interactive authentication required` (a `User=` unit, no TTY) and `upgrade.js` treated it as a WARNING and fell through; `readInstalledCoreVersion()` then confirmed the version on **disk**, where `npm install` had correctly put it; and `pollHealth()` got a 200 from the **still-running old process**, because a health check confirms a port is served, never *what* serves it. A lying tool is worse than a broken one — nothing goes looking. The damage outlived the incident: `subscribe.js` had already routed the unattended lane around `bongos upgrade` in favour of `go-live.js`, citing this false-pass in a comment. **Decision: a bump is confirmed by asking the running process what version it is.** (1) A failed restart enters the same auto-rollback path as a failed install/migrate/health and exits non-zero — rolling back rather than merely erroring keeps disk and process consistent, since disk-ahead-of-process is the state that made the incident invisible. (2) The served version is read back from `/version` (`coreVersion`, since 1.17.2, prelaunch-gate exempt), defaulting to the `--health-url` origin and overridable via `--version-url` — derived rather than opt-in because every existing call site passes only `--health-url`, and a check you must opt into is off exactly where it is needed. (3) A **mismatch** fails (proof of failure → roll back); an **unreadable** endpoint only warns (absence of proof — refusing every such bump would regress harder than the false-pass), except under an explicit `--version-url`, which asks for proof and therefore gets a failure. That strict mode is what the unattended subscription lane now passes, its roster already carrying the URL. **Escalation is `sudo -n`, not a hand-placed polkit rule:** `restartService()` retries a failed restart through `sudo -n` when not root — the idiom `dev.js`/`dev-lib.js` already use and consistent with `provision.js`, `upgrade.js` having been the one place that restarted without escalating. The live `/etc/polkit-1/rules.d/<redacted>.rules` mitigation is superseded: a rebuilt box inherits code, not hand-placed `/etc` files (polkit route kept in [`docs/recipes/instance-service-restart.md`](../recipes/instance-service-restart.md)). `--no-health-check` stays the single escape hatch and now waives the read-back too. Rejected: comparing `startedAt` (cannot distinguish a restart onto the same old core from one onto the new); making an unreadable endpoint fatal by default; requiring `--version-url` everywhere (absent from every current call site — the same "off where it matters" failure in a new costume); keeping polkit as the answer.](0279-an-upgrade-is-proven-by-the-served-version-not-the-health-check.md) | core distribution / upgrade verification |
374
374
  | 0280 | [**The unattended lane may drive a co-tenant, because the roster entry IS the per-instance decision** ([task 1003843](https://cloudbongos.com/builders#/task/1003843) · goal 1000106; from [task 1003521](https://cloudbongos.com/builders#/task/1003521), which built co-tenant mode into `go-live.js` and deliberately left this caller alone). `subscribe.js` keeps a SECOND COPY of go-live's target contract, and it drifted: `GO_LIVE_REQUIRED` still listed `deployTimer` after 1003521 made it optional, so `goLiveTargetFor()` returned `missing: ['deployTimer']` for every provisioned co-tenant, and `rawTopologyFor()`'s fixed field list dropped `pinMode` before the target was built — the same stale-normalizer trap that function's own comment was written to fix, one field later. **1003521 deferred this as a policy judgement call; reading the fallback shows the premise was wrong.** `mapped.ok === false` never meant "skip this instance" — it meant "deploy it the WEAKER way": a bare `bongos upgrade`, no backup, no deploy-timer guard, and a pin COMMITTED AND PUSHED into the co-tenant's own repo, which is exactly what `pinMode` exists to prevent. The lane was already auto-deploying co-tenants; the only open question was whether it did so well. A THIRD defect sat behind the other two and would have turned the silent downgrade into a hard failure had only the first been fixed: the normalizer yields `deployTimer: null`, an explicit null SURVIVES `JSON.stringify` into `--target-json`, and go-live's `validateTarget` runs `isSafeToken(null)` on it and rejects the whole target. A FOURTH sat PAST the go-live call and is what separates "reachable" from "safe": after any successful upgrade the lane runs its own pin net, `commitPin()`, which commits the pin files and PUSHES them to `origin` — a net that predates `pinMode`. Under `local-commit` go-live leaves the tree clean so it no-ops by luck; under **`leave-dirty`** the pin is in the working tree BY DESIGN, so the net committed it and pushed it into the customer's own repo — the lane honouring `pinMode` right up to the moment it undid it. **Decision: the lane may drive a co-tenant and needs no new gate, because listing an instance in `config/update-subscriptions.json` already IS the explicit per-instance operator decision** — the roster ships empty, nothing is touched until an operator lists it, and the routine is autonomy-gated (`requiresAutonomy: true`, default OFF) on top of that; a second consent gate asks the same person the same question twice. So: `GO_LIVE_REQUIRED` now MIRRORS go-live's `REQUIRED_FIELDS`, with a test reading BOTH source files and failing on drift; `deployTimer` moves to the optional-field loop so a co-tenant OMITS the key rather than carrying a null; `rawTopologyFor` carries `pinMode`; `resolveGoLive` gains a `needsCoTenant` probe leg; and `goLiveEngineFor()` makes the decision ONCE for both the dry run and the apply path, which had re-derived it separately. The pin net now obeys the same `pinMode` go-live was given — `commitPin()` takes `push` (default true, so the platform instance is unchanged), `local-commit` commits without pushing, `leave-dirty` skips the net, the rollback tidy follows the same rule, and the "not durable" advice stops telling an operator to `git push` a repo we do not own; its refusal to touch a worktree carrying NON-pin changes is unchanged in every mode. **The probe is CONDITIONAL on purpose** — demanding `pinMode` of every target would strip the go-live path from any instance between the `--target-json` release and 1003521's, trading a live regression for a hypothetical one. Rejected: a second consent gate (an `allowCoTenant` flag); deferring to the unspecced fleet control plane ([task 1001948](https://cloudbongos.com/builders#/task/1001948)) — which does not leave co-tenants alone, it leaves them pushing pins into customer repos; dropping `deployTimer` from `GO_LIVE_REQUIRED` and nothing else (the fix as proposed — trades a silent downgrade for a hard rejection); probing unconditionally; reaching co-tenant mode WITHOUT teaching the durability net about it (nominal support that breaks its own promise seconds later); and teaching `loadSubscriptions()` about `pinMode` instead, since that normalizer ships in the control plane's vendored core that nobody bumps — the exact trap `rawTopologyFor` routes around.](0280-the-unattended-lane-may-drive-a-co-tenant-because-the-roster-is-the-decision.md) | core distribution / unattended deploy |
375
375
  | 0281 | [**An instance’s identity is its own unix account and its own password-authed Postgres role** ([task 1003369](https://cloudbongos.com/builders#/task/1003369) · goal 1000094, audit ref `B3` of the 2026-08-29 security audit). [ADR 0111](0111-instance-hosting-provisioning-module.md) made standup automatic and [ADR 0128](0128-provisioning-runner-privilege-and-db-auth-model.md) settled how the RUNNER escalates; neither settled who the INSTANCE is once it runs, and the answer was that everybody was the same person. `serviceUnit` hardcoded `User=lars`, and **co-tenant is the default shape** — so on a shared box every instance’s process ran under one uid, and `pool.js` connected by **passwordless peer auth**. Two consequences, neither a bug in any route: a unix user may read `/proc/<pid>/environ` of its own processes, so every instance could read every sibling’s `GITHUB_APP_PRIVATE_KEY`, hub client secret and OAuth secret; and peer auth answers “which unix user are you”, so any code as `lars` could open a pool against **any** database on the box by naming it — including the control plane’s own `builders` and `builder_sessions`. The `0600` `web.env` and the per-instance DB were already correct: sound walls with one door, and every instance had the key. **Decision: each instance gets its own unix account and its own password-authed PG role, both derived from its slug, both created by the provision run.** The account is `bongos-<slug>`, hash-folded past utmp’s 32 chars — prefixed because a project may legally be called `postgres`, and hash-suffixed because two slugs sharing a 16-char prefix would otherwise fold onto ONE account, which is the shared identity being removed. Its home is `/var/lib/<user>` and deliberately **not** under `/home`, since the unit sets `ProtectHome=read-only` and the server writes `builder-secret.key` inside its config home on first boot — a `/home` account would make that `EROFS`. The unit gains `ProtectSystem=strict` / `ProtectHome=read-only` / `PrivateTmp=true` with `ReadWritePaths` as the whole allow-list; **the checkout the service runs is deliberately not writable by it**. The role is `LOGIN` + password, owns its own DB, and is NOT superuser/CREATEDB/CREATEROLE — with **`REVOKE CONNECT … FROM PUBLIC` as the half that actually isolates**, because PG grants `CONNECT` to `PUBLIC` by default and per-role passwords alone leave a sibling one peer-auth hop away. Grants run in a SECOND statement **after** the migrate, since the privileged migrate runs as `postgres` and owns every table it creates; `ALTER DEFAULT PRIVILEGES` aims the same grant forward. The password is minted per run and never stored — the same run sets it on the role and rewrites the `0600` env file, so **every provision rotates it** — and `pool.js` reads `PGUSER`/`PGPASSWORD` **explicitly** rather than via `pg`’s env defaults, because the whole defect was a connection whose identity nothing in the code stated. **Absence is the old behavior:** no password ⇒ no credential lines and no pool keys, so an instance provisioned before this keeps its peer auth and the rollout cannot brick a box. Two consequences worth the record. (1) **The generated SQL contains no double quote, and that is load-bearing** — every literal is dollar-quoted and every identifier goes through `format(%I)`, because these commands are embedded in the dedicated droplet’s cloud-init as `- [ bash, -lc, "…" ]`, a YAML DOUBLE-QUOTED scalar: a plain `"role"` identifier reads fine in `psql` and silently truncates the line. A test asserts the absence rather than leaving it to habit. (2) **A partial, stated honestly** — the account is added to the app-user group so it can read the checkout it runs from (without it the unit fails at exec with `EACCES`), so FILE-level co-tenant separation is not what this delivers; the process and database boundaries are. Rejected: a `DATABASE_URL` in `web.env` (needs no pool change, but trades a stated, assertable identity for a socket-path parse that is `pg-connection-string`’s business); per-role passwords WITHOUT the `PUBLIC` revoke (reads like isolation, and peer auth walks around it); `ProtectSystem=full`, or `strict` with the working directory writable (makes the code the service executes writable by the service, in a change whose subject is containment); keeping `User=lars` and isolating with a second mechanism (the shared uid IS the `/proc` vector); storing the password in `provisioning_instances` (a live credential in plaintext, to buy stability the rotation does not need).](0281-an-instance-identity-is-its-own-unix-account-and-pg-role.md) | tenant isolation / provisioning / DB auth |
376
+ | 0282 | [**A module that spends money for its callers DECLARES so, and the core refuses to enable it where anyone can sign up and nobody is on the hook** ([task 1003884](https://cloudbongos.com/builders#/task/1003884), owner decision 2026-09-13). A fire from `modules/agents` costs ~$0.90, `POST /agents/:name/invoke` answers **202** so the caller does not even wait, and the route is gated at `requireBuilder` and nothing else — deliberately, because a v1 agent is hard-read-only by plumbing. That bounds CAPABILITY, not SPEND, and `lib/fire-budget.js` says so in its own header: it is a blast radius, explicitly NOT a budget, and it defers the real cap to `agents_runs.cost_usd`. The cap did not exist. The exposure is three reasonable facts composed: a fire is expensive and async; invoke admits any **any-builder**; and with the join door `open`, any GitHub account IS one seconds after sign-up. **Decision: the MODULE declares `"spend": { "requiresPayer": true }` and the CORE refuses to enable it when the composed join door (ADR 0247 — the door, never the `joinability` knob alone) is `open` with no configured payer.** The declaration lives in the manifest because fitness Check 10 forbids the kernel from naming a discovered module key — a constraint that turns out to be the better design, since the next metered module inherits the guard by declaring one line rather than by someone remembering to widen a list. The refusal is at **config load**, so the routes never mount and the migrations never run: a per-request check would let the module mount, advertise its routes and read as enabled everywhere the UI looks, then refuse callers one at a time — a broken feature, not a closed door. It is applied AFTER config, env and default alike, because `<PREFIX>_MODULE_<KEY>` is exactly the knob a hurried deploy reaches for and a wall env could outrank is a suggestion. An UNREADABLE posture refuses too, inverting this file’s usual fail-to-unchanged default: "I could not tell whether strangers can sign up" has to mean "assume they can", and the cost of being wrong is one module resolving off. `spend_payer` (`none|project|builder`) joins `SETTINGS_VOCAB` on ADR 0241’s transport, and `agents_runs` gains `payer_kind` + `payer_builder_id` — **stored, not derived**, because the setting is a live knob and deriving would make last month’s bill change when an owner flips it. `none` is NOT a third payer: a run always has a real one and `none` attributes to the project, so a CLOSED-door instance is unchanged and `none` is fatal only where it is also dangerous. An EVENT fire has no caller, so it bills the project rather than nobody — otherwise the event path is the one unbounded route. The per-payer monthly ceiling (`lib/spend-ceiling.js`) is a SECOND wall that must never merge into the first: a rate ceiling cannot bound a bill (~$650/month/builder at 10 fires an hour) and a spend ceiling cannot bound a loop (it reads spend already landed). It records NOTHING — the ledger is the tally, and a second tally is the disagreeing answer fire-budget’s header refuses to become. It honours the finding of abandoned [task 1001393](https://cloudbongos.com/builders#/task/1001393), which caught the autonomy brake reading month-to-date spend from a DB it could not reach and **failing open**: every unknown refuses (no cap, non-finite cap, no payer, a read that threw, a sum absent or unparseable), answering **402** rather than fire-budget’s 429 so "the money is gone" and "too fast" stay distinguishable. Writing the absent-sum clause down was not paranoia — the first draft admitted the fire on a null sum because `Number(null)` is `0`, and the test for that clause is what caught it. The overshoot is STATED: in-flight fires are invisible to the read, bounded by concurrency × ~$0.90, and what bounds concurrency is fire-budget — each wall’s blind spot is the other’s window. The cost-needs-a-payer constraint is added `NOT VALID` so it binds every future write without failing the migration of an instance whose history predates the column. **What it does not do:** nobody is billed; `builder` attributes spend, it does not collect.](0282-a-module-that-spends-for-its-callers-declares-a-payer-and-the-core-refuses-it-without-one.md) | modules / spend attribution / agents |
@@ -1940,5 +1940,9 @@ is load-bearing: the script throws rather than guess if it is missing, and
1940
1940
  landed since 1.19.731 with no explicit bump. run 34766314699. (task 1002620)
1941
1941
  1.19.733 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1942
1942
  landed since 1.19.732 with no explicit bump. run 34766569496. (task 1002620)
1943
+ 1.19.734 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1944
+ landed since 1.19.733 with no explicit bump. run 34767120578. (task 1002620)
1945
+ 1.19.735 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1946
+ landed since 1.19.734 with no explicit bump. run 34768136326. (task 1002620)
1943
1947
  ---------------------------------------------------------------------------
1944
1948
  ```
@@ -107,12 +107,34 @@ Every module must have a `module.json` at its root (`modules/<key>/module.json`)
107
107
  "provides": ["box.hasEverConnected"], // OPTIONAL. Seam PORTS this module registers a provider for.
108
108
  "consumes": [], // OPTIONAL. Seam ports this module resolves (required caps).
109
109
  "prerequisites": { "modules": [] }, // OPTIONAL. Other modules that must be enabled first.
110
+ "spend": { "requiresPayer": true }, // OPTIONAL. This module spends MONEY for whoever calls it.
110
111
  "dependencies": { "discord.js": "^14.26.4" } // OPTIONAL. External npm deps this module needs at runtime.
111
112
  }
112
113
  ```
113
114
 
114
115
  An unknown key throws at validate time — typos fail loud (top-level attribution fields have no closed allowlist; only `contributes` sub-keys do).
115
116
 
117
+ ### `spend` — "my surfaces spend real money for whoever calls them" ([ADR 0282](adr/0282-a-module-that-spends-for-its-callers-declares-a-payer-and-the-core-refuses-it-without-one.md))
118
+
119
+ Declare `"spend": { "requiresPayer": true }` when a route of yours can spend money on behalf of the
120
+ caller — a paid model call, a metered third-party API, anything with a per-request bill. The core
121
+ then **refuses to enable your module** when the project's composed join door is `open` (anyone may
122
+ sign up) and the project has named no payer, and it refuses at **config load**, so your routes never
123
+ mount at all rather than 402-ing one caller at a time. A closed-door instance is unaffected.
124
+
125
+ The key set is closed: a misspelled `requirespayer` fails validation rather than silently declaring
126
+ no spend, because that is the direction that costs money.
127
+
128
+ **Why the declaration lives here rather than in the core.** The kernel may not name a module key
129
+ (fitness Check 10), so the guard is true of any module that says this about itself — which means the
130
+ next metered module inherits it by declaring one line instead of by someone remembering to widen a
131
+ list in `src/modules.js`.
132
+
133
+ **What declaring it does NOT give you.** A per-payer ceiling is still yours to build: the core
134
+ decides whether you may be *enabled*, not what you may *cost*. `modules/agents/lib/spend-ceiling.js`
135
+ is the worked example — it sums the module's own ledger per payer per calendar month and fails
136
+ closed on every unknown, including a ledger read it cannot complete.
137
+
116
138
  ### `dependencies` — a module's own npm deps ([ADR 0138](adr/0138-bundled-module-deps-declared-per-module.md))
117
139
 
118
140
  `@bongos/core` ships lean — its synthesized `package.json` declares only `express` + `pg` ([ADR 0108](adr/0108-instance-composition-configurable-root.md)). A bundled module that needs an external npm package at runtime (e.g. `discord` → `discord.js`) declares it in its own `module.json` `dependencies` (`{ "<npm-name>": "<semver-range>" }`, schema-validated). **Core does not carry it**, so the *consumer* must — greenfield `bongos init` composes the ENABLED modules' declared deps into the instance `package.json` (`enabledModuleDeps` → `buildInstancePackageJson`; an explicit pre-existing pin wins, `@bongos/core` is always set), and the R89 cutover preserves the monolith's existing dependency closure. If a module needs a dep and neither declares it nor is it in the consumer's closure, an instance that enables the module crashes at boot — so **declare it here**.