@bongos/core 1.19.427 → 1.19.429

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 (36) hide show
  1. package/.bongos-core.json +75 -35
  2. package/clients/bongos-client/index.d.ts +1 -1
  3. package/docs/adr/0213-platform-terms-acceptance-is-the-hubs-own-witness.md +71 -0
  4. package/docs/adr/0214-canonical-profile-url-one-resolver-dark-until-the-page-exists.md +90 -0
  5. package/docs/adr/README.md +2 -0
  6. package/docs/api/openapi.json +3 -2
  7. package/docs/copy-inventory.md +45 -36
  8. package/docs/copy-registry.json +127 -46
  9. package/docs/file-map.md +3 -1
  10. package/docs/module-api-changelog.md +4 -0
  11. package/modules/hall-ui/public/profile-canonical.js +137 -0
  12. package/modules/hall-ui/public/profile-connections.js +20 -9
  13. package/modules/hall-ui/public/profile.html +9 -0
  14. package/modules/hall-ui/public/profile.js +5 -0
  15. package/modules/platform-identity/account-visibility.js +31 -12
  16. package/modules/platform-identity/canonical-profile.js +143 -0
  17. package/modules/platform-identity/connections.js +4 -3
  18. package/modules/platform-identity/migrations/platform_identity_019_terms_acceptance.sql +47 -0
  19. package/modules/platform-identity/platform-identity.js +12 -10
  20. package/modules/platform-identity/routes/connections.js +4 -3
  21. package/modules/platform-identity/routes/sso.js +7 -0
  22. package/modules/platform-identity/terms-acceptance.js +97 -0
  23. package/package-lock.json +2 -2
  24. package/package.json +1 -1
  25. package/public-landing/terms.html +8 -0
  26. package/src/bongos/routes/builders.js +24 -2
  27. package/src/bongos/serve-internal.js +28 -2
  28. package/src/module-api.js +1 -1
  29. package/tests/canonical_profile_url.mjs +628 -0
  30. package/tests/connections_api.mjs +13 -8
  31. package/tests/connections_ownership.mjs +6 -1
  32. package/tests/effective_visibility_predicate.mjs +89 -14
  33. package/tests/profile_connections_ui.mjs +60 -7
  34. package/tests/profile_privacy_owner_view.mjs +31 -4
  35. package/tests/profile_route.mjs +45 -13
  36. package/tests/terms_acceptance.mjs +573 -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.427",
6
- "core_contract": "1.19.427",
7
- "source_commit": "f0b176008c51428ad1c344b7704d0e9462c0ee90",
5
+ "core_version": "1.19.429",
6
+ "core_contract": "1.19.429",
7
+ "source_commit": "c9d9b31a6f80bb459bf490c45544f2edd344e056",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-29T19:37:42.056Z",
9
+ "built_at": "2026-08-29T20:59:30.558Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
- "docs_redacted": 362,
12
+ "docs_redacted": 364,
13
13
  "agent_docs_stubbed": 23,
14
- "functional_verbatim": 1731,
14
+ "functional_verbatim": 1737,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 2116,
20
- "tree_sha256": "96e06bbbfd609aa582c5ac2461392edc56e3c51afdbdb2d2551165711fd75276",
19
+ "file_count": 2124,
20
+ "tree_sha256": "e575e80b0436081de41f6f6833a4a4f94d9420d69cb3357a68a33f4330902fa1",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -372,7 +372,7 @@
372
372
  {
373
373
  "path": "clients/bongos-client/index.d.ts",
374
374
  "mode": "0000644",
375
- "sha256": "402be6a9f85c5434bb183a378c9a1631b9a3ca336487c04f969581d9d8041663"
375
+ "sha256": "0bd5a2773bde6bc126a6351d20f5ce30049d2a8bc9a8ba2154f74af9cb1401b2"
376
376
  },
377
377
  {
378
378
  "path": "clients/bongos-client/index.mjs",
@@ -1559,10 +1559,20 @@
1559
1559
  "mode": "0000644",
1560
1560
  "sha256": "1251b6ba7b5fc79765aed5fcf9696d6f55d52acf6d6766200215a480f2d574a3"
1561
1561
  },
1562
+ {
1563
+ "path": "docs/adr/0213-platform-terms-acceptance-is-the-hubs-own-witness.md",
1564
+ "mode": "0000644",
1565
+ "sha256": "c3aba5879f639ea409d80bc8545085fc1892fe191868ac69c9be52507750f33a"
1566
+ },
1567
+ {
1568
+ "path": "docs/adr/0214-canonical-profile-url-one-resolver-dark-until-the-page-exists.md",
1569
+ "mode": "0000644",
1570
+ "sha256": "b964e1674d615cb4525e94bb4cac63d4ac0813e3c1ea9236620bf030d7c53124"
1571
+ },
1562
1572
  {
1563
1573
  "path": "docs/adr/README.md",
1564
1574
  "mode": "0000644",
1565
- "sha256": "c4216e812144d5ed7b95c3ffd25c124b8e7a1f97610721e3b7ab938751f881c7"
1575
+ "sha256": "9b2d411decbc309773631064ed286ee0d134860ec688204ac45b827a28748d7a"
1566
1576
  },
