@bongos/core 1.19.425 → 1.19.427
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 +66 -31
- package/docs/adr/0210-scouting-graduates-on-an-instance-asserted-owner-claim.md +100 -0
- package/docs/adr/0212-connections-on-the-profile-surface-and-addressing.md +61 -0
- package/docs/adr/README.md +2 -0
- package/docs/api/openapi.json +1 -1
- package/docs/copy-inventory.md +14 -6
- package/docs/copy-registry.json +91 -9
- package/docs/file-map.md +3 -1
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/people.js +5 -5
- package/modules/hall-ui/public/profile-connections.js +304 -0
- package/modules/hall-ui/public/profile.html +16 -1
- package/modules/hall-ui/public/profile.js +5 -0
- package/modules/hall-ui/public/settings.html +2 -2
- package/modules/hall-ui/public/settings.js +12 -12
- package/modules/hall-ui/public/style.css +38 -0
- package/modules/platform-identity/account-visibility.js +47 -1
- package/modules/platform-identity/platform-identity.js +14 -11
- package/modules/platform-identity/routes/scouting.js +61 -21
- package/modules/platform-identity/routes/sso.js +6 -0
- package/modules/platform-identity/scouting-authz.js +139 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/hall-preview/server.js +3 -0
- package/src/bongos/auth.js +59 -3
- package/src/module-api.js +1 -1
- package/tests/account_privacy_flags.mjs +23 -13
- package/tests/effective_visibility_predicate.mjs +49 -4
- package/tests/profile_connections_ui.mjs +874 -0
- package/tests/scouting_owner_access_and_reach.mjs +544 -0
- package/tests/scouting_page_gate.mjs +185 -0
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.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.427",
|
|
6
|
+
"core_contract": "1.19.427",
|
|
7
|
+
"source_commit": "f0b176008c51428ad1c344b7704d0e9462c0ee90",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-29T19:
|
|
9
|
+
"built_at": "2026-08-29T19:37:42.056Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
|
-
"docs_redacted":
|
|
12
|
+
"docs_redacted": 362,
|
|
13
13
|
"agent_docs_stubbed": 23,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1731,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2116,
|
|
20
|
+
"tree_sha256": "96e06bbbfd609aa582c5ac2461392edc56e3c51afdbdb2d2551165711fd75276",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -1549,10 +1549,20 @@
|
|
|
1549
1549
|
"mode": "0000644",
|
|
1550
1550
|
"sha256": "bdf26860d1f10413a30a770ab420d49fdf58f922d75e23fb1cf9f174f7e469e4"
|
|
1551
1551
|
},
|
|
1552
|
+
{
|
|
1553
|
+
"path": "docs/adr/0210-scouting-graduates-on-an-instance-asserted-owner-claim.md",
|
|
1554
|
+
"mode": "0000644",
|
|
1555
|
+
"sha256": "e7cc54c2186bda5ae76039a57ab285320bdd57c6879a51e55bcd145fe9261ad5"
|
|
1556
|
+
},
|
|
1557
|
+
{
|
|
1558
|
+
"path": "docs/adr/0212-connections-on-the-profile-surface-and-addressing.md",
|
|
1559
|
+
"mode": "0000644",
|
|
1560
|
+
"sha256": "1251b6ba7b5fc79765aed5fcf9696d6f55d52acf6d6766200215a480f2d574a3"
|
|
1561
|
+
},
|
|
1552
1562
|
{
|
|
1553
1563
|
"path": "docs/adr/README.md",
|
|
1554
1564
|
"mode": "0000644",
|
|
1555
|
-
"sha256": "
|
|
1565
|
+
"sha256": "c4216e812144d5ed7b95c3ffd25c124b8e7a1f97610721e3b7ab938751f881c7"
|
|
1556
1566
|
},
|
|
1557
1567
|
{
|
|
1558
1568
|
"path": "docs/api-reference.md",
|
|
@@ -1562,7 +1572,7 @@
|
|
|
1562
1572
|
{
|
|
1563
1573
|
"path": "docs/api/openapi.json",
|
|
1564
1574
|
"mode": "0000644",
|
|
1565
|
-
"sha256": "
|
|
1575
|
+
"sha256": "1a80f11c01764ec8e1542f9f5b3b2b37123656778646dd316c1e32180973b310"
|
|
1566
1576
|
},
|
|
1567
1577
|
{
|
|
1568
1578
|
"path": "docs/architecture.md",
|
|
@@ -1587,12 +1597,12 @@
|
|
|
1587
1597
|
{
|
|
1588
1598
|
"path": "docs/copy-inventory.md",
|
|
1589
1599
|
"mode": "0000644",
|
|
1590
|
-
"sha256": "
|
|
1600
|
+
"sha256": "054f4d38105b2bfad142c1a72b982f09e78bd3b14074eac1b2a001c49b8cd2bf"
|
|
1591
1601
|
},
|
|
1592
1602
|
{
|
|
1593
1603
|
"path": "docs/copy-registry.json",
|
|
1594
1604
|
"mode": "0000644",
|
|
1595
|
-
"sha256": "
|
|
1605
|
+
"sha256": "e6fbf0872ac28c4931fbec5bc335b50283d4e0b1d7247e300612eb0c6ec653d0"
|
|
1596
1606
|
},
|
|
1597
1607
|
{
|
|
1598
1608
|
"path": "docs/design/apex-pass-2-direction.md",
|
|
@@ -1937,7 +1947,7 @@
|
|
|
1937
1947
|
{
|
|
1938
1948
|
"path": "docs/file-map.md",
|
|
1939
1949
|
"mode": "0000644",
|
|
1940
|
-
"sha256": "
|
|
1950
|
+
"sha256": "4690b1019567fe7e15f8e7a3c873b9cef94534bd07124545a388f9cd8bf85918"
|
|
1941
1951
|
},
|
|
1942
1952
|
{
|
|
1943
1953
|
"path": "docs/handoff-template.md",
|
|
@@ -1947,7 +1957,7 @@
|
|
|
1947
1957
|
{
|
|
1948
1958
|
"path": "docs/module-api-changelog.md",
|
|
1949
1959
|
"mode": "0000644",
|
|
1950
|
-
"sha256": "
|
|
1960
|
+
"sha256": "3af56594001e0448960dd5999bf603e94c008f31d7c24997e6af2d4e0f52f550"
|
|
1951
1961
|
},
|
|
1952
1962
|
{
|
|
1953
1963
|
"path": "docs/modules-contract.md",
|
|
@@ -4092,7 +4102,7 @@
|
|
|
4092
4102
|
{
|
|
4093
4103
|
"path": "modules/hall-ui/public/people.js",
|
|
4094
4104
|
"mode": "0000644",
|
|
4095
|
-
"sha256": "
|
|
4105
|
+
"sha256": "7e0e2f091fb8c286220bbd5f790497ab54c78e569f59519a00fa5739d11bd451"
|
|
4096
4106
|
},
|
|
4097
4107
|
{
|
|
4098
4108
|
"path": "modules/hall-ui/public/primer.css",
|
|
@@ -4109,6 +4119,11 @@
|
|
|
4109
4119
|
"mode": "0000644",
|
|
4110
4120
|
"sha256": "40f6f44283aa7dba25a2c983d77b6e6f53f40eefef3394122d34f7f42fc5f676"
|
|
4111
4121
|
},
|
|
4122
|
+
{
|
|
4123
|
+
"path": "modules/hall-ui/public/profile-connections.js",
|
|
4124
|
+
"mode": "0000644",
|
|
4125
|
+
"sha256": "b6ff1827da188dbd5a36091e6f5634d43ac21d386cc04b553b6f46c92827dff2"
|
|
4126
|
+
},
|
|
4112
4127
|
{
|
|
4113
4128
|
"path": "modules/hall-ui/public/profile-nudge.js",
|
|
4114
4129
|
"mode": "0000644",
|
|
@@ -4117,12 +4132,12 @@
|
|
|
4117
4132
|
{
|
|
4118
4133
|
"path": "modules/hall-ui/public/profile.html",
|
|
4119
4134
|
"mode": "0000644",
|
|
4120
|
-
"sha256": "
|
|
4135
|
+
"sha256": "3b48f9fedd44d702a4323bd7cbdc0e3f570ca50d2940c6f22508d6c5bc482f8e"
|
|
4121
4136
|
},
|
|
4122
4137
|
{
|
|
4123
4138
|
"path": "modules/hall-ui/public/profile.js",
|
|
4124
4139
|
"mode": "0000644",
|
|
4125
|
-
"sha256": "
|
|
4140
|
+
"sha256": "0885bb6b92dfdc54b9177b0cb949a692ebc8025a019f309d6d786f9dfaf2442d"
|
|
4126
4141
|
},
|
|
4127
4142
|
{
|
|
4128
4143
|
"path": "modules/hall-ui/public/ranks.html",
|
|
@@ -4192,12 +4207,12 @@
|
|
|
4192
4207
|
{
|
|
4193
4208
|
"path": "modules/hall-ui/public/settings.html",
|
|
4194
4209
|
"mode": "0000644",
|
|
4195
|
-
"sha256": "
|
|
4210
|
+
"sha256": "81da0b750da880c411a8604687a8c300852c631ffdd07d4fd1bb3c4533e650c0"
|
|
4196
4211
|
},
|
|
4197
4212
|
{
|
|
4198
4213
|
"path": "modules/hall-ui/public/settings.js",
|
|
4199
4214
|
"mode": "0000644",
|
|
4200
|
-
"sha256": "
|
|
4215
|
+
"sha256": "d3ddd434100c65a5839e3004ad1a5c4537d2da23ecd4441ee3eff3aa0ee8b5a1"
|
|
4201
4216
|
},
|
|
4202
4217
|
{
|
|
4203
4218
|
"path": "modules/hall-ui/public/shell.js",
|
|
@@ -4212,7 +4227,7 @@
|
|
|
4212
4227
|
{
|
|
4213
4228
|
"path": "modules/hall-ui/public/style.css",
|
|
4214
4229
|
"mode": "0000644",
|
|
4215
|
-
"sha256": "
|
|
4230
|
+
"sha256": "41dcbf8e7b21b95c134cfa7df182fc1059d67a4185c38fc55ff310ecde4df5aa"
|
|
4216
4231
|
},
|
|
4217
4232
|
{
|
|
4218
4233
|
"path": "modules/hall-ui/public/task-detail.js",
|
|
@@ -4797,7 +4812,7 @@
|
|
|
4797
4812
|
{
|
|
4798
4813
|
"path": "modules/platform-identity/account-visibility.js",
|
|
4799
4814
|
"mode": "0000644",
|
|
4800
|
-
"sha256": "
|
|
4815
|
+
"sha256": "04deda249e65ceb416064ecb503ff101600c57ae84be9bf2ecab1de4b6701879"
|
|
4801
4816
|
},
|
|
4802
4817
|
{
|
|
4803
4818
|
"path": "modules/platform-identity/application-echo.js",
|
|
@@ -4917,7 +4932,7 @@
|
|
|
4917
4932
|
{
|
|
4918
4933
|
"path": "modules/platform-identity/platform-identity.js",
|
|
4919
4934
|
"mode": "0000644",
|
|
4920
|
-
"sha256": "
|
|
4935
|
+
"sha256": "f2a30bba329bf7d9940ab0af4c28417ba316c317cfc83e37e9a082cb9a160897"
|
|
4921
4936
|
},
|
|
4922
4937
|
{
|
|
4923
4938
|
"path": "modules/platform-identity/pollers/code-sweep.js",
|
|
@@ -4957,12 +4972,17 @@
|
|
|
4957
4972
|
{
|
|
4958
4973
|
"path": "modules/platform-identity/routes/scouting.js",
|
|
4959
4974
|
"mode": "0000644",
|
|
4960
|
-
"sha256": "
|
|
4975
|
+
"sha256": "e9afb0c1839c2933b1222158a3fccca8c3e3659282bb705e9d5c80575471200c"
|
|
4961
4976
|
},
|
|
4962
4977
|
{
|
|
4963
4978
|
"path": "modules/platform-identity/routes/sso.js",
|
|
4964
4979
|
"mode": "0000644",
|
|
4965
|
-
"sha256": "
|
|
4980
|
+
"sha256": "b31ded3abdef839e72f3f87b5a96984332c1836b8a88a5c452aeaaefc4b713d6"
|
|
4981
|
+
},
|
|
4982
|
+
{
|
|
4983
|
+
"path": "modules/platform-identity/scouting-authz.js",
|
|
4984
|
+
"mode": "0000644",
|
|
4985
|
+
"sha256": "b4da61899339ac5e9fac517efa4b3d98e39a8e7d38fd8b0cffbd8464ba85e902"
|
|
4966
4986
|
},
|
|
4967
4987
|
{
|
|
4968
4988
|
"path": "modules/platform-identity/tests/platform-identity.mjs",
|
|
@@ -5522,12 +5542,12 @@
|
|
|
5522
5542
|
{
|
|
5523
5543
|
"path": "package-lock.json",
|
|
5524
5544
|
"mode": "0000644",
|
|
5525
|
-
"sha256": "
|
|
5545
|
+
"sha256": "0c38238be602951a19181a4e65e8e2544a06c25ecb4968dc952ff84e4a70bb2f"
|
|
5526
5546
|
},
|
|
5527
5547
|
{
|
|
5528
5548
|
"path": "package.json",
|
|
5529
5549
|
"mode": "0000644",
|
|
5530
|
-
"sha256": "
|
|
5550
|
+
"sha256": "e796318cb273523ec008c90d6105ad81b10a7acb9460a15063610e552c70856b"
|
|
5531
5551
|
},
|
|
5532
5552
|
{
|
|
5533
5553
|
"path": "public-docs/index.html",
|
|
@@ -7292,7 +7312,7 @@
|
|
|
7292
7312
|
{
|
|
7293
7313
|
"path": "scripts/hall-preview/server.js",
|
|
7294
7314
|
"mode": "0000644",
|
|
7295
|
-
"sha256": "
|
|
7315
|
+
"sha256": "0a0d26971c469980b398fe9d7ba1cd85013a704ca0e188d8055ee26c8dfa8044"
|
|
7296
7316
|
},
|
|
7297
7317
|
{
|
|
7298
7318
|
"path": "scripts/migrate.sh",
|
|
@@ -7402,7 +7422,7 @@
|
|
|
7402
7422
|
{
|
|
7403
7423
|
"path": "src/bongos/auth.js",
|
|
7404
7424
|
"mode": "0000644",
|
|
7405
|
-
"sha256": "
|
|
7425
|
+
"sha256": "517e25a2ca9b9adafa993725802af9e7fc1708f0a5609102ddce593e82478ca2"
|
|
7406
7426
|
},
|
|
7407
7427
|
{
|
|
7408
7428
|
"path": "src/bongos/cascade-dispatch.js",
|
|
@@ -7657,7 +7677,7 @@
|
|
|
7657
7677
|
{
|
|
7658
7678
|
"path": "src/module-api.js",
|
|
7659
7679
|
"mode": "0000644",
|
|
7660
|
-
"sha256": "
|
|
7680
|
+
"sha256": "25a7279a6e6870a9e0ce02a7a5a4e78891c2d37c5a0cb7cd623ad1369a756885"
|
|
7661
7681
|
},
|
|
7662
7682
|
{
|
|
7663
7683
|
"path": "src/module-loader/catalog.js",
|
|
@@ -7717,7 +7737,7 @@
|
|
|
7717
7737
|
{
|
|
7718
7738
|
"path": "tests/account_privacy_flags.mjs",
|
|
7719
7739
|
"mode": "0000644",
|
|
7720
|
-
"sha256": "
|
|
7740
|
+
"sha256": "d0c32156de102ab98447542def38978caded0dbe03e7a6f5004240484ba037ec"
|
|
7721
7741
|
},
|
|
7722
7742
|
{
|
|
7723
7743
|
"path": "tests/achievements_reconcile.mjs",
|
|
@@ -8522,7 +8542,7 @@
|
|
|
8522
8542
|
{
|
|
8523
8543
|
"path": "tests/effective_visibility_predicate.mjs",
|
|
8524
8544
|
"mode": "0000644",
|
|
8525
|
-
"sha256": "
|
|
8545
|
+
"sha256": "3563a287a7faa8bdf26dbde292584f62b4514814a0cfdf99e58e13f02514233b"
|
|
8526
8546
|
},
|
|
8527
8547
|
{
|
|
8528
8548
|
"path": "tests/est_advisory.mjs",
|
|
@@ -9679,6 +9699,11 @@
|
|
|
9679
9699
|
"mode": "0000644",
|
|
9680
9700
|
"sha256": "013df2528c4998588c1d3f89a9a25f8fa1e912b8abc2d8e95c26c250869a386f"
|
|
9681
9701
|
},
|
|
9702
|
+
{
|
|
9703
|
+
"path": "tests/profile_connections_ui.mjs",
|
|
9704
|
+
"mode": "0000644",
|
|
9705
|
+
"sha256": "1165d03c123ace3558f2a17e06730e37bd6556e98d91f40c2b17fea11cd54353"
|
|
9706
|
+
},
|
|
9682
9707
|
{
|
|
9683
9708
|
"path": "tests/profile_fields.mjs",
|
|
9684
9709
|
"mode": "0000644",
|
|
@@ -9979,6 +10004,16 @@
|
|
|
9979
10004
|
"mode": "0000644",
|
|
9980
10005
|
"sha256": "b499ea62cdc1a615e565ce7b0379b2cab83b9aaa8098361f94d3eb93ebc7f927"
|
|
9981
10006
|
},
|
|
10007
|
+
{
|
|
10008
|
+
"path": "tests/scouting_owner_access_and_reach.mjs",
|
|
10009
|
+
"mode": "0000644",
|
|
10010
|
+
"sha256": "5c53f51280b77816b5498808ab097a209a368c2e3b6cbdbc6a8ed2a55e11d8b9"
|
|
10011
|
+
},
|
|
10012
|
+
{
|
|
10013
|
+
"path": "tests/scouting_page_gate.mjs",
|
|
10014
|
+
"mode": "0000644",
|
|
10015
|
+
"sha256": "33c586229a164e9a90804bd00485f9b3cfc665213adf1717841103d2cd5d8293"
|
|
10016
|
+
},
|
|
9982
10017
|
{
|
|
9983
10018
|
"path": "tests/scouting_rollup_fanout.mjs",
|
|
9984
10019
|
"mode": "0000644",
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
# ADR 0210 — The recruiting directory graduates on an instance-asserted ownership claim, and the recruiting opt-out gates the directory and nothing else
|
|
2
|
+
|
|
3
|
+
- **Status:** accepted
|
|
4
|
+
- **Date:** 2026-08-29
|
|
5
|
+
- **Task:** [task 1002290](https://cloudbongos.com/builders#/task/1002290) (R14, goal 1000045 — Public builder profiles, criterion C5)
|
|
6
|
+
- **Relates to:** [ADR 0201](<redacted>.md) D3 (what "project owner" means, and the hub-held owner list it forbids as a gate), [ADR 0141](<redacted>.md) §3/§4 (federated identity; authority stays local), [ADR 0016](<redacted>.md) (cross-project numbers are descriptive, never authority), [ADR 0205](<redacted>.md) D1/D2/D4 (the hub's own sign-in witness), [ADR 0171](<redacted>.md) D2/D3 (consent in the query), [ADR 0157](<redacted>.md) (`project.curate` archon → metic), `docs/specs/<redacted>.md` D1/D2 (the recruiting opt-out), `docs/specs/<redacted>.md` D3 (leaderboards, which the opt-out must NOT touch)
|
|
7
|
+
|
|
8
|
+
> **0208** is held by a session in flight; this ADR takes 0210 rather than race it (the ADR 0199 / 0202 / 0207 precedent).
|
|
9
|
+
|
|
10
|
+
## Context
|
|
11
|
+
|
|
12
|
+
Goal 1000045 C5 says **"project owners (not only Archons) browse builder profiles to recruit"**, and R14 is the task that makes it true. Two things stood between the goal and that sentence, and they turned out to be independent problems wearing one task id.
|
|
13
|
+
|
|
14
|
+
**The reach flag gated nothing.** `recruiter_discoverable` (privacy spec D1) shipped with task 1002966 and was wired into the *effective-answer* helper by task 1002967 — which then reported, accurately, that it still protected nobody: *"a public account that opted out of recruiting still appears in `/scouting`."* The Settings row said so out loud, in the future tense, because a toggle that claims protection it does not have is worse than no toggle. So one half of this task is a predicate.
|
|
15
|
+
|
|
16
|
+
**"Archon-only" was already stale, and the phrase in the task was hiding a real question.** R14's brief says "widen the Archon-only `/scouting`", but ADR 0157 had already lowered `project.curate` from archon to metic, and `src/bongos/auth.js` carried a comment predicting that R14 would widen the atom's audience further and that the page gate would "follow it automatically". Reading that literally would have made this task a floor change — and a floor change would have missed the audience the criterion names. **A project owner is not a rank.** Somebody who registers a project on the platform is an Archon *on their own instance* and, on the hub where the directory lives, very often a `xenos` holding nothing at all. No floor reaches them without also reaching every stranger who ever signed in.
|
|
17
|
+
|
|
18
|
+
## Decision
|
|
19
|
+
|
|
20
|
+
### D1 — "Project owner" on the hub is an **instance-asserted, hub-witnessed** `membership_kind = 'owner'`, and never the catalog's `owner_github_id`
|
|
21
|
+
|
|
22
|
+
ADR 0201 D3 already answered *who* a project owner is — the project's own archon: the registrant `maybeSeatFirstAdmin` seated, plus whoever that archon grants on that instance — and it forbade one specific implementation: `platform_identity_projects.owner_github_id` **"stays advisory attribution and is never read as a gate"**, because a hub-held owner list consulted before an admission is the hub deciding who may admit on someone else's box.
|
|
23
|
+
|
|
24
|
+
That prohibition is about `owner_github_id`, and it is about *admission*. Both halves matter, because the record this ADR does gate on is a different row with a different provenance:
|
|
25
|
+
|
|
26
|
+
| | `platform_identity_projects.owner_github_id` | `<redacted>.membership_kind` |
|
|
27
|
+
|---|---|---|
|
|
28
|
+
| Written by | hub curation | the **project itself**, at `POST /sso/membership/check-in`, authenticated by `client_id` + `client_secret` |
|
|
29
|
+
| Decided by | a hub row | the instance's own config — `src/bongos/auth-admission.js` `membershipKind()` |
|
|
30
|
+
| Vouched by | nothing | the hub's **own** witness: `touchMembership` refuses to write unless `signin_verified_at` is set, stamped only where the hub itself authenticated the human (ADR 0205 D1/D2) |
|
|
31
|
+
|
|
32
|
+
So gating on `membership_kind = 'owner'` is the hub reading **the instance's own claim about its own archon, mirrored** — authority stays local, exactly as ADR 0141 requires. And the act being gated is a **hub read of the hub's own directory**, not an admission on anyone else's box. Neither leg of ADR 0201 D3's objection reaches it.
|
|
33
|
+
|
|
34
|
+
The witness is not decoration. Client credentials say which *project* is calling; they say nothing about *who* the `github_id` in the body is (ADR 0205, security report 1000027). An owner claim the hub never witnessed is a client's unproven self-report, and the gate refuses it — in SQL *and* in the JS predicate the query's result is passed through, so deleting either layer is caught.
|
|
35
|
+
|
|
36
|
+
### D2 — The two doors are an OR, the rank door is passed in **already built**, and the new door can only ever admit
|
|
37
|
+
|
|
38
|
+
`GET /scouting` admits a `project.curate` holder **OR** a project owner. The OR lives in `modules/platform-identity/scouting-authz.js` (the `modules/lifecycle/goal-authz.js` shape — owner-or-rank as a pure module sibling), never in `platform-identity.js`, which is at the file-size ratchet.
|
|
39
|
+
|
|
40
|
+
Three properties are deliberate, and each rules out an implementation that would have looked simpler:
|
|
41
|
+
|
|
42
|
+
1. **Ownership is asked first; a "no" falls through to the UNMODIFIED permission gate.** So a caller who is neither gets byte-for-byte the 403 this route always produced — same code, same `required`/`held`, same floors map naming the metic floor. The widening cannot change, soften, or swallow a refusal. Intercepting the permission gate's response to retry would have been the alternative, and it would have owned that 403's shape forever.
|
|
43
|
+
2. **`requirePermission('project.curate')` is spelled in the route's own middleware list** and handed to the helper already built. A gate hidden inside the helper is invisible to `route-rank-check.js` and `gen-api-docs.js`, which classify a route from its middleware — this route would have been recorded as `any-builder`, wrong in the dangerous direction, and the generated API reference would have published it that way.
|
|
44
|
+
3. **The ownership question fails CLOSED onto the permission gate.** A database blip must not admit and must not 500; it falls to the rank door, which denies unless the caller holds the atom. The failure mode of the new door is "the old door, unchanged".
|
|
45
|
+
|
|
46
|
+
**No new permission atom is minted.** ADR 0201 D3 says this goal mints none, and the recruiting atom the specs name — `project.recruit` — belongs to the project-admin-console wave ([spike 1002786](https://cloudbongos.com/builders#/task/1002786) R3), which has not landed. Minting it here would fork its definition across two waves. When it lands, it replaces the `project.curate` half of this OR; the ownership half is unaffected.
|
|
47
|
+
|
|
48
|
+
### D3 — The page gate follows the route through one declared `alsoAdmits` door, or the widening is invisible
|
|
49
|
+
|
|
50
|
+
`/builders/people` fronts `GET /scouting` and is gated by `requireProjectCuratePage`, whose whole vocabulary was "a permission". A widened route behind a permission-only page gate leaves the new audience staring at a redirect — the feature would exist and be unreachable by the people it was built for.
|
|
51
|
+
|
|
52
|
+
`PAGE_GATES` therefore gains an optional `alsoAdmits`, naming a function on the `platform-identity.hub` port (the module that owns the ownership fact). It is declared on exactly one gate, consulted **only after the permission check has already refused**, and fails closed every way it can fail — no port (every non-hub instance), no function, or a throw all deny. It can widen; it cannot narrow.
|
|
53
|
+
|
|
54
|
+
It is also consulted on the *authority-outage* branch (`held === null`), which is deliberate and worth stating: the ADR 0016 rule that "admitting when the authority system is missing is a privilege escalation" is about admitting *on the absence of an answer*. Here a different, still-working authority answered yes. Not asking would make the page narrower than the route it fronts, which is the drift this gate exists to prevent.
|
|
55
|
+
|
|
56
|
+
### D4 — Reach composes into a **directory** predicate, and deliberately does not fold into the rollup one
|
|
57
|
+
|
|
58
|
+
`account-visibility.js` is the sole owner of every effective-visibility answer, and `tests/effective_visibility_predicate.mjs` enforces that no other file may spell one. So the SQL twin of the reach COALESCE (`recruiterReachSql`) lands there beside its JS twin, and a new `scoutingListableSql` composes it with `rollupVisibleSql`. `listScoutingRollup` — the one reader behind both `/scouting` and the hall's `/people` — joins that.
|
|
59
|
+
|
|
60
|
+
**Reach is NOT folded into `rollupVisibleSql`**, and this is the decision most likely to be "simplified" later. `hide_stats` suppresses numbers *everywhere, for every reader*; the recruiting opt-out governs *the recruiting surface only*. The community spec's D3 says it in as many words — *"the recruiting opt-out does not affect leaderboards — it governs the recruiting surface only"*. Folding reach downward would silently empty a future public leaderboard of every opted-out builder, and it would do it invisibly, because a leaderboard missing people looks exactly like a leaderboard.
|
|
61
|
+
|
|
62
|
+
That makes `listScoutingRollup` **the recruiting reader specifically**, not a generic rollup reader. A public leaderboard must join `rollupVisibleSql` and must not reuse it. That constraint is written into the function's own doc-comment, into `scoutingListableSql`'s, and into an assertion in `tests/effective_visibility_predicate.mjs` that fails if reach ever appears in the rollup predicate.
|
|
63
|
+
|
|
64
|
+
### D5 — No migration, and that is the finding rather than an omission
|
|
65
|
+
|
|
66
|
+
R14 was pre-assigned `<redacted>*`. Nothing needed it: both columns exist (migration 012), and `<redacted>` already carries the `(github_id)` index migration 003 added for "My Projects" — the ownership probe is that same lookup plus two cheap column tests on a handful of rows, stopping at the first. A partial index would be speculative optimisation against a table a builder has a handful of rows in. The number stays unused.
|
|
67
|
+
|
|
68
|
+
### D6 — The ownership claim answers to the platform's own revocation switch
|
|
69
|
+
|
|
70
|
+
`hasOwnerMembership` joins `platform_identity_sso_clients` and requires `status = 'active'`. This was added after the adversarial review, and it closes the only hole in D1 that mattered: **a membership row never expires and nothing ages it out.** Once written, the only things that stop it saying `owner` are the instance reporting a lower kind at a later check-in, or the builder leaving of their own accord — neither of which is available to the platform operator dealing with a rogue or compromised project. Suspending its client (`setClientStatus`) already stops that project signing anyone in; a read *derived* from that project must answer to the same switch, or suspending a project would leave its owner reading every builder on the platform, indefinitely.
|
|
71
|
+
|
|
72
|
+
The general rule, worth stating once because the next derived permission will meet it too: **a durable row used as an authorization input needs a revocation dimension, and the right one is whatever lever already exists — not a new one.**
|
|
73
|
+
|
|
74
|
+
## Consequences
|
|
75
|
+
|
|
76
|
+
- **The opt-out is real, on both surfaces, immediately.** A public, unhidden, active account that switches "Discoverable by recruiters" off leaves `/scouting` and the hall's People directory on the next read; switching it back on restores them. The Settings copy moves to the present tense on task 1002967's own precedent, and `tests/account_privacy_flags.mjs` flips with it — the assertion pair is unchanged in kind (copy must track enforcement), only in tense.
|
|
77
|
+
- **A private account that is recruiter-discoverable is still absent.** Reach never buys addressability: the spec's D2 "private · discoverable" row is served by the D3 *sliver* (`getRecruiterSliver`, still unbuilt behind task 1002968), never by the full rollup. Fail-closed until it lands.
|
|
78
|
+
- **⚠ DELEGATES DO NOT REACH THE DIRECTORY YET, AND THAT IS AN OWNER CALL.** The goal defines "project owner" as *"the registrant of the project on the platform, plus delegates they grant"*. `src/bongos/auth-admission.js` reports `membership_kind: 'owner'` **for the instance's `firstAdminLogin()` only** — so the **registrant half works and the delegate half does not**: a second archon on a project holds `member` on the hub and reaches `/scouting` only if they separately hold `project.curate` there. Closing it means the instance reporting `owner` for *any* archon at check-in, which widens what `owner` means on a shipped surface (My Projects buckets projects by it) and changes what every federated instance tells the hub. That is a product call, not an implementation detail, so it is filed as [blocker 1000122](https://cloudbongos.com/builders#/blocker/1000122) with the delegate work itself tracked as [task 1003350](https://cloudbongos.com/builders#/task/1003350), which the blocker is linked to. **Nothing in this ADR changes when it is answered** — the gate reads whatever the instance reports, which is precisely why the registrant half could ship on its own rather than waiting behind a question only the owner can settle.
|
|
79
|
+
- **Being scoutable, and opting out of it, confer nothing.** ADR 0016 holds in both directions and is proved in both: an opted-out builder who owns a project still *reads* the directory while being absent from it — including from their own copy of it, because the read is viewer-independent and a reader must never be able to infer a privacy setting from whose list they got.
|
|
80
|
+
- **The proof lives in the DB-free lane** (`tests/scouting_owner_access_and_reach.mjs`): a fake pool whose filter is built from each statement's own `WHERE` clause, under the real route, the real `requirePermission` over the real government catalog, and real HTTP. CI has no Postgres, so a real-DB proof there would self-skip and gate nothing (task 1002282). Mutation-checked three ways — strip the reach half, kill the ownership door, drop the SQL witness term — each goes red.
|
|
81
|
+
- **`/scouting`'s recorded rank is unchanged** (`metic+archon`), because the ownership door is invisible to a static scanner and the atom is spelled where the auditor reads it. The generated API reference says the same thing it said before, which is the honest answer: the *floor* did not move, a second door opened beside it.
|
|
82
|
+
|
|
83
|
+
## Honest limits — what this task did NOT close
|
|
84
|
+
|
|
85
|
+
These came out of the adversarial review this task ran on its own diff. Each is real, each is recorded rather than quietly left, and none of them is fixed here — writing them down is the point, because a limit nobody records reads later as a guarantee.
|
|
86
|
+
|
|
87
|
+
- **⚠ THE HALL NAV STILL DOES NOT SHOW A PROJECT OWNER THE DOOR.** The page gate admits them (D3), but `modules/hall-ui/public/shell.js` still gates the People nav item on `government` (metic|archon) inside a `Community` group gated on `member` (`authed && rank !== 'xenos'`). D3's own premise is that a project owner is often a bare `xenos` — so that visitor sees neither the group nor the link, and the only way in is typing `/builders/people`. This is the same "exists but is unreachable" shape D3 exists to prevent, moved one layer up. It is **not** fixed here because the fix is a hall-UI change — a new `is_project_owner` flag on the boot payload, a gate key in `gateAllows`, and a widened group gate — that belongs with [R15](https://cloudbongos.com/builders#/task/1002291), the owner recruiting UI, which owns that surface and will need the same flag anyway. Filed as [task 1003349](https://cloudbongos.com/builders#/task/1003349). Until it lands, the widened audience is reachable by URL and by the API, not by navigation.
|
|
88
|
+
- **The write path behind the ownership claim was not tightened.** `src/bongos/auth-admission.js` reports `owner` for whoever matches the instance's `FIRST_ADMIN` config — not for whoever actually holds archon — and `touchMembership` writes whatever kind the authenticated client sends, with no cap on how many `owner` rows one client may hold. So a provisioned instance can name arbitrary GitHub accounts as its owner over time. This is a **pre-existing** property of a shipped, cross-version wire contract (ADR 0205); what this task changed is that the field is now an *authorization input*, which is why it is named here rather than left implicit. Two things bound it: client registration is operator-run, and D6's suspension join is the lever that revokes a whole rogue project at once. Tightening `touchMembership` itself belongs with the delegate decision in blocker 1000122, since both are about what an instance may assert.
|
|
89
|
+
- **`openapi.json` cannot express "or owner".** The generated `x-permissions: ["project.curate"]` and its "all required" phrasing survive, because the generator classifies a route from its middleware and there is no field for a second, non-permission audience. A client generating an access check from that field gets an over-strict answer — wrong, but erring safe.
|
|
90
|
+
- **The ownership probe runs before the rank check for every caller,** including the operator path, and on every denied `/builders/people` load. Deliberate: the ordering is what keeps the 403 byte-identical (D2 §1). It is one indexed lookup on a table where a builder has a handful of rows.
|
|
91
|
+
|
|
92
|
+
## Rejected alternatives
|
|
93
|
+
|
|
94
|
+
- **Lower `project.curate`'s floor further (to thetes or xenos).** The obvious reading of "widen the Archon-only /scouting", and wrong: a floor is a rank, a project owner is a relationship, and no floor low enough to admit a registrant excludes anyone at all. It would have delivered "everyone browses the talent directory" and called it C5.
|
|
95
|
+
- **Mint a `project.owner` atom.** ADR 0201 D3 already rejected exactly this ("a new 'project owner' permission atom") for the reason that still holds: ownership is per-project and grantable only by the project, so an atom on the hub would be a second place ownership is asserted, free to drift from the one the instance reports.
|
|
96
|
+
- **Mint `project.recruit` here.** The right atom for a recruiting surface, and it belongs to the console wave that defines it (spike 1002786 R3). Minting it in this task would fork its definition across two waves before either had shipped.
|
|
97
|
+
- **Gate on `platform_identity_projects.owner_github_id`.** The one implementation ADR 0201 D3 names and forbids; its own column comment says advisory. See D1.
|
|
98
|
+
- **Fold reach into `rollupVisibleSql`.** One predicate instead of two, and it silently breaks the leaderboards the community spec D3 explicitly protects. See D4.
|
|
99
|
+
- **Widen `membershipKind()` to report every archon as `owner`.** The fix for the delegate gap, and it changes a cross-version wire contract plus the meaning of a value a shipped surface buckets on. Filed as blocker 1000122 instead of guessed.
|
|
100
|
+
- **Leave the page gate alone and ship the route.** Half a graduation: the audience the criterion names would hold an API they could not open a page for.
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# ADR 0212 — The connections section is built on the profile page that exists, and it addresses a builder by asking the API to name them
|
|
2
|
+
|
|
3
|
+
- **Status:** accepted
|
|
4
|
+
- **Date:** 2026-08-29
|
|
5
|
+
- **Task:** [task 1002296](https://cloudbongos.com/builders#/task/1002296) (R20 · goal 1000045 — Public builder profiles, criterion C6)
|
|
6
|
+
- **Amends:** [ADR 0202](<redacted>.md) D4 (the design of record) and [ADR 0207](<redacted>.md) (the API's own departures). Neither decision changes; this records where the *rendering* had to choose, and what the chosen surface cannot yet do.
|
|
7
|
+
- **Relates to:** ADR 0016 (a connection is never authority), ADR 0171 D4/D5 (leak by subtraction; no-store), `docs/specs/<redacted>.md` (the page spec whose section 6 this is)
|
|
8
|
+
|
|
9
|
+
> ADR **0210** and **0211** were spoken for by sibling sessions in flight; this ADR takes 0212 rather than race them (the ADR 0199 / 0202 / 0207 precedent).
|
|
10
|
+
|
|
11
|
+
## Context
|
|
12
|
+
|
|
13
|
+
R20 says "connections on the profile". The profile-page spec's section table marks section 6 **Hub ✓ / Instance —**, meaning the canonical `cloudbongos.com/u/<handle>` page. That page **does not exist**: it is goal 1000048 R14 / [task 1002365](https://cloudbongos.com/builders#/task/1002365), and [task 1002806](https://cloudbongos.com/builders#/task/1002806) established with evidence that there is no route, no page, no hub search entry and no sitemap row for it. Meanwhile `modules/hall-ui/public/profile.html` — the live profile at `/builders/profile` — carries a comment R04 left there on purpose: *"Reserved slots — connections, guilds, and actions (profile-page spec sections 6–8) mount here in later phases."*
|
|
14
|
+
|
|
15
|
+
Two problems follow, and they are the only two this ADR decides.
|
|
16
|
+
|
|
17
|
+
1. **Which page.** Build nothing and wait for a page nobody has scheduled, or build into the slot that was reserved for exactly this.
|
|
18
|
+
2. **How to name the other builder.** Every connections route addresses a counterparty by **handle** (ADR 0202 D1, ADR 0171 D8). The profile page's read, core `GET /builders/:id/profile`, whitelists nine public columns and **`handle` is not among them** — it knows only `github_login`. Widening that whitelist is [task 1002804](https://cloudbongos.com/builders#/task/1002804)'s canonical-URL work, not this one's.
|
|
19
|
+
|
|
20
|
+
## Decision
|
|
21
|
+
|
|
22
|
+
### D1 — Section 6 lands on the hall profile page, revealed only where the hub answers
|
|
23
|
+
|
|
24
|
+
The section mounts into R04's reserved slot on `modules/hall-ui/public/profile.{html,js}`, as a separate script (`profile-connections.js`) so neither the page script nor `platform-identity.js` grows against its line ratchet.
|
|
25
|
+
|
|
26
|
+
It follows the **cross-project record's** contract exactly, because that is the precedent for a hub-only section on this page: the markup ships `hidden`, and the script reveals it only when the hub's routes answered with something to show. On an instance without `platform-identity` every call 404s and the section never appears — degrade, don't 500 (profile-page spec constraint 4).
|
|
27
|
+
|
|
28
|
+
This does **not** pre-empt the canonical page. When `/u/<handle>` is built it renders the same three reads; two surfaces consuming one own-scope API is the composition ADR 0202 D4 §4 already describes (a viewer-relative number cannot ride a viewer-independent public response, so the *page* composes it from a second authenticated call — which is true of whichever page does the composing).
|
|
29
|
+
|
|
30
|
+
**Rejected.** *Waiting for the hub page* — R20's dependents ([task 1002298](https://cloudbongos.com/builders#/task/1002298), the C6 proof) would block on a task in a different goal with no owner. *Building `/u/<handle>` here* — the spec says in terms that only one such page gets built, and it is not this task's.
|
|
31
|
+
|
|
32
|
+
### D2 — The page hands the API the login it holds; the API hands back the handle, and every write uses that
|
|
33
|
+
|
|
34
|
+
`profile-connections.js` calls `GET /connections/degree/:handle` with the **github_login** it already renders on screen, and then uses the **`handle` the response echoes** for the connect write — never the login it started from.
|
|
35
|
+
|
|
36
|
+
This works because `findAccountByHandle` resolves the current handle *or* the single-slot `previous_handle`, and the route answers with `target.handle`. The API's own header says why it is built that way: *"an ACTION endpoint follows a rename rather than answering 301, so a stale 'Connect' link on a page still works."* Asking it to resolve a login is the same move a stale link makes.
|
|
37
|
+
|
|
38
|
+
The failure mode is the right one. A login that resolves to neither slot answers **404 — the same 404 as an unknown handle, a provisional account, and a private stranger** — and on all of them the section stays hidden and no connect button is drawn. So the degradation is exactly the set of cases where the button must not render anyway, plus one it arguably should: **a builder who renamed their handle twice loses the connect affordance on this surface.** That is stated here rather than discovered later. The fix is not a login→handle oracle; it is the handle-addressed page (D1), which never makes this hop.
|
|
39
|
+
|
|
40
|
+
**Rejected.** *Adding `handle` to `GET /builders/:id/profile`* — a core kernel file, its leak test, and the `rel=canonical` wiring that task 1002804 owns; two sessions editing that whitelist in the same week is the collision this coordination exists to avoid. *Putting `handle` inside `cross_project`* — that payload is `null` for a hidden account, and ADR 0202 D4 §3 is explicit that `hide_stats` suppresses the numbers beside an edge and never the edge, so the handle would vanish precisely where the section must still work. *Resolving through the public `GET /profiles/:handle`* — it 404s for a private account, including one the viewer is connected to, which D4 §2 says is addressable to them.
|
|
41
|
+
|
|
42
|
+
### D3 — Three rendering rules, each the last place a privacy decision can be undone
|
|
43
|
+
|
|
44
|
+
The API can only keep its promises if the screen keeps them too. Three are enforced by `tests/profile_connections_ui.mjs` rather than by memory.
|
|
45
|
+
|
|
46
|
+
1. **`degree: null` renders "Out of network", never "not connected"** — ADR 0202's Consequences, made literal. And the copy beside it is **constant**: it must read identically whether the response said `truncated` or not, because the target's own 1-hop truncation is deliberately unreported (ADR 0207 L2) and copy that varied with the flag would rebuild that side channel in words. A degree the API never defined falls to the same label rather than being invented one.
|
|
47
|
+
2. **A sent request claims exactly `201 { status: 'pending' }`** — "Request sent", disabled, and nothing about notification or acceptance. A re-ask swallowed by the 90-day cooldown answers identically and writes nothing, so on the next load the button is simply back to "Connect" **with no explanation anywhere** (ADR 0207 L1). Adding one would be the decline leaking through the UI after the API refused to leak it.
|
|
48
|
+
3. **Every count is the length of the list beside it**, and `seen_at` and `truncated` never reach the screen.
|
|
49
|
+
|
|
50
|
+
## Consequences
|
|
51
|
+
|
|
52
|
+
- Criterion C6's UI half exists on a real page today, and the canonical page inherits it as three own-scope calls rather than a rewrite.
|
|
53
|
+
- One honest gap, named above: a twice-renamed handle has no connect button on the instance profile. It fails closed, and it disappears when `/u/<handle>` lands.
|
|
54
|
+
- The section is descriptive only. Nothing it renders is read by `requireRank`, `requirePermission` or any admission gate, and nothing it renders is rollup-derived — which is why `hide_stats` does not suppress it (ADR 0016, ADR 0202 D4 §3).
|
|
55
|
+
- The proof runs the real router over a WHERE-derived fake pool on a real socket with the real client above it, so a change that makes the *API* less careful — a private stranger becoming addressable, the cooldown ceasing to swallow, the caller's consent floor dropping — fails this UI test too, not only the API's own.
|
|
56
|
+
|
|
57
|
+
## Deferred (named, not silently dropped)
|
|
58
|
+
|
|
59
|
+
- **A public per-builder connections list.** There is deliberately no such route: `GET /connections` is own-scope, so another builder's profile shows the viewer-relative label and the mutuals the viewer is entitled to, and nothing else. Publishing someone's connection list is a privacy-matrix question for the owner, not a builder (ADR 0202's "connections-specific visibility toggle" deferral).
|
|
60
|
+
- **Removing a connection from the other person's profile.** Removal lives on your own list, where the row and its id already are; putting it on their page would need a third read for one button.
|
|
61
|
+
- **Section 7 (guilds) and the wider section 8 actions row.** C7 and the governance-gated invite are their own tasks; the slot comment still names them.
|
package/docs/adr/README.md
CHANGED
|
@@ -297,5 +297,7 @@ This keeps the decision history honest and traceable.
|
|
|
297
297
|
| 0208 | [**Building the application lifecycle: the handle waits for its writer, and the vouch ADR 0201 D2.1 describes has no minter that can express it** ([task 1002284](https://cloudbongos.com/builders#/task/1002284), goal 1000045 — R08, criteria C3/C4). Builds [ADR 0201](<redacted>.md) D1 + D4 + D3.2 and records where the design could not be written literally. **The contradiction:** D1.2 says the migration adds "exactly" `applicant_github_id` + `vouched_at`; D2.6 says the applicant's handle is stored on the row. Decision — the handle waits for R09 ([task 1002285](https://cloudbongos.com/builders#/task/1002285)), because the ADR's own dependency table routes D2 *wholesale* (including the handle) there, and because R08 ships no vouch writer at all: a permanently-NULL `applicant_handle` would read as "this account has no handle" when it means "R09 has not shipped". **The hole:** D2.1's replay separation cannot be built as described. `mintHubAssertion` takes no `purpose`, and the asymmetry protecting `mintHubLogoutToken` works only because a logout token carries no identity — an application vouch carries both, so it collides with the login assertion's shape; worse, `verifyHubAssertion` does not reject a payload carrying a `purpose`, so a vouch minted that way would verify *as a login assertion* today. R09 needs a third mint/verify pair plus a tightening of `verifyHubAssertion` (free only while no minter emits a `purpose`), and a deliberate choice about the single-use `jti` store. Also decided: the echo's READ joins `platform_identity_sso_clients` (a `client_id` alone is not renderable) while the TABLE stays the three columns D1.3 named; a re-application refreshes `applied_at`, so the 30-day window runs from the most recent ask; an idempotent PATCH returns the row **without** re-stamping `resolved_by`/`resolved_at`, so the audit trail records who actually resolved it; `dismissed → invited` is refused `409 already_dismissed` naming `POST /access-requests/invite`; and `GET /my-projects` drops from `applied` any project the caller now builds on, so the hub never implies an outcome it is not allowed to hold. Honest limits: `vouched` is false for every real row until R09 lands (the two-class queue is proved by a seeded row, not by a live writer); `already_approved` still reaches the hub as "already_member" prose through `joinRelayOutcome`'s bare-409 branch, which is R10/R12's surface; and the proofs are DB-free, so the partial unique index is modelled rather than exercised — five mutations were run to show the fake pool bites. Rejected: adding `applicant_handle` anyway; bolting a `purpose` argument onto `mintHubAssertion`; a `status` column on the echo; refusing `dismissed → invited` with the existing `not_pending`; a new "project owner" atom.](<redacted>.md) | platform identity / admission lifecycle |
|
|
298
298
|
| 0209 | [**Two routes answering one question spend one budget; whether the answer itself collapses is the owner's call** ([task 1003339](https://cloudbongos.com/builders#/task/1003339), goal 1000045 — criterion `i-control-my-own-access-and-exposure`). `GET /access-requests/status` (public, returns `member|invited|pending|dismissed|none`) and `GET /auth/web/admission-status` (its bare-boolean twin) both answer "does this GitHub login have an account here?", and neither was throttled — `rateLimitMiddleware` returns early for every non-write method, and the one read limiter is mounted only under `/public/*`. Scripted at line rate either becomes a login→membership roster: who builds here, and confirmation that a named person is present. Decision: ship the ceiling as ONE budget — `accountExistenceReadRateLimit`, 120 reads/60s per IP over a single bucket, exported as one middleware INSTANCE rather than a factory and mounted on both routes, because two limiters each configured 120/60s look identical in a diff, pass any test that counts to the limit per route, and hand an enumerator 240/min by alternating. The onboarding module reaches it through the doorway, so `src/bongos/middleware/rate-limit.js` joins `KERNEL_FILES` on `logger.js`'s argument: authz decides WHO may ask, the ceiling decides HOW OFTEN, and the file imports nothing. Deliberately NOT decided here: collapsing the response so a non-subject caller cannot tell `pending`/`dismissed`/`none` from "not admitted" — the consumer sweep found route 1 has exactly ONE consumer (`scripts/gds/login.js`), which already treats `pending` and `none` identically, so collapsing costs exactly one thing: the only message in the codebase that tells a declined applicant they were declined. That is a judgement about what an applicant is owed, so it goes to the owner as a blocker with the evidence. Noted either way: collapsing does not close the oracle — `admitted:true` still means member-or-invited. Rejected: a separate limiter per route; a `createReadRateLimit` factory (two matching call sites get two buckets — the generality loses the property); vendoring a fourth copy of the sliding window; hardening only the smaller route; collapsing on my own judgement because "nothing much" reads it.](<redacted>.md) | security / rate limiting |
|
|
299
299
|
| 0207 | [**Building the connection graph: three departures from ADR 0202, and the three limits it is honest about** ([task 1002295](https://cloudbongos.com/builders#/task/1002295), goal 1000045 criterion C6 — the build of the connections wave). ADR 0202 decided the graph; building it surfaced three places where the decision's MECHANISM could not be written literally without losing the PROPERTY it was about, and three places where the shipped code cannot promise as much as a casual reading suggests. Departures: (1) **level-wise CTEs, not `WITH RECURSIVE`** — a recursive term can carry neither a per-level fan-out cap nor an ordered per-level truncation, which are the two things D3 actually decided, so a recursive build would have had a depth bound and no fan-out bound; a test asserts `RECURSIVE` appears in neither query. (2) **1st degree is an unbounded pair-index probe**, never a scan of the capped frontier — reading it off the `FANOUT_1`-truncated 1-hop reports "2nd" for someone in the viewer's own connections list once they pass 500 connections, and the rule this states is that **a cap may only ever make an answer less specific (`null`), never wrong**. (3) The shared visibility predicate lands in its own file, `modules/platform-identity/account-visibility.js` (`isAccountVisible` / `accountVisibleSql` / `accountActiveSql`), because task 1002967 had not shipped and `platform-identity.js` sat at the size ratchet with zero headroom — **1002967 adopts it rather than writing a second predicate**. Also decided: node visibility during traversal is viewer-relative and asymmetric on purpose (your OWN 1-hop floors on consent alone so a friend who goes private does not vanish from your list; every deeper hop clears the public predicate); cancel is a `DELETE` an ADDRESSEE may not use, so the 90-day cooldown cannot be dodged by calling a decline a delete; and the create response is `201 { status: 'pending' }` and nothing else, so a swallowed re-ask has nothing to fabricate. Limits stated rather than implied: a swallowed re-ask is silent in the RESPONSE and observable by its ABSENCE from the requester's outbox (the claim is narrowed, not the code — a decoy row would be a lie in the database to protect a lie in the API); the TARGET's 1-hop is capped too and that truncation is deliberately NOT reported, because saying so leaks one bit about a third party's degree; and the query COUNT is bounded while the frontier's WORK is O(sum of degree over the 1-hop), since `ORDER BY … LIMIT` materialises before it truncates — the revisit trigger is a measured p95, and the cheap next move is a `statement_timeout` returning the `null` D3 already chose. Deferred and named: a per-builder cap on OUTBOUND requests (the cooldown stops repeat asks to one person, not one builder asking a hundred — a real number is owner-signed), and a real-Postgres pass on the expire-then-insert write path, which CI cannot run.](<redacted>.md) | platform identity / social graph / privacy |
|
|
300
|
+
| 0212 | [**The connections section is built on the profile page that exists, and it addresses a builder by asking the API to name them** ([task 1002296](https://cloudbongos.com/builders#/task/1002296), R20 · goal 1000045 criterion C6 — the UI half of the connections wave). The profile-page spec puts section 6 on the canonical `cloudbongos.com/u/<handle>`, which [task 1002806](https://cloudbongos.com/builders#/task/1002806) proved does not exist (no route, no page, no search entry, no sitemap row) and which belongs to goal 1000048 R14. Decisions: (1) **section 6 lands in the reserved slot R04 left on the hall profile page** (`modules/hall-ui/public/profile.{html,js}`), as a separate `profile-connections.js` so neither the page script nor `platform-identity.js` grows against its line ratchet, and it follows the CROSS-PROJECT section’s contract exactly — the markup ships `hidden` and reveals only where the hub answered, so an instance without `platform-identity` never renders it. It does not pre-empt the canonical page, which inherits the same three own-scope calls. (2) **Addressing: the page hands the API the `github_login` it holds and uses the `handle` the response echoes back for every write** — core `GET /builders/:id/profile` whitelists nine columns and `handle` is not one of them, and widening that whitelist is task 1002804’s canonical-URL work. `findAccountByHandle` already resolves current-or-previous handle and the route answers with the canonical one, which is the same move the API built for a stale Connect link. It fails closed into the deliberately indistinguishable 404 (unknown / provisional / private stranger), so the ONE honest gap is stated rather than discovered: **a twice-renamed handle has no connect button on this surface**, and it disappears when `/u/<handle>` lands. (3) Three rendering rules, enforced by `tests/profile_connections_ui.mjs` (real router over a WHERE-derived fake pool on a real socket, real client above it): `degree: null` renders "Out of network" and never "not connected", with copy that is BYTE-IDENTICAL whether `truncated` was set — varying it would rebuild the ADR 0207 L2 side channel in words; a sent request claims exactly `201 { status: 'pending' }`, so a re-ask swallowed by the 90-day cooldown simply shows "Connect" again next load with no explanation anywhere; and every count is the length of the list beside it, with `seen_at` and `truncated` never reaching the screen. Rejected: waiting for the hub page (it would block C6’s proof on another goal); building `/u/<handle>` here (the spec says only one such page gets built); adding `handle` to the core profile whitelist or to `cross_project` (the latter is `null` under `hide_stats`, which is exactly where the edge must still show — ADR 0202 D4 §3); resolving through public `GET /profiles/:handle` (it 404s for a private account the viewer IS connected to). Deferred and named: a public per-builder connections list (there is deliberately no such route), removal from the other person’s profile, and sections 7–8.](<redacted>.md) | platform identity / social graph / hall UI |
|
|
301
|
+
| 0210 | [**The recruiting directory graduates on an instance-asserted ownership claim, and the recruiting opt-out gates the directory and nothing else** ([task 1002290](https://cloudbongos.com/builders#/task/1002290), R14, goal 1000045 criterion C5 — "project owners, not only Archons, browse builder profiles to recruit"). Two independent problems wore one task id. The reach flag `recruiter_discoverable` protected nobody — task 1002967 said so, and the Settings row stayed in the future tense because of it. And "widen the Archon-only /scouting" was already stale (ADR 0157 lowered `project.curate` to metic) while hiding the real question: **a project owner is not a rank**, and someone who registers a project is an Archon on their OWN instance and usually a bare `xenos` on the hub where the directory lives, so no floor reaches them without reaching every stranger. **D1** "project owner" on the hub is `<redacted>.membership_kind = 'owner'` — written by the PROJECT itself at `POST /sso/membership/check-in` from its own first-admin config, and refused unless the hub's own `signin_verified_at` witness stands (ADR 0205) — and never `platform_identity_projects.owner_github_id`, the hub-curated column ADR 0201 D3 names and forbids; the prohibition is about a hub-held list consulted before an ADMISSION on someone else's box, and this is the hub reading the instance's own mirrored claim before a read of the hub's OWN directory. **D2** the two doors are an OR in `modules/platform-identity/scouting-authz.js` (the `goal-authz.js` owner-or-rank shape, kept out of `platform-identity.js` at its size ratchet): ownership is asked first and a "no" falls through to the UNMODIFIED `requirePermission`, so a refusal keeps its exact code, `required`/`held` and floors map — the widening can only admit; the atom is spelled in the ROUTE's middleware list, because a gate hidden in the helper would make `route-rank-check.js` and `gen-api-docs.js` record this privileged route as `any-builder`; and a broken ownership read falls closed onto the rank door rather than admitting or 500ing. No atom is minted — `project.recruit` belongs to the console wave (spike 1002786 R3). **D3** `PAGE_GATES` gains an optional `alsoAdmits` naming a function on the `platform-identity.hub` port, declared on `requireProjectCuratePage` alone and consulted only after the permission check refused, so the `/people` shell admits exactly the two audiences its data route does; absent port, absent function or a throw all deny. **D4** the reach COALESCE gets its SQL twin in `account-visibility.js` (its sole owner) and composes into `scoutingListableSql` = rollup AND reach, which `listScoutingRollup` joins — but reach is deliberately NOT folded into `rollupVisibleSql`, because the community spec D3 says the opt-out governs the recruiting surface only and folding it down would silently empty a future public leaderboard. **D5** no migration: both columns exist and migration 003's `(github_id)` index already serves the probe. **D6** the ownership query JOINs `platform_identity_sso_clients` on `status='active'` — a membership row never expires and nothing ages it out, so suspending the client (the lever that already stops a project signing anyone in) had to revoke the read it derives, or suspending a rogue project would leave its owner reading every builder on the platform forever. The task's own adversarial review also found the sole-owner drift guard was matching only the UNALIASED spelling of the reach COALESCE — i.e. not the one that ships — and two fail-open defaults (a prototype-reachable `alsoAdmits`, and an alias guard resting on template evaluation order); all three fixed and mutation-checked. ⚠ Honest limits recorded, not fixed: the hall NAV still hides People from a project owner (task 1003349, sequenced with R15 which owns that surface), the `touchMembership` write path stays as ADR 0205 left it, and `openapi.json` cannot express "or owner". ⚠ And DELEGATES do not reach the directory yet (blocker 1000122 + task 1003350, linked) — an instance reports `owner` for its first admin only, so the registrant half works and the delegate half needs the instance to report `owner` for any archon, which changes a cross-version wire contract and a shipped bucketing; filed as blocker 1000122, and nothing here changes when it is answered. Rejected: lowering the floor further (a floor low enough to admit a registrant excludes nobody); a `project.owner` atom (ADR 0201 D3 rejected it); minting `project.recruit` here; gating on `owner_github_id`; folding reach into the rollup predicate; widening `membershipKind()` on our own judgement; shipping the route without the page.](<redacted>.md) | platform identity / authz + privacy |
|
|
300
302
|
|
|
301
303
|
> ⚠️ **Numbering collisions are now machine-enforced, not narrated.** Twenty numbers are shared by two ADRs each — assigned in parallel sessions before anything checked. The files keep their filenames (renumbering would break every existing citation, and a number once assigned is never reused), and this table disambiguates each pair as `NNNN-a` / `NNNN-b`. The authoritative list is `LEGACY_DUPLICATE_ADRS` in [`scripts/gds/adr-namespace.js`](../../scripts/gds/adr-namespace.js), frozen by exact filename: a **new** collision — or a third file joining a legacy number, or a rename of either half — hard-fails the `unit` CI gate, as does an index row that points at no file or an ADR with no row. The list may only shrink. This paragraph used to narrate the collisions one by one and had fallen eight behind reality; see [ADR 0195](<redacted>.md) for why the check exists and why the pairs are grandfathered rather than renumbered.
|
package/docs/api/openapi.json
CHANGED
|
@@ -12055,7 +12055,7 @@
|
|
|
12055
12055
|
"scouting"
|
|
12056
12056
|
],
|
|
12057
12057
|
"summary": "GET /scouting",
|
|
12058
|
-
"description": "the internal talent directory across every project.
|
|
12058
|
+
"description": "the internal talent directory across every project. Read by a `project.curate` holder OR a project owner (ADR 0210, task 1002290); a caller who is neither gets the unchanged permission refusal before any cross-builder data is read.\n\n**Rank:** `metic+archon` — Metic or Archon rank (review/triage powers).\n\n**Permissions:** `project.curate` (all required).",
|
|
12059
12059
|
"x-rank": "metic+archon",
|
|
12060
12060
|
"x-source": "modules/platform-identity/routes/scouting.js",
|
|
12061
12061
|
"x-permissions": [
|