1567
1577
  {
1568
1578
  "path": "docs/api-reference.md",
@@ -1572,7 +1582,7 @@
1572
1582
  {
1573
1583
  "path": "docs/api/openapi.json",
1574
1584
  "mode": "0000644",
1575
- "sha256": "1a80f11c01764ec8e1542f9f5b3b2b37123656778646dd316c1e32180973b310"
1585
+ "sha256": "6b3d91999080e09913f97a766b097e26ff9ae941d4026ab7b025c6dc4a7f9996"
1576
1586
  },
1577
1587
  {
1578
1588
  "path": "docs/architecture.md",
@@ -1597,12 +1607,12 @@
1597
1607
  {
1598
1608
  "path": "docs/copy-inventory.md",
1599
1609
  "mode": "0000644",
1600
- "sha256": "054f4d38105b2bfad142c1a72b982f09e78bd3b14074eac1b2a001c49b8cd2bf"
1610
+ "sha256": "6192d6ab0115cd57a4dce5a61da2de1084ba6ccfa860356b8d54045ebd3609aa"
1601
1611
  },
1602
1612
  {
1603
1613
  "path": "docs/copy-registry.json",
1604
1614
  "mode": "0000644",
1605
- "sha256": "e6fbf0872ac28c4931fbec5bc335b50283d4e0b1d7247e300612eb0c6ec653d0"
1615
+ "sha256": "9f52cecfbab533846f8e182676801940c94141c2fb6096b745e73aa0423688a9"
1606
1616
  },
1607
1617
  {
1608
1618
  "path": "docs/design/apex-pass-2-direction.md",
@@ -1947,7 +1957,7 @@
1947
1957
  {
1948
1958
  "path": "docs/file-map.md",
1949
1959
  "mode": "0000644",
1950
- "sha256": "4690b1019567fe7e15f8e7a3c873b9cef94534bd07124545a388f9cd8bf85918"
1960
+ "sha256": "23121260ec5061645027d50609712217a619ea9ae8ad539cdeeb18e1cd552755"
1951
1961
  },
1952
1962
  {
1953
1963
  "path": "docs/handoff-template.md",
@@ -1957,7 +1967,7 @@
1957
1967
  {
1958
1968
  "path": "docs/module-api-changelog.md",
1959
1969
  "mode": "0000644",
1960
- "sha256": "3af56594001e0448960dd5999bf603e94c008f31d7c24997e6af2d4e0f52f550"
1970
+ "sha256": "b827125881354448b4c3e19a16a6b0a27210386869d33a09fa0d488812745b6a"
1961
1971
  },
1962
1972
  {
1963
1973
  "path": "docs/modules-contract.md",
@@ -4119,10 +4129,15 @@
4119
4129
  "mode": "0000644",
4120
4130
  "sha256": "40f6f44283aa7dba25a2c983d77b6e6f53f40eefef3394122d34f7f42fc5f676"
4121
4131
  },
4132
+ {
4133
+ "path": "modules/hall-ui/public/profile-canonical.js",
4134
+ "mode": "0000644",
4135
+ "sha256": "4f010fd2a4e77447839eb77f5e294ea62c525fc47f392e916790f37078a92726"
4136
+ },
4122
4137
  {
4123
4138
  "path": "modules/hall-ui/public/profile-connections.js",
4124
4139
  "mode": "0000644",
4125
- "sha256": "b6ff1827da188dbd5a36091e6f5634d43ac21d386cc04b553b6f46c92827dff2"
4140
+ "sha256": "1f52e92aea4583273d719538c453d4d11313f15d583536316c75ef4d625a8265"
4126
4141
  },
4127
4142
  {
4128
4143
  "path": "modules/hall-ui/public/profile-nudge.js",
@@ -4132,12 +4147,12 @@
4132
4147
  {
4133
4148
  "path": "modules/hall-ui/public/profile.html",
4134
4149
  "mode": "0000644",
4135
- "sha256": "3b48f9fedd44d702a4323bd7cbdc0e3f570ca50d2940c6f22508d6c5bc482f8e"
4150
+ "sha256": "236e3da37f6e8b57525a7d0bc9cc07cea25db4a983770ed965409df8b9d5f655"
4136
4151
  },
4137
4152
  {
4138
4153
  "path": "modules/hall-ui/public/profile.js",
4139
4154
  "mode": "0000644",
4140
- "sha256": "0885bb6b92dfdc54b9177b0cb949a692ebc8025a019f309d6d786f9dfaf2442d"
4155
+ "sha256": "6433253093196f674142979c881ea8dc735d17841aeb99ddbc0e69b621b4b7e0"
4141
4156
  },
4142
4157
  {
4143
4158
  "path": "modules/hall-ui/public/ranks.html",
@@ -4812,17 +4827,22 @@
4812
4827
  {
4813
4828
  "path": "modules/platform-identity/account-visibility.js",
4814
4829
  "mode": "0000644",
4815
- "sha256": "04deda249e65ceb416064ecb503ff101600c57ae84be9bf2ecab1de4b6701879"
4830
+ "sha256": "788d0a51e29e0bd7971a63b8ed168ce71beb2a30a170bd0dbfc457d96b275233"
4816
4831
  },
4817
4832
  {
4818
4833
  "path": "modules/platform-identity/application-echo.js",
4819
4834
  "mode": "0000644",
4820
4835
  "sha256": "3793f588d62eaa96a74da6bceb55043e8a9de35bc120dbe63f26bcc2b7e904da"
4821
4836
  },
4837
+ {
4838
+ "path": "modules/platform-identity/canonical-profile.js",
4839
+ "mode": "0000644",
4840
+ "sha256": "2aa433ba293545a313972f88b1143d74551cb6ec4e06747906ff7e3c365f4e6a"
4841
+ },
4822
4842
  {
4823
4843
  "path": "modules/platform-identity/connections.js",
4824
4844
  "mode": "0000644",
4825
- "sha256": "3bdd5edf87ff2095035e8945afb7d3a0779cbb9f691e6e634f12a6500edeb21b"
4845
+ "sha256": "ed671aad4b6af46e56e29d5427ac2d5bdfc3ed414dbb981a27fce5097946e7ae"
4826
4846
  },
4827
4847
  {
4828
4848
  "path": "modules/platform-identity/hub-device.js",
@@ -4924,6 +4944,11 @@
4924
4944
  "mode": "0000644",
4925
4945
  "sha256": "d5553c3d06e8d71b89e62db54a9a3a3ae9810d3f29b262c97dca1f8242aca879"
4926
4946
  },
4947
+ {
4948
+ "path": "modules/platform-identity/migrations/platform_identity_019_terms_acceptance.sql",
4949
+ "mode": "0000644",
4950
+ "sha256": "112fd5dcc9d2bfbc8ec064963e1a783d8ef7855a1a47cf6575ea70da06803d68"
4951
+ },
4927
4952
  {
4928
4953
  "path": "modules/platform-identity/module.json",
4929
4954
  "mode": "0000644",
@@ -4932,7 +4957,7 @@
4932
4957
  {
4933
4958
  "path": "modules/platform-identity/platform-identity.js",
4934
4959
  "mode": "0000644",
4935
- "sha256": "f2a30bba329bf7d9940ab0af4c28417ba316c317cfc83e37e9a082cb9a160897"
4960
+ "sha256": "09a9be4a79097faf612ee52b1eea1c5b845bba873032853aab36c55411395833"
4936
4961
  },
4937
4962
  {
4938
4963
  "path": "modules/platform-identity/pollers/code-sweep.js",
@@ -4947,7 +4972,7 @@
4947
4972
  {
4948
4973
  "path": "modules/platform-identity/routes/connections.js",
4949
4974
  "mode": "0000644",
4950
- "sha256": "bbd846ab2654124005801914764bddd26212e869975f757bbacc7ee559919a65"
4975
+ "sha256": "d8f7404fc30052a4f815e735dff7981da56d00ccb902ace5fac132a35f59e6de"
4951
4976
  },
4952
4977
  {
4953
4978
  "path": "modules/platform-identity/routes/my-projects.js",
@@ -4977,13 +5002,18 @@
4977
5002
  {
4978
5003
  "path": "modules/platform-identity/routes/sso.js",
4979
5004
  "mode": "0000644",
4980
- "sha256": "b31ded3abdef839e72f3f87b5a96984332c1836b8a88a5c452aeaaefc4b713d6"
5005
+ "sha256": "dc3eaa8789a28a389ddeaac88f5cd514db8bf10705f430839f0bf164fc963674"
4981
5006
  },
4982
5007
  {
4983
5008
  "path": "modules/platform-identity/scouting-authz.js",
4984
5009
  "mode": "0000644",
4985
5010
  "sha256": "b4da61899339ac5e9fac517efa4b3d98e39a8e7d38fd8b0cffbd8464ba85e902"
4986
5011
  },
5012
+ {
5013
+ "path": "modules/platform-identity/terms-acceptance.js",
5014
+ "mode": "0000644",
5015
+ "sha256": "85aa2a81a80d09031282926bc5f63cef25e85630233fcbd7899ba783b2a62d59"
5016
+ },
4987
5017
  {
4988
5018
  "path": "modules/platform-identity/tests/platform-identity.mjs",
4989
5019
  "mode": "0000644",
@@ -5542,12 +5572,12 @@
5542
5572
  {
5543
5573
  "path": "package-lock.json",
5544
5574
  "mode": "0000644",
5545
- "sha256": "0c38238be602951a19181a4e65e8e2544a06c25ecb4968dc952ff84e4a70bb2f"
5575
+ "sha256": "f2b5dbf384c0e497631f003718439033bab6eb4b1f3ded6d29b187a0d487418d"
5546
5576
  },
5547
5577
  {
5548
5578
  "path": "package.json",
5549
5579
  "mode": "0000644",
5550
- "sha256": "e796318cb273523ec008c90d6105ad81b10a7acb9460a15063610e552c70856b"
5580
+ "sha256": "42c9b8ace9c23611c7449eb129580b6c9b40eedb9e0ed9ac0c0a10fdf6c85408"
5551
5581
  },
5552
5582
  {
5553
5583
  "path": "public-docs/index.html",
@@ -6022,7 +6052,7 @@
6022
6052
  {
6023
6053
  "path": "public-landing/terms.html",
6024
6054
  "mode": "0000644",
6025
- "sha256": "06e12ecafb6dfa09cf7c1b8415fa67a78486dde334fd72bb6f71f5f474301444"
6055
+ "sha256": "c0e7026074a7485ac7f3e308a8c1fb6b4cac858d8e959dda03e65a3365763b2f"
6026
6056
  },
6027
6057
  {
6028
6058
  "path": "public-landing/world/assets/clouds/cloud-island-large.png",
@@ -7572,7 +7602,7 @@
7572
7602
  {
7573
7603
  "path": "src/bongos/routes/builders.js",
7574
7604
  "mode": "0000644",
7575
- "sha256": "a5250b0ff33a42087c2546ab74b68cc7decdd3a08fda7a2f0ad56bcbf51837cd"
7605
+ "sha256": "dc71324e4a1357f86b7e5a66c64ca5b38a2c79ef58c6d7a6f7337a23cfa1ee60"
7576
7606
  },
7577
7607
  {
7578
7608
  "path": "src/bongos/routes/github.js",
@@ -7642,7 +7672,7 @@
7642
7672
  {
7643
7673
  "path": "src/bongos/serve-internal.js",
7644
7674
  "mode": "0000644",
7645
- "sha256": "5b2c35e1c4a9b75616adcc4aacd851fc16885e7e9a919c4becc67efb44cd44f2"
7675
+ "sha256": "a5036b24dbcd9d3f78de44781e60accb07ef62e458004d7e195af1c60f7e89f3"
7646
7676
  },
7647
7677
  {
7648
7678
  "path": "src/bongos/stealth-gate.js",
@@ -7677,7 +7707,7 @@
7677
7707
  {
7678
7708
  "path": "src/module-api.js",
7679
7709
  "mode": "0000644",
7680
- "sha256": "25a7279a6e6870a9e0ce02a7a5a4e78891c2d37c5a0cb7cd623ad1369a756885"
7710
+ "sha256": "9a399f369f1c857fda182086231cee242e797eb7ac8fc24fdacdc9bdf706943e"
7681
7711
  },
7682
7712
  {
7683
7713
  "path": "src/module-loader/catalog.js",
@@ -8119,6 +8149,11 @@
8119
8149
  "mode": "0000644",
8120
8150
  "sha256": "0d7bfd5a02e0beefe5f4a4b60daa4d3dee57f8e893febfd366732b419a75678f"
8121
8151
  },
8152
+ {
8153
+ "path": "tests/canonical_profile_url.mjs",
8154
+ "mode": "0000644",
8155
+ "sha256": "6f9d5c57d61aa3e1d9c6e8d7be7568d8a2a0d1d6832b8880ce3204a8b67f3a6c"
8156
+ },
8122
8157
  {
8123
8158
  "path": "tests/cap_newcomer_tasks.mjs",
8124
8159
  "mode": "0000644",
@@ -8257,12 +8292,12 @@
8257
8292
  {
8258
8293
  "path": "tests/connections_api.mjs",
8259
8294
  "mode": "0000644",
8260
- "sha256": "fe844d310fbe79e38f199dc623a96e0facd751967a9a247be5cbe9c15f80f13a"
8295
+ "sha256": "61af2ef3905b31a796e80168f1e267b608792d7195cf2e48d6a1ca85eb76ef5e"
8261
8296
  },
8262
8297
  {
8263
8298
  "path": "tests/connections_ownership.mjs",
8264
8299
  "mode": "0000644",
8265
- "sha256": "1d716fc81ebdb81daedade019f5adcdda2161c06df40711c8fb78f3ec5d605b0"
8300
+ "sha256": "58fa2cbe6866697402fc22037a4b615c642311f40c85e4b19555de55e7591eb1"
8266
8301
  },
8267
8302
  {
8268
8303
  "path": "tests/consumer_layout_boot.mjs",
@@ -8542,7 +8577,7 @@
8542
8577
  {
8543
8578
  "path": "tests/effective_visibility_predicate.mjs",
8544
8579
  "mode": "0000644",
8545
- "sha256": "3563a287a7faa8bdf26dbde292584f62b4514814a0cfdf99e58e13f02514233b"
8580
+ "sha256": "1aeeaea5d344543ab48b8dde994f4375bea9ef7678ce50e09402376334731f76"
8546
8581
  },
8547
8582
  {
8548
8583
  "path": "tests/est_advisory.mjs",
@@ -9702,7 +9737,7 @@
9702
9737
  {
9703
9738
  "path": "tests/profile_connections_ui.mjs",
9704
9739
  "mode": "0000644",
9705
- "sha256": "1165d03c123ace3558f2a17e06730e37bd6556e98d91f40c2b17fea11cd54353"
9740
+ "sha256": "1e6923437bcafbbb396d43202a4fe5c9d49dd9baef059d84aa2520bd64a195b6"
9706
9741
  },
9707
9742
  {
9708
9743
  "path": "tests/profile_fields.mjs",
@@ -9717,7 +9752,7 @@
9717
9752
  {
9718
9753
  "path": "tests/profile_privacy_owner_view.mjs",
9719
9754
  "mode": "0000644",
9720
- "sha256": "7ccf39b7557fdb7f906210a8d2e58079b92c860d25bc906d8243b8477b02a02a"
9755
+ "sha256": "75cc928587baac3151104c3ff4a1cfd39436fc7255f3afac818079724d6dbab1"
9721
9756
  },
9722
9757
  {
9723
9758
  "path": "tests/profile_rollup_consent.mjs",
@@ -9727,7 +9762,7 @@
9727
9762
  {
9728
9763
  "path": "tests/profile_route.mjs",
9729
9764
  "mode": "0000644",
9730
- "sha256": "9c29747faf840a5d0bcd7b09479cccea293155b77b247043aa1d047c7254d73d"
9765
+ "sha256": "0189425a0430dbab84044bdca2ac99e4dde87db061b8226412aa2fc393bb3119"
9731
9766
  },
9732
9767
  {
9733
9768
  "path": "tests/profile_ui_cross_project.mjs",
@@ -10444,6 +10479,11 @@
10444
10479
  "mode": "0000644",
10445
10480
  "sha256": "3ff18b286a68b244a272b3f2606ae33b298622347aae97305152f235ca57b330"
10446
10481
  },
10482
+ {
10483
+ "path": "tests/terms_acceptance.mjs",
10484
+ "mode": "0000644",
10485
+ "sha256": "513359af9b786feae7503c1c4b62e1b064f9a582d36c52932301b95e25f012fb"
10486
+ },
10447
10487
  {
10448
10488
  "path": "tests/test_path_guard.mjs",
10449
10489
  "mode": "0000644",
@@ -34,7 +34,7 @@ export interface GetBuildersDirectoryResponse { builders: unknown }
34
34
  export interface GetBuildersIdActiveClaimsResponse { builder_id: unknown; claims: unknown; stale_hours: unknown }
35
35
  export interface GetBuildersIdActivityResponse { builder_id: unknown; section: unknown; role: unknown; rows: unknown; total: unknown }
36
36
  export interface GetBuildersIdOnboardingResponse { builder_id: unknown; onboarding: unknown }
37
- export interface GetBuildersIdProfileResponse { builder: unknown; shipped_count: unknown; recent_ships: unknown; achievements: unknown; cross_project?: unknown }
37
+ export interface GetBuildersIdProfileResponse { builder: unknown; shipped_count: unknown; recent_ships: unknown; achievements: unknown; cross_project?: unknown; handle?: unknown }
38
38
  export interface GetBuildersMePendingRankChangeResponse { change: unknown; greeting?: unknown }
39
39
  export interface GetBuildersRosterResponse { builders: unknown; stale_hours: unknown }
40
40
  export interface GetClosenessForMeResponse { closeness: unknown; count: unknown }
@@ -0,0 +1,71 @@
1
+ # ADR 0213 — The platform T&C acceptance is the hub's own witness, written once, and consent becomes two columns
2
+
3
+ - **Status:** accepted
4
+ - **Date:** 2026-08-29
5
+ - **Task:** [task 1002913](https://cloudbongos.com/builders#/task/1002913) (goal 1000045 — Public builder profiles)
6
+ - **Relates to:** [ADR 0141](<redacted>.md) §4 (which deferred cross-project-aggregation consent to "the platform T&C" and conditioned the scouting view on it), [ADR 0171](<redacted>.md) D3 (fail-closed `profile_state`, and the sentence that reserved this task's place), [ADR 0016](<redacted>.md) (cross-project numbers are descriptive, never authority), [ADR 0205](<redacted>.md) D3 (the hub's own sign-in witness; `ensureAccount` vs `upsertAccount`), [ADR 0210](<redacted>.md) (the directory this consent covers), [ADR 0202](<redacted>.md) D4 (the shared effective-visibility reader), `docs/specs/<redacted>.md` (the "already carries platform T&C consent" premise), `docs/specs/<redacted>.md`
7
+
8
+ > **0211 and 0212** are held by sessions in flight; this ADR takes 0213 rather than race them (the ADR 0199 / 0202 / 0207 / 0210 precedent).
9
+
10
+ ## Context
11
+
12
+ ADR 0141 §4 shipped the cross-project scouting rollup with a promise attached: consent for aggregating a builder's work across every hosted project *"is handled by the platform T&C, tracked separately, out of scope here"*, and the view *"must stay Archon-internal until [it] lands"*. ADR 0171 then superseded the gating half — goal 1000045's owner decision made the data public by default with a self-serve hide — and D3 built the consent boundary the decision needed, describing `profile_state='active'` as the state an account reaches when *"a human is demonstrably at the hub (**and where T&C acceptance can later be captured, task 1002913**)"*.
13
+
14
+ Both ADRs, in other words, wrote their consent story around an artifact that did not exist. The public-profiles spec states the premise plainly — profiles are public by default because that is *"tied to having a platform account, which already carries platform T&C consent"* — and the profile-page spec had already flagged, in as many words, that **that premise is not currently true**.
15
+
16
+ Three things were found on picking the task up, and they changed its shape:
17
+
18
+ 1. **A `/terms` page already exists.** Task 1003251 landed `public-landing/terms.html`, routed and server-stamped. The task brief (written before it) says no T&C document exists anywhere in the tree; that is now stale. What does not exist is the *content* ADR 0141 deferred to it: the page covers accounts, code ownership, fair use and liability, and says nothing whatsoever about work crossing between projects. `/privacy` is silent on it too.
19
+ 2. **The `scouting.js:6` reference the task points at is gone.** Task 1002290 rewrote that header for ADR 0210; it now describes consent as enforced by `scoutingListableSql`. So the last in-tree pointer to the missing artifact had already been overwritten — the debt was becoming invisible rather than getting paid.
20
+ 3. **The un-gating this artifact was supposed to unlock has already happened**, via ADR 0171 and ADR 0210. Nothing waits on this ADR to widen. What waits on it is the *honesty* of a premise every public surface already rests on.
21
+
22
+ ## Decision
23
+
24
+ ### D1 — The acceptance record is the hub's own evidence, never a caller's claim
25
+
26
+ `platform_identity_accounts` gains `terms_accepted_at timestamptz` and `terms_version text` (migration `<redacted>.sql`). Both are written by exactly one statement: `upsertAccount`'s `activate` branch. That branch has exactly one caller — `GET /sso/authorize` — where `activate: true` is a **source literal** and every other field comes from the hub's own `resolveSession` result, gated by `sessionUsableForFederation`.
27
+
28
+ This is deliberately the ADR 0205 D3 property, reused. An acceptance record a caller could assert is evidence of nothing; the only thing that makes the record mean "this human accepted" is that no request-controlled value can reach the statement that writes it. `ensureAccount` — the federated check-in / device-poll path, which takes an *unproven* `github_id` from a request body — writes NULL, exactly as it writes `provisional`.
29
+
30
+ ### D2 — Written once. The first acceptance is the one that stands
31
+
32
+ The conflict branch writes `COALESCE(platform_identity_accounts.terms_accepted_at, now())`. Signing in again re-reads the terms but does not restamp them, so the column keeps meaning *when consent was given* rather than *when this account was last seen*. `terms_version` is COALESCE'd the same way and always written in the same statement, so the pair cannot diverge.
33
+
34
+ ### D3 — Consent is two columns, and the shared predicate is where that is enforced
35
+
36
+ `account-visibility.js` — the sole owner of every effective-visibility answer (ADR 0202 D4) — now spells the consent axis as `profile_state = 'active' AND terms_accepted_at IS NOT NULL`, in both its SQL and JS forms. `accountVisibleSql`/`isAccountVisible` compose from it rather than restating it, so the rollup, directory and connections predicates inherit the change without any of them naming the new column.
37
+
38
+ Writing it here rather than at the call sites is the whole point: both halves are stamped by the same statement, so for any account activated after this ADR they can never disagree — and requiring both in the predicate is what makes that structural instead of a promise. It is also what stops a row that predates the terms from publishing on a premise that was never true.
39
+
40
+ `tests/effective_visibility_predicate.mjs` extends its sole-owner rule to the new column, in the `${q}`-prefixed form the owner actually emits — the form the reach-COALESCE hole (task 1002290) taught us to pin rather than the bare spelling nobody writes.
41
+
42
+ ### D4 — No backfill. Existing accounts re-accept on their next hub sign-in
43
+
44
+ Accounts that are already `active` get `terms_accepted_at IS NULL`, because no platform T&C covering cross-project aggregation has ever been shown to anyone and inventing a timestamp would fabricate the very artifact this task exists to make real. Under D3 they are therefore not publicly visible until their next hub-session authorize, which stamps them.
45
+
46
+ This is ADR 0171 D3's own backfill rule applied to its own sentence (*"all existing accounts start provisional and lazily upgrade on their next hub-session authorize"*), and it is what P03 ratified ([task 1002799](https://cloudbongos.com/builders#/task/1002799), owner, 2026-08-16): no backfill activation, on the rationale that a builder passes through the hub anyway. **This is the one consequence with a visible product effect, and it is on [blocker 1000123](https://cloudbongos.com/builders#/blocker/1000123) for the owner** — grandfathering is one line in the migration if they prefer it.
47
+
48
+ ### D5 — Re-acceptance on a later revision is out of scope, and visible rather than silent
49
+
50
+ Because the version is recorded and never overwritten, publishing a new `TERMS_VERSION` leaves every row naming the text it actually agreed to, so the platform can see who has not accepted the new one. What to *do* about that — re-prompt, re-gate, or nothing — is a product flow, not a storage shape, and is not built here.
51
+
52
+ ### D6 — The wording is the owner's, and only the wording was left pending
53
+
54
+ The mechanism ships complete. The text of the new section is a draft on [blocker 1000123](https://cloudbongos.com/builders#/blocker/1000123); `TERMS_VERSION` is date-stamped and asserted to match the page, so changing the wording is an edit to two strings.
55
+
56
+ ## Consequences
57
+
58
+ - The premise the public-profile surfaces rest on becomes true for every account activated from here, and fail-closed for every account that predates it.
59
+ - **Every currently-`active` account is unpublished until its next hub sign-in** (D4). Reversible in one line; on the owner's blocker.
60
+ - `platform-identity.js` sat at 1497 lines against a hard `oversized_file_count: 0` ratchet, so the write-side SQL lives in a new `terms-acceptance.js` — the fifth instance of the "new file beside it" precedent (`account-visibility.js`, `application-echo.js`, `routes/connections.js`, `src/bongos/routes/my-sessions.js`). The file now measures exactly 1500 and **has no headroom left**; the next change to it must shrink it.
61
+ - The read/write split is load-bearing: `terms-acceptance.js` owns only statements that SET the columns, `account-visibility.js` owns the one that TESTS them. The guard's spelling rules deliberately do not match writes, which is what lets both files exist without one of them being a drift copy.
62
+ - **Settings will read wrong for a legacy account under D4** until someone closes it: `GET /me/cross-project` returns `profile_state` raw and the Settings consent note branches on it alone, so an account that signed in before the terms existed reads "active" while its profile is published nowhere. One own-scope boolean plus a copy change fixes it - filed as idea 1000875, and it disappears entirely if the owner grandfathers on blocker 1000123.
63
+ - `docs/specs/<redacted>.md`'s standing note that the consent premise "is not currently true for every account" is now addressed rather than outstanding.
64
+
65
+ ## Rejected alternatives
66
+
67
+ - **A separate `platform_identity_terms_acceptances` table (one row per acceptance).** Correct if acceptance were an event stream — re-acceptance across versions, audit history. It is not one yet (D5), and a table whose only reader is a per-account NULL test is a join the visibility predicate would have to grow. Two columns, one statement, no join.
68
+ - **A click-through gate on `/sso/authorize`.** An explicit "I accept" button is stronger evidence than "by signing in you agree". It also puts an interstitial in the middle of a federated redirect that exists to be invisible, and it changes a sign-in flow two other live tasks were editing. The page now states the terms and the sign-in records acceptance of them; upgrading to an explicit gate is additive and does not invalidate any record written under this ADR.
69
+ - **Backfilling `terms_accepted_at` for already-active accounts** (D4) — records a consent that never happened, which is exactly the fiction this task exists to end. Offered to the owner as the alternative it is, not taken silently.
70
+ - **Deriving `profile_state` from `terms_accepted_at`** (dropping the column). The two facts are genuinely different — the hub witnessed a human, and that human accepted a text — and D5's revision case needs them separable.
71
+ - **Putting the acceptance predicate in `terms-acceptance.js`.** It would have made that file a second owner of an effective-visibility answer, which ADR 0202 D4 forbids and the structural guard fails.
@@ -0,0 +1,90 @@
1
+ # ADR 0214 — The canonical profile URL gets one resolver, and it stays dark until the page it names actually resolves
2
+
3
+ - **Status:** accepted
4
+ - **Date:** 2026-08-29
5
+ - **Task:** [task 1002804](https://cloudbongos.com/builders#/task/1002804) (P08, goal 1000045 — Public builder profiles, criterion C1)
6
+ - **Relates to:** ADR 0171 D1/D2/D8 (link out instead of inlining; `hide_stats` suppresses numbers, never existence; `github_id` is the join key and the handle is presentation), ADR 0202 D4 / ADR 0210 (one shared visibility predicate, never a second variant), ADR 0141 (identity federated, authority local), ADR 0083 (module boundary), ADR 0016 (identity is never authority), `docs/specs/<redacted>.md` Decision 1 + Decision 3, `docs/specs/<redacted>.md` D2 (404, never 403)
7
+
8
+ ## Context
9
+
10
+ Profile-page spec Decision 1 names one canonical public profile with two render paths: `cloudbongos.com/u/<handle>` is canonical and server-rendered on the hub; `builders.<instance>/profile` stays the LOCAL view and emits `rel=canonical` to it; and the numeric `?id=` form "never dies" as a rename-proof fallback that redirects to the canonical handle URL. ADR 0171 D1 amended that: federated non-hub instances **link out** rather than inlining cross-project numbers, and named this task the owner of the federated render path — the visible link, the `rel=canonical` emission, and the numeric-id redirect.
11
+
12
+ Four facts about the tree, established before any of this was designed:
13
+
14
+ 1. **`/u/<handle>` does not exist.** No route, no page, no hub search, no sitemap (proved by task 1002806, confirmed by task 1002296). The page is goal 1000048 R14 / [task 1002365](https://cloudbongos.com/builders#/task/1002365), which sits at `backlog`. So does R13 (cross-instance canonical), R12 (robots.txt) and R15 (sitemap). R01 — the ADR that *decides the SEO namespace* — is only `ready`, and R03 in the same goal proposes moving platform pages under `/bongos/…`. **The path string `/u/<handle>` is not merely unbuilt; its shape is not yet settled.**
15
+ 2. **The READ behind that page already exists.** `GET /profiles/:handle` (`modules/platform-identity/routes/public-profile.js`, R05) resolves current-or-previous handle through `resolveHandle`, which floors on the shared `accountVisibleSql` predicate and answers 404 for unknown / provisional / private alike — never 403.
16
+ 3. **`GET /builders/:id/profile` whitelists nine core columns and `handle` is not one of them.** Task 1002296 hit this and routed around it: `profile-connections.js` hands the connections API a `github_login` and uses the handle the response echoes back. That workaround has a hole its own header names — the rename window is a **single** `previous_handle` slot, so a builder who has renamed **twice** has a login that matches neither `handle` nor `previous_handle`, the degree call 404s, and the connections section silently never appears.
17
+ 4. **No served page carries a hub origin.** `clientBranding()` deliberately omits `domains`; `applyBrandTokens()` stamps `{{publicOrigin}}` and `{{buildersOrigin}}` (this instance's own addresses) and nothing else. The only hub origin anywhere at runtime is `loadIdpConfig().origin` in `src/bongos/auth-config.js` — server-side auth wiring, never rendered. ADR 0171 D1 flagged exactly this gap.
18
+
19
+ ## Decision
20
+
21
+ ### D1 — One resolver owns the canonical URL, and it answers `null` while the page does not resolve
22
+
23
+ `modules/platform-identity/canonical-profile.js` is THE place that answers "what is this builder's canonical profile URL?". Everything that needs the answer — the `rel=canonical` link, the visible link-out, the numeric-id redirect — asks it and renders nothing when it answers `null`. One resolver, three consumers, so the three can never disagree about where a profile lives or about whether it is safe to point there.
24
+
25
+ `CANONICAL_PROFILE_PAGE_LIVE` is `false`. **Today the resolver answers `null` everywhere and all three surfaces are dark.**
26
+
27
+ **Why dark, and not "canonical ahead of the page".** Emitting a canonical at a URL that is planned but unbuilt is a normal SEO staging move when the target is imminent and its address is fixed. Neither holds here:
28
+
29
+ - **The target 404s.** A `rel=canonical` pointing at a 404 does not consolidate anything — a 404 cannot be indexed, so there is no signal to consolidate onto. What it *can* do is cost: the declaring page is the only one that works, and a canonical that names a non-indexable URL is a documented way to have a working page reported as a duplicate of nothing and dropped. We would be spending the one page that ranks to point at a page that does not exist.
30
+ - **The address is not settled.** R01 has not been written and R03 proposes relocating platform pages under `/bongos/…`. Baking `/u/<handle>` into every federated instance's served HTML now means every one of them advertises a path that a sibling task may still move — and a canonical is precisely the kind of claim that is expensive to retract, because crawlers cache it and users bookmark what it redirects them to.
31
+ - **There is no upside to being early.** Canonical consolidation begins when the target becomes indexable, not before. Emitting now buys nothing and risks the working page.
32
+
33
+ So the honest answer is: build the machinery, prove it, and **do not point at a 404**. The flip is one boolean in one file, and R14 cannot land the page without noticing it — `tests/canonical_profile_url.mjs` fails if a `/u/` page route appears anywhere in the tree while the flag is still `false`. The flag cannot silently lie in either direction.
34
+
35
+ **What must be true to flip it** (the checklist R13/R14 inherit, stated once so nobody has to re-derive it): the page is served at the path this file names; R01 has ratified that path; the page 404s (never 403) for absent / provisional / private, matching `GET /profiles/:handle`; and `noindex`-until-first-ship (P09) is in place so flipping the flag does not publish a farm of empty profiles.
36
+
37
+ ### D2 — The hub origin becomes a server-side stamp, delivered as a `<meta>`, not a `<script>`
38
+
39
+ `applyBrandTokens()` gains `{{hubOrigin}}`. It resolves to the instance's **own** `publicOrigin` when `platform-identity` is enabled (that module *is* the hub flag — `src/platform-server.js`), to `loadIdpConfig().origin` on a federated instance, and to `''` on a vanilla one. This is the "server-side hub-origin stamp token" ADR 0171 D1 asked for, and it is the only way a served page can carry a correct cross-host canonical: `clientBranding()` cannot reach `domains`, and a baked value would make every instance advertise whoever authored the file (the fitness identity gate, task 1002748).
40
+
41
+ It is stamped as `<meta name="hub-origin" content="{{hubOrigin}}">`, following the `{{instanceBase}}` precedent — a meta attribute, never a script global. **The token is deliberately not written directly into a `href`**: `dropEmptyHrefLinks` only drops an href that is a *bare* origin token, so `href="{{hubOrigin}}/u/x"` on an instance with no hub would silently become the same-host relative URL `/u/x` — a canonical pointing at the instance itself. Reading the meta and refusing an empty value makes that failure impossible rather than merely unlikely.
42
+
43
+ ### D3 — `handle` joins the numeric profile response under the EXISTENCE predicate, not the rollup one
44
+
45
+ `GET /builders/:id/profile` gains a top-level `handle`, attached only where the `platform-identity.hub` port resolves, sourced from a new port function `getPublicHandle(githubId)` that floors on `isAccountVisible` — the shared predicate, never a variant.
46
+
47
+ **Why the existence predicate and not the rollup one.** `account-visibility.js` is explicit that `hide_stats` suppresses NUMBERS and never existence, and `resolveHandle` already says the same out loud: hiding removes the rollup, not the page. A builder who hides their stats still has a public profile at `/u/<handle>` — it just carries no numbers. Gating the handle on `rollupVisibleSql` would deny a canonical URL and a connect button to exactly the people who chose a public profile without public numbers.
48
+
49
+ **This does not create a new inference.** It is true that `handle` present together with `cross_project: null` narrows the four indistinguishable rollup-null cases to "hidden". That pair is **already published, unauthenticated, on `/profiles/:handle`**, which answers `200 { account: { handle }, cross_project: null }` for exactly that account. The change re-presents on the numeric route a pair the handle route already serves; it does not widen what the platform discloses. The three cases ADR 0171 D2 requires to stay merged — no account, provisional, private — all still produce `handle` absent **and** `cross_project` null, byte-identical.
50
+
51
+ **What this closes today, with the flag still off:** fact 3 above. `profile-connections.js` can take the canonical handle straight from the profile response instead of guessing with a `github_login`, so a twice-renamed builder gets their connections section back.
52
+
53
+ ### D4 — The numeric `?id=` form redirects on the hub and canonicalizes on a federated instance
54
+
55
+ The spec's two clauses — "the local view stays as-is" and "the numeric form redirects to the canonical URL" — read as a contradiction only until you ask *which instance*:
56
+
57
+ - **On the hub**, `builders.<apex>/profile?id=N` and `<apex>/u/<handle>` describe the same person from the same database. Two URLs for one page on one host is precisely the duplication Decision 1 exists to end, so the numeric form **redirects**.
58
+ - **On a federated instance**, `builders.foo.com/profile?id=N` shows *foo's* local rank, works and achievements. That is genuinely different content, so it **must not** redirect. It stays the local view, emits `rel=canonical`, and shows the visible link-out — ADR 0171 D1's federated render path exactly.
59
+ - **On a vanilla instance** nothing changes at all.
60
+
61
+ A page cannot both redirect to a URL and declare it canonical, and it does not have to: the two behaviours belong to different instances.
62
+
63
+ The hub redirect is a client-side `location.replace` (it replaces rather than pushes, so Back still leaves the site cleanly) issued after the profile read resolves the handle. It is not a true 301 and does not claim to be: the hall page is served from a boot-time in-memory HTML map with no per-request database access, and giving that path a DB read to serve a redirect is a real cost for a page that is not indexed today (there is no robots.txt yet — R12). **When R14 lands the server-rendered page it should serve the numeric form as a real 301 in its own namespace, using this same resolver**; that is written here so the follow-up is a decision already made rather than one rediscovered.
64
+
65
+ ### D5 — The redirect is not an existence oracle
66
+
67
+ The hub redirect fires iff the profile response carries a `handle`, which it does iff `isAccountVisible` — the same predicate `/profiles/:handle` already floors on. Three properties make the fallback silent:
68
+
69
+ 1. **The non-redirect branch is a working page, not an error.** No account, provisional, and private all render the ordinary local profile — the same markup, the same status, the same everything. There is no error shape to read a signal off.
70
+ 2. **The three "no" cases are indistinguishable from each other.** `getPublicHandle` returns `null` for all three; the response omits the key identically. The redirect can only ever separate "publicly visible account" from "not that", never one *reason* from another.
71
+ 3. **The one bit it does expose is already public.** "This builder has a publicly visible platform account" is exactly what an unauthenticated `GET /profiles/<handle>` answers with 200-vs-404 today. The redirect derives its target from a handle the response already carried; it adds no bit the caller could not already read.
72
+
73
+ And the redirect target is a handle, never a `github_id` — the join key stays internal, as `<redacted>` already insists.
74
+
75
+ ### D6 — Publishing the handle map puts the numeric profile read under the EXISTING public read ceiling
76
+
77
+ `GET /builders/:id/profile` answered at line rate. That is structural rather than an oversight: `rateLimitMiddleware` returns early for every non-write method, and the one read limiter is mounted under `/public/*`, which this route is not (ADR 0209's finding, one route over). Tolerable while the route re-presented leaderboard data everyone can already read; not once it also carries `handle`, because an id walk then yields the **builder → canonical-profile-handle map**, one call per builder, for every publicly visible account. `modules/platform-identity/routes/public-profile.js` caps `/profiles/:handle` for exactly that mapping, with exactly that reasoning. Two routes publishing one map get one ceiling.
78
+
79
+ So the route mounts `publicReadRateLimitMiddleware` — **the shared instance** (120/60s per IP, the `/public/*` posture), not a new budget and not a private copy. ADR 0209's load-bearing point applies verbatim: two limiters configured identically are indistinguishable in a diff and hand an enumerator twice the budget, so the test asserts the shared instance **by object identity**.
80
+
81
+ **This required strengthening a guard, not weakening one.** `tests/profile_route.mjs` (R06.1) pinned the route as public by requiring the registration line to read `router.get(path, async (req, res) =>` — the handler and nothing else. Its own comment says what it is for: *"a future PR walling this behind `requireBuilder` would break 'another account can see it'"*. That intent is **auth**, and throttling is not authentication — `/public/*` is rate-limited and public. But a line-regex cannot tell the two apart, and it silently missed the more likely bypass anyway: a gate mounted with `router.use`. The guard now reads the **real router stack**, rejects any function exported by `../auth` on that route, rejects any router-level middleware at all, and allows exactly one named handler — the shared limiter — with its reason. Six mutations were run against it; the strengthened form catches all of them, including the two `router.use` forms the old regex let through.
82
+
83
+ ## Consequences
84
+
85
+ - **Three surfaces ship dark and one ships live.** The canonical link, the link-out and the numeric redirect are all gated on one boolean that is `false`; the widened `handle` on the profile read is live immediately and closes the twice-renamed connect-button hole. A reviewer should expect exactly that asymmetry.
86
+ - **R13/R14 inherit a one-line flip and a checklist.** `CANONICAL_PROFILE_PAGE_LIVE` in `modules/platform-identity/canonical-profile.js`, with D1's four preconditions in its doc-comment and a test that fails if a `/u/` page route lands while it is still `false`.
87
+ - **A cross-version window exists and is bounded.** A federated instance running a core release where the flag is `true` while its hub is pinned to an older release would link out to a 404. The blast radius is a link and a canonical, not a broken page, and the flag ships in the same release as the page it names.
88
+ - **The link-out discloses that a builder has a public platform account** — the same bit D5 analyses, now visible on a federated instance's page rather than only via the handle route. Deliberate: that is what "link out to the canonical record" means.
89
+ - **`applyBrandTokens` grows a seventh token.** Its signature is now at seven injectable parameters; a further one should probably move the origins into an object rather than extend the list again.
90
+ - **Not decided here:** the sitemap and `noindex` rules (P09 / R15), the served page itself and its caching posture (R14), whether the numeric permalink on the hub should key on the core builder id or a platform-side identifier once the page exists (R14's call, with D5's rule binding either way), and whether federated instances ever inline cross-project numbers — ADR 0171 D1 names the pull-with-cache follow-up and it stays a separate task.
@@ -298,6 +298,8 @@ This keeps the decision history honest and traceable.
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
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
+ | 0214 | [**The canonical profile URL gets one resolver, and it stays dark until the page it names actually resolves** ([task 1002804](https://cloudbongos.com/builders#/task/1002804), P08 · goal 1000045 criterion C1 — the canonical/redirect wiring). Profile-page spec Decision 1 wants `rel=canonical` on the instance, a link-out to the hub, and a numeric-id redirect; `/u/<handle>` does not exist (goal 1000048 R14 is `backlog`, and R01 — the ADR that fixes its PATH — is unwritten, while R03 proposes moving platform pages under `/bongos/…`). Decisions: (1) **one resolver** (`modules/platform-identity/canonical-profile.js`) answers where a profile lives, and it answers `null` while the page 404s — a canonical naming a non-indexable URL consolidates nothing and risks the one page that works, and the address is not even settled yet; one boolean flips all three surfaces together, and a test reds if a `/u/` route lands while it is still false. (2) **`{{hubOrigin}}`** joins `applyBrandTokens` (own `publicOrigin` on the hub, `loadIdpConfig().origin` when federated, `''` otherwise) — the server-side stamp ADR 0171 D1 asked for — delivered as a `<meta>`, never inside an `href`, because an empty value in an href degrades to a same-host URL claiming to be the hub. (3) **`handle` joins `GET /builders/:id/profile`** under the EXISTENCE predicate (`isAccountVisible`), not the rollup one: `hide_stats` suppresses numbers and never addressability, and the `handle`+`cross_project: null` pair is already published unauthenticated by `GET /profiles/:handle`. This closes ADR 0212’s stated gap — a twice-renamed builder now has a connect button. (4) **The hub redirects and a federated instance canonicalizes**, which is how Decision 1’s two apparently contradictory clauses reconcile: the hub serves the same person at both URLs, a federated instance serves genuinely different content. (5) **The redirect is not an existence oracle** — its non-redirect branch is the ordinary working page, absent/provisional/private are byte-identical, and the one bit it exposes is what `/profiles/:handle` already answers with 200-vs-404.](<redacted>.md) | platform identity / SEO / hall UI |
301
302
  | 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 |
303
+ | 0213 | [**The platform T&C acceptance is the hub's own witness, written once, and consent becomes two columns** ([task 1002913](https://cloudbongos.com/builders#/task/1002913), goal 1000045). ADR 0141 §4 deferred consent for cross-project aggregation to "the platform T&C" and ADR 0171 D3 wrote its own definition around the moment that acceptance "can later be captured" - but no acceptance record existed, and while `/terms` DOES exist (task 1003251) it says nothing about work crossing between projects, so the public-profile premise ("already carries platform T&C consent") was untrue for every account. The scouting.js:6 pointer the task named had also been overwritten by ADR 0210, so the debt was going invisible rather than getting paid. Decision: `terms_accepted_at` + `terms_version` on `platform_identity_accounts` (migration 019), written ONLY by `upsertAccount`'s `activate` branch - one caller, /sso/authorize, where `activate: true` is a source literal and every field comes from the hub's resolved session (the ADR 0205 D3 property; a record a caller could assert is evidence of nothing). COALESCE makes it write-once, so the column keeps meaning WHEN consent was given. The consent axis in `account-visibility.js` becomes `profile_state = 'active' AND terms_accepted_at IS NOT NULL`, composed into every other predicate, which is what makes the tie structural rather than a promise. NO backfill (D4): existing active accounts re-accept on their next hub sign-in - ADR 0171 D3's own rule and P03's ratified rationale - and that visible consequence, plus the wording itself, went to the owner on blocker 1000123. Rejected: an acceptances TABLE (no event stream yet), a click-through gate (an interstitial in a redirect built to be invisible; additive later), backfilling (records a consent that never happened), deriving profile_state from the timestamp, and putting the predicate in `terms-acceptance.js` (a second owner of an effective-visibility answer, which ADR 0202 D4 forbids).](<redacted>.md) | platform identity / consent / privacy |
302
304
 
303
305
  > ⚠️ **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.
@@ -2847,7 +2847,7 @@
2847
2847
  "builders"
2848
2848
  ],
2849
2849
  "summary": "GET /builders/:id/profile",
2850
- "description": "GET /builders/:id/profile — the viewable builder profile page's read (task 1467, goal 25 \"bx-identity\"). Returns ONE builder's public portfolio: identity + rank + economy counters + shipped-work count + recent ships + unlocked achievements. Backs the shareable /builders/profile page. rank: public — every field returned here is ALREADY world-readable: the leaderboard (GET /public/leaderboard, no auth) exposes rank, total_credits, karma, and the top achievement ids; GET /builders/:id/onboarding is likewise an unauthenticated roster-visible read. This route is a per-builder re- presentation of the SAME already-public data, so a portfolio link is shareable without a sign-in wall (that IS the feature). It deliberately WHITELISTS the public column subset off the full builder row — the private columns getBuilderById also returns (anthropic_email / anthropic_user_id / monthly_budget_usd / system_role / box_blocked*) are NEVER spread into the JSON. A leak-prevention unit test (tests/profile_route.mjs) asserts their absence + the 404-on-unknown-id behaviour.\n\n**Rank:** `public` — No authentication — any caller.",
2850
+ "description": "GET /builders/:id/profile — the viewable builder profile page's read (task 1467, goal 25 \"bx-identity\"). Returns ONE builder's public portfolio: identity + rank + economy counters + shipped-work count + recent ships + unlocked achievements. Backs the shareable /builders/profile page. rank: public — every field returned here is ALREADY world-readable: the leaderboard (GET /public/leaderboard, no auth) exposes rank, total_credits, karma, and the top achievement ids; GET /builders/:id/onboarding is likewise an unauthenticated roster-visible read. This route is a per-builder re- presentation of the SAME already-public data, so a portfolio link is shareable without a sign-in wall (that IS the feature). It deliberately WHITELISTS the public column subset off the full builder row — the private columns getBuilderById also returns (anthropic_email / anthropic_user_id / monthly_budget_usd / system_role / box_blocked*) are NEVER spread into the JSON. A leak-prevention unit test (tests/profile_route.mjs) asserts their absence + the 404-on-unknown-id behaviour. THROTTLED since task 1002804. The global limiter meters WRITES only (rateLimitMiddleware returns early for GET) and the public read limiter is mounted under /public/*, which this is not — so this route answered at line rate. That was tolerable while it re-presented leaderboard data; it is not now that it also carries `handle`, because an id walk then yields the builder → canonical-profile-handle map in one call per builder. modules/platform-identity/routes/public-profile.js caps /profiles/:handle for exactly that mapping, so the two routes that publish it get the same ceiling. The EXISTING shared /public/* budget (120/60s per IP), not a new one — ADR 0209's rule that routes answering one question spend one budget.\n\n**Rank:** `public` — No authentication — any caller.",
2851
2851
  "x-rank": "public",
2852
2852
  "x-source": "src/bongos/routes/builders.js",
2853
2853
  "parameters": [
@@ -15820,7 +15820,8 @@
15820
15820
  "shipped_count": {},
15821
15821
  "recent_ships": {},
15822
15822
  "achievements": {},
15823
- "cross_project": {}
15823
+ "cross_project": {},
15824
+ "handle": {}
15824
15825
  },
15825
15826
  "required": [
15826
15827
  "builder",