@bongos/core 1.19.425 → 1.19.426
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 +47 -27
- package/docs/adr/0210-scouting-graduates-on-an-instance-asserted-owner-claim.md +100 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +1 -1
- package/docs/copy-inventory.md +1 -1
- package/docs/copy-registry.json +2 -2
- package/docs/file-map.md +2 -0
- package/docs/module-api-changelog.md +2 -0
- package/modules/hall-ui/public/people.js +5 -5
- package/modules/hall-ui/public/settings.html +2 -2
- package/modules/hall-ui/public/settings.js +12 -12
- 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/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/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.426",
|
|
6
|
+
"core_contract": "1.19.426",
|
|
7
|
+
"source_commit": "6e32dc947ac0c7879f4df33ac9a6642485b921f9",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-29T19:
|
|
9
|
+
"built_at": "2026-08-29T19:24:28.557Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
|
-
"docs_redacted":
|
|
12
|
+
"docs_redacted": 361,
|
|
13
13
|
"agent_docs_stubbed": 23,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1729,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 2113,
|
|
20
|
+
"tree_sha256": "dbe9e6dd3b2d878e12342577089f1fb18739cf3b570acea9df12a15b9ba6a42b",
|
|
21
21
|
"files": [
|
|
22
22
|
{
|
|
23
23
|
"path": ".claude/skills/blocker-review/SKILL.md",
|
|
@@ -1549,10 +1549,15 @@
|
|
|
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
|
+
},
|
|
1552
1557
|
{
|
|
1553
1558
|
"path": "docs/adr/README.md",
|
|
1554
1559
|
"mode": "0000644",
|
|
1555
|
-
"sha256": "
|
|
1560
|
+
"sha256": "3aa9b5f26de5b7e4911ce1ee8debb6fd58dc1543e487a1ec1a51e5995a6a473c"
|
|
1556
1561
|
},
|
|
1557
1562
|
{
|
|
1558
1563
|
"path": "docs/api-reference.md",
|
|
@@ -1562,7 +1567,7 @@
|
|
|
1562
1567
|
{
|
|
1563
1568
|
"path": "docs/api/openapi.json",
|
|
1564
1569
|
"mode": "0000644",
|
|
1565
|
-
"sha256": "
|
|
1570
|
+
"sha256": "1a80f11c01764ec8e1542f9f5b3b2b37123656778646dd316c1e32180973b310"
|
|
1566
1571
|
},
|
|
1567
1572
|
{
|
|
1568
1573
|
"path": "docs/architecture.md",
|
|
@@ -1587,12 +1592,12 @@
|
|
|
1587
1592
|
{
|
|
1588
1593
|
"path": "docs/copy-inventory.md",
|
|
1589
1594
|
"mode": "0000644",
|
|
1590
|
-
"sha256": "
|
|
1595
|
+
"sha256": "f554b543f8f13d00649476abd2aba52d973eae5db9dc1fede6956c63f1d6e0e7"
|
|
1591
1596
|
},
|
|
1592
1597
|
{
|
|
1593
1598
|
"path": "docs/copy-registry.json",
|
|
1594
1599
|
"mode": "0000644",
|
|
1595
|
-
"sha256": "
|
|
1600
|
+
"sha256": "adb2bd7b686eb3943c7bdf8c2129d0de3e80ebb9db99b7507a4320a569beb383"
|
|
1596
1601
|
},
|
|
1597
1602
|
{
|
|
1598
1603
|
"path": "docs/design/apex-pass-2-direction.md",
|
|
@@ -1937,7 +1942,7 @@
|
|
|
1937
1942
|
{
|
|
1938
1943
|
"path": "docs/file-map.md",
|
|
1939
1944
|
"mode": "0000644",
|
|
1940
|
-
"sha256": "
|
|
1945
|
+
"sha256": "a5b1b7926513202fefed48db17bb50fa7727a15ee48e6981b524fc4f76db28ca"
|
|
1941
1946
|
},
|
|
1942
1947
|
{
|
|
1943
1948
|
"path": "docs/handoff-template.md",
|
|
@@ -1947,7 +1952,7 @@
|
|
|
1947
1952
|
{
|
|
1948
1953
|
"path": "docs/module-api-changelog.md",
|
|
1949
1954
|
"mode": "0000644",
|
|
1950
|
-
"sha256": "
|
|
1955
|
+
"sha256": "b32e4941a78d82f7cbbdb5a42608ebb46370b46088ce56432e4c8ec19c2365ec"
|
|
1951
1956
|
},
|
|
1952
1957
|
{
|
|
1953
1958
|
"path": "docs/modules-contract.md",
|
|
@@ -4092,7 +4097,7 @@
|
|
|
4092
4097
|
{
|
|
4093
4098
|
"path": "modules/hall-ui/public/people.js",
|
|
4094
4099
|
"mode": "0000644",
|
|
4095
|
-
"sha256": "
|
|
4100
|
+
"sha256": "7e0e2f091fb8c286220bbd5f790497ab54c78e569f59519a00fa5739d11bd451"
|
|
4096
4101
|
},
|
|
4097
4102
|
{
|
|
4098
4103
|
"path": "modules/hall-ui/public/primer.css",
|
|
@@ -4192,12 +4197,12 @@
|
|
|
4192
4197
|
{
|
|
4193
4198
|
"path": "modules/hall-ui/public/settings.html",
|
|
4194
4199
|
"mode": "0000644",
|
|
4195
|
-
"sha256": "
|
|
4200
|
+
"sha256": "81da0b750da880c411a8604687a8c300852c631ffdd07d4fd1bb3c4533e650c0"
|
|
4196
4201
|
},
|
|
4197
4202
|
{
|
|
4198
4203
|
"path": "modules/hall-ui/public/settings.js",
|
|
4199
4204
|
"mode": "0000644",
|
|
4200
|
-
"sha256": "
|
|
4205
|
+
"sha256": "d3ddd434100c65a5839e3004ad1a5c4537d2da23ecd4441ee3eff3aa0ee8b5a1"
|
|
4201
4206
|
},
|
|
4202
4207
|
{
|
|
4203
4208
|
"path": "modules/hall-ui/public/shell.js",
|
|
@@ -4797,7 +4802,7 @@
|
|
|
4797
4802
|
{
|
|
4798
4803
|
"path": "modules/platform-identity/account-visibility.js",
|
|
4799
4804
|
"mode": "0000644",
|
|
4800
|
-
"sha256": "
|
|
4805
|
+
"sha256": "04deda249e65ceb416064ecb503ff101600c57ae84be9bf2ecab1de4b6701879"
|
|
4801
4806
|
},
|
|
4802
4807
|
{
|
|
4803
4808
|
"path": "modules/platform-identity/application-echo.js",
|
|
@@ -4917,7 +4922,7 @@
|
|
|
4917
4922
|
{
|
|
4918
4923
|
"path": "modules/platform-identity/platform-identity.js",
|
|
4919
4924
|
"mode": "0000644",
|
|
4920
|
-
"sha256": "
|
|
4925
|
+
"sha256": "f2a30bba329bf7d9940ab0af4c28417ba316c317cfc83e37e9a082cb9a160897"
|
|
4921
4926
|
},
|
|
4922
4927
|
{
|
|
4923
4928
|
"path": "modules/platform-identity/pollers/code-sweep.js",
|
|
@@ -4957,12 +4962,17 @@
|
|
|
4957
4962
|
{
|
|
4958
4963
|
"path": "modules/platform-identity/routes/scouting.js",
|
|
4959
4964
|
"mode": "0000644",
|
|
4960
|
-
"sha256": "
|
|
4965
|
+
"sha256": "e9afb0c1839c2933b1222158a3fccca8c3e3659282bb705e9d5c80575471200c"
|
|
4961
4966
|
},
|
|
4962
4967
|
{
|
|
4963
4968
|
"path": "modules/platform-identity/routes/sso.js",
|
|
4964
4969
|
"mode": "0000644",
|
|
4965
|
-
"sha256": "
|
|
4970
|
+
"sha256": "b31ded3abdef839e72f3f87b5a96984332c1836b8a88a5c452aeaaefc4b713d6"
|
|
4971
|
+
},
|
|
4972
|
+
{
|
|
4973
|
+
"path": "modules/platform-identity/scouting-authz.js",
|
|
4974
|
+
"mode": "0000644",
|
|
4975
|
+
"sha256": "b4da61899339ac5e9fac517efa4b3d98e39a8e7d38fd8b0cffbd8464ba85e902"
|
|
4966
4976
|
},
|
|
4967
4977
|
{
|
|
4968
4978
|
"path": "modules/platform-identity/tests/platform-identity.mjs",
|
|
@@ -5522,12 +5532,12 @@
|
|
|
5522
5532
|
{
|
|
5523
5533
|
"path": "package-lock.json",
|
|
5524
5534
|
"mode": "0000644",
|
|
5525
|
-
"sha256": "
|
|
5535
|
+
"sha256": "c16499122c1680ba466c0245ddb69d6225fd9152e76f9e626c8522ff237cd20b"
|
|
5526
5536
|
},
|
|
5527
5537
|
{
|
|
5528
5538
|
"path": "package.json",
|
|
5529
5539
|
"mode": "0000644",
|
|
5530
|
-
"sha256": "
|
|
5540
|
+
"sha256": "ba7f2f7a6795a2ecd9316195ff8ac0df597e077f7d8bce4efed6d45a5945ea88"
|
|
5531
5541
|
},
|
|
5532
5542
|
{
|
|
5533
5543
|
"path": "public-docs/index.html",
|
|
@@ -7402,7 +7412,7 @@
|
|
|
7402
7412
|
{
|
|
7403
7413
|
"path": "src/bongos/auth.js",
|
|
7404
7414
|
"mode": "0000644",
|
|
7405
|
-
"sha256": "
|
|
7415
|
+
"sha256": "517e25a2ca9b9adafa993725802af9e7fc1708f0a5609102ddce593e82478ca2"
|
|
7406
7416
|
},
|
|
7407
7417
|
{
|
|
7408
7418
|
"path": "src/bongos/cascade-dispatch.js",
|
|
@@ -7657,7 +7667,7 @@
|
|
|
7657
7667
|
{
|
|
7658
7668
|
"path": "src/module-api.js",
|
|
7659
7669
|
"mode": "0000644",
|
|
7660
|
-
"sha256": "
|
|
7670
|
+
"sha256": "4ca579815846fc51d427439c0e5e9d15e6102d628fe97b013c0234d59fe691c2"
|
|
7661
7671
|
},
|
|
7662
7672
|
{
|
|
7663
7673
|
"path": "src/module-loader/catalog.js",
|
|
@@ -7717,7 +7727,7 @@
|
|
|
7717
7727
|
{
|
|
7718
7728
|
"path": "tests/account_privacy_flags.mjs",
|
|
7719
7729
|
"mode": "0000644",
|
|
7720
|
-
"sha256": "
|
|
7730
|
+
"sha256": "d0c32156de102ab98447542def38978caded0dbe03e7a6f5004240484ba037ec"
|
|
7721
7731
|
},
|
|
7722
7732
|
{
|
|
7723
7733
|
"path": "tests/achievements_reconcile.mjs",
|
|
@@ -8522,7 +8532,7 @@
|
|
|
8522
8532
|
{
|
|
8523
8533
|
"path": "tests/effective_visibility_predicate.mjs",
|
|
8524
8534
|
"mode": "0000644",
|
|
8525
|
-
"sha256": "
|
|
8535
|
+
"sha256": "3563a287a7faa8bdf26dbde292584f62b4514814a0cfdf99e58e13f02514233b"
|
|
8526
8536
|
},
|
|
8527
8537
|
{
|
|
8528
8538
|
"path": "tests/est_advisory.mjs",
|
|
@@ -9979,6 +9989,16 @@
|
|
|
9979
9989
|
"mode": "0000644",
|
|
9980
9990
|
"sha256": "b499ea62cdc1a615e565ce7b0379b2cab83b9aaa8098361f94d3eb93ebc7f927"
|
|
9981
9991
|
},
|
|
9992
|
+
{
|
|
9993
|
+
"path": "tests/scouting_owner_access_and_reach.mjs",
|
|
9994
|
+
"mode": "0000644",
|
|
9995
|
+
"sha256": "5c53f51280b77816b5498808ab097a209a368c2e3b6cbdbc6a8ed2a55e11d8b9"
|
|
9996
|
+
},
|
|
9997
|
+
{
|
|
9998
|
+
"path": "tests/scouting_page_gate.mjs",
|
|
9999
|
+
"mode": "0000644",
|
|
10000
|
+
"sha256": "33c586229a164e9a90804bd00485f9b3cfc665213adf1717841103d2cd5d8293"
|
|
10001
|
+
},
|
|
9982
10002
|
{
|
|
9983
10003
|
"path": "tests/scouting_rollup_fanout.mjs",
|
|
9984
10004
|
"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.
|
package/docs/adr/README.md
CHANGED
|
@@ -297,5 +297,6 @@ 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
|
+
| 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
301
|
|
|
301
302
|
> ⚠️ **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": [
|
package/docs/copy-inventory.md
CHANGED
|
@@ -997,7 +997,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
|
|
|
997
997
|
| `8fc605026acf` | certain | Your pending join {…}: {…} — awaiting approval. | `modules/hall-ui/public/goal-inbox.js:88` |
|
|
998
998
|
| `e91418db3962` | certain | Your preferences, {…}. | `modules/hall-ui/public/settings.js:841` |
|
|
999
999
|
| `e91418db3962` | certain | Your preferences, {…}. | `modules/hall-ui/public/settings.js:1374` |
|
|
1000
|
-
| `
|
|
1000
|
+
| `73ee3dc369a5` | certain | Your profile page setting applies now — private already answers "not found" everywhere your profile is read. R | `modules/hall-ui/public/settings.html:110` |
|
|
1001
1001
|
| `f39746e3d66b` | certain | Your rendering preference could not be loaded. Reload the page to try again. | `modules/hall-ui/public/settings.js:549` |
|
|
1002
1002
|
| `b2dfd7120052` | certain | Your request is sent ✓ | `modules/hall-ui/public/hall-render.js:372` |
|
|
1003
1003
|
| `c16addf957d0` | certain | Your track record across every project you build on, shown on your public profile. Hiding it takes effect imme | `modules/hall-ui/public/settings.html:87` |
|
package/docs/copy-registry.json
CHANGED
|
@@ -9885,9 +9885,9 @@
|
|
|
9885
9885
|
"confidence": "certain"
|
|
9886
9886
|
},
|
|
9887
9887
|
{
|
|
9888
|
-
"id": "
|
|
9888
|
+
"id": "73ee3dc369a5",
|
|
9889
9889
|
"surface": "builders-hall",
|
|
9890
|
-
"text": "Your profile page setting applies now — private already answers \"not found\" everywhere your profile is read. Recruiter discoverability
|
|
9890
|
+
"text": "Your profile page setting applies now — private already answers \"not found\" everywhere your profile is read. Recruiter discoverability applies now too — switching it off takes you out of the People directory immediately, and back on puts you there again.",
|
|
9891
9891
|
"file": "modules/hall-ui/public/settings.html",
|
|
9892
9892
|
"line": 110,
|
|
9893
9893
|
"origin": "html-text",
|
package/docs/file-map.md
CHANGED
|
@@ -353,6 +353,8 @@ tests/
|
|
|
353
353
|
├── effective_visibility_predicate.mjs ← (task 1002967, goal 1000045): the EFFECTIVE-VISIBILITY structural guard over `modules/platform-identity/account-visibility.js` (the file task 1002295 landed and this task ADOPTED rather than duplicating). Three layers — SQL and JS agree row-for-row over the privacy-spec D2 matrix; that file is the only one allowed to compare `profile_state`/`account_visibility`/`hide_stats` to a literal (per-occurrence exemptions, each with a reason and a checked obligation); and every `SELECT` on `platform_identity_accounts` is enumerated FROM SOURCE and must join a shared predicate, be declared non-public, or be a checked deferral to a named caller — so a read that does not exist yet (hub search, the sitemap) fails the build until its author classifies it. Behavioural half runs the real `/profiles/:handle` route over an in-memory accounts table filtered by each statement's own WHERE clause: private → 404 (never 403), byte-identical to stranger and owner alike. DB-free `unit` lane.
|
|
354
354
|
├── application_lifecycle.mjs ← (task 1002284, goal 1000045, ADR 0201 D1/D3.2/D4 + ADR 0208): the application lifecycle EXECUTED — apply, list-pending, approve, decline, RESCIND (`invited → dismissed`), idempotency on the state already held, and the two things that must never happen: a builder without `access_request.review` moving the queue, and a VOUCH buying admission. Real router, REAL `requirePermission` resolving against the real government catalog's rank seed, and the REAL `hasInvitedAccessRequest` from `src/bongos/db-kernel.js` pointed at the same in-memory `access_requests` table (a stubbed `./pool`), whose filter is built from each statement's own WHERE clause and which refuses a predicate it cannot read. The no-authority proof seeds a vouched row and asserts the approve path and the admission read are byte-identical to the unvouched twin, and that no statement on either path names `applicant_github_id`/`vouched_at`. DB-free `unit` lane.
|
|
355
355
|
├── application_echo.mjs ← (task 1002284, goal 1000045, ADR 0201 D1.3/D1.4): the hub's STATUSLESS echo of an application. Part A pins the domain functions — the read binds `APPLICATION_ECHO_WINDOW_DAYS` itself (one 30, not three), the write is an upsert on the pair, neither statement mentions a status, and a non-numeric principal is refused before any query (ADR 0171 D2). Part B runs the real `POST /my-projects/join` and `GET /my-projects` over an in-memory `platform_identity_application_echoes` table with the relay's answer scripted: only an accepted application leaves a trace — a standing membership, either door refusal, a members-only project and an unreachable project leave none — and best-effort is asserted in both directions (a failed echo write still reports the filed application; a failed echo read still lands the memberships listing). DB-free `unit` lane.
|
|
356
|
+
├── scouting_owner_access_and_reach.mjs ← (task 1002290, goal 1000045 C5, ADR 0210): the R14 proof — WHO may read the cross-project recruiting directory, and WHO appears in it. A project owner (an instance-asserted, hub-witnessed `membership_kind='owner'`) reads `GET /scouting` with no rank at all; a builder who is neither owner nor `project.curate` holder gets the unchanged 403; an un-witnessed owner claim is refused; a broken ownership read falls closed onto the permission gate rather than admitting or 500ing. And the recruiting opt-out finally bites: a public, unhidden, active account that opted out is absent from `/scouting` and the hall's `/people` (the same endpoint), returns when it opts back in, and the opt-out grants and denies no permission — an opted-out owner still reads the directory while being absent from their own copy of it. Fake pool filtered by each statement's own WHERE clause, real route, real `requirePermission` over the real government catalog, real HTTP. Mutation-checked three ways. DB-free `unit` lane.
|
|
357
|
+
├── scouting_page_gate.mjs ← (task 1002290, goal 1000045 C5, ADR 0210 D3): the OTHER half of R14 — the `/builders/people` SHELL must admit exactly the two audiences `GET /scouting` admits. A project owner holding no permission reaches it; everyone the route refuses gets the byte-identical `?denied=project.curate` redirect; and each of six ways the `alsoAdmits` door can fail (module absent, function absent, throw, reject, non-boolean, prototype-only) fails CLOSED. Also pins that the other four page gates never consult the seam at all, and that exactly ONE gate declares a second door. Its own file because the route proof's ESM imports pull `auth.js` in before a session double could be installed — this one installs it first, the `government_page_gates.mjs` pattern. DB-free `unit` lane.
|
|
356
358
|
└── public_task_resolver.mjs ← (#604): the public `/public/tasks/:id` projection — title/value_summary exposed only for shipped|confirmed; withheld for in-flight so unshipped/security task names can't leak.
|
|
357
359
|
```
|
|
358
360
|
|
|
@@ -1287,5 +1287,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
|
|
|
1287
1287
|
landed since 1.19.423 with no explicit bump. run 33270353952. (task 1002620)
|
|
1288
1288
|
1.19.425 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1289
1289
|
landed since 1.19.424 with no explicit bump. run 33270559541. (task 1002620)
|
|
1290
|
+
1.19.426 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1291
|
+
landed since 1.19.425 with no explicit bump. run 33270772607. (task 1002620)
|
|
1290
1292
|
---------------------------------------------------------------------------
|
|
1291
1293
|
```
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
// people.js — the People page: the /scouting cross-project builder directory
|
|
2
2
|
// graduated into the hall (task 1002577; ADR 0141 §4 is the data's design of
|
|
3
|
-
// record). Same data endpoint as scouting.js — /api/gds/scouting,
|
|
4
|
-
// server-side
|
|
5
|
-
// on that
|
|
6
|
-
// hall chrome + kit primitives are what
|
|
7
|
-
//
|
|
3
|
+
// record). Same data endpoint as scouting.js — /api/gds/scouting, gated
|
|
4
|
+
// server-side on 'project.curate' OR being a project owner (R14, ADR 0210); the
|
|
5
|
+
// page itself is gated in serve-internal.js on that same pair, so a visitor who
|
|
6
|
+
// is neither never sees this shell. The hall chrome + kit primitives are what
|
|
7
|
+
// "graduate" means: searchable, paged, shell-navigable.
|
|
8
8
|
// All network values render through OTBKit.esc — never raw innerHTML.
|
|
9
9
|
(function () {
|
|
10
10
|
'use strict';
|
|
@@ -108,8 +108,8 @@
|
|
|
108
108
|
<div id="privacy-rows"></div>
|
|
109
109
|
<p class="settings-status" id="privacy-status" role="status" aria-live="polite"></p>
|
|
110
110
|
<p class="section-note">Your profile page setting applies now — private already answers "not found"
|
|
111
|
-
everywhere your profile is read. Recruiter discoverability
|
|
112
|
-
|
|
111
|
+
everywhere your profile is read. Recruiter discoverability applies now too — switching
|
|
112
|
+
it off takes you out of the People directory immediately, and back on puts you there again.</p>
|
|
113
113
|
</section>
|
|
114
114
|
|
|
115
115
|
<!-- CRAFT — discipline preferences (GET /me, PATCH /me/disciplines). -->
|
|
@@ -596,15 +596,15 @@
|
|
|
596
596
|
// toggle is deliberately BINARY (spec D1: "one column, one settings toggle");
|
|
597
597
|
// resetting back to null stays an API affordance, not a third UI state.
|
|
598
598
|
//
|
|
599
|
-
// COPY RULE — a toggle may promise exactly what is enforced, and
|
|
600
|
-
// are
|
|
601
|
-
//
|
|
602
|
-
//
|
|
603
|
-
//
|
|
604
|
-
//
|
|
605
|
-
//
|
|
606
|
-
//
|
|
607
|
-
//
|
|
599
|
+
// COPY RULE — a toggle may promise exactly what is enforced, and BOTH rows now
|
|
600
|
+
// are, one task apart. VISIBILITY went live with task 1002967: every public
|
|
601
|
+
// read joins the shared effective-visibility predicate, so private really does
|
|
602
|
+
// 404 — including to the owner, which the row must say out loud: a page that
|
|
603
|
+
// vanishes for you too is surprising unless you are told where your record went
|
|
604
|
+
// (here, Settings). DISCOVERABILITY went live with task 1002290:
|
|
605
|
+
// listScoutingRollup joins the reach predicate, so opting out really does empty
|
|
606
|
+
// the People directory of you. It names THAT surface and no wider set: the D3
|
|
607
|
+
// sliver is unbuilt, and no guard holds a promise about a surface that is not.
|
|
608
608
|
function renderPrivacy(data) {
|
|
609
609
|
const wrap = $('#privacy-rows');
|
|
610
610
|
if (!wrap) return;
|
|
@@ -618,7 +618,7 @@
|
|
|
618
618
|
privacyRow('visibility', 'Public profile page', isPublic ? 'Public' : 'Private', isPublic,
|
|
619
619
|
'Public means anyone can open your profile page and find you by name. Private hides the page from everyone — including you: it answers "not found" to every visitor, and you read your own record here in Settings instead.', '') +
|
|
620
620
|
privacyRow('discoverable', 'Discoverable by recruiters', discoverable ? 'On' : 'Off', discoverable,
|
|
621
|
-
`Recruiters looking for builders
|
|
621
|
+
`Recruiters looking for builders can find you. ${grant} Turning this off takes you out of the People directory straight away.`,
|
|
622
622
|
follows ? ' <span class="voices__default">· follows your profile setting</span>' : '');
|
|
623
623
|
}
|
|
624
624
|
|
|
@@ -645,8 +645,8 @@
|
|
|
645
645
|
: 'Saved — your profile page is set to private.';
|
|
646
646
|
}
|
|
647
647
|
return on
|
|
648
|
-
? 'Saved —
|
|
649
|
-
: 'Saved — you are
|
|
648
|
+
? 'Saved — recruiters can find you.'
|
|
649
|
+
: 'Saved — you are out of the People directory.';
|
|
650
650
|
}
|
|
651
651
|
|
|
652
652
|
// PATCH any own-scoped privacy flag (routes/profile.js): { hide_stats } and/or
|
|
@@ -28,8 +28,11 @@
|
|
|
28
28
|
// rollupVisibleSql / isRollupVisible NUMBERS — visible AND NOT
|
|
29
29
|
// hide_stats. ADR 0171 D2's predicate EXTENDED with the existence axis,
|
|
30
30
|
// and COMPOSED from accountVisibleSql rather than restated.
|
|
31
|
-
// isRecruiterDiscoverable
|
|
31
|
+
// recruiterReachSql / isRecruiterDiscoverable REACH — the COALESCE, privacy
|
|
32
32
|
// spec D1. A reach predicate only: it never buys addressability.
|
|
33
|
+
// scoutingListableSql THE DIRECTORY — numbers AND reach,
|
|
34
|
+
// the ONE predicate the recruiting directory joins (ADR 0210, task
|
|
35
|
+
// 1002290). Composed, never restated.
|
|
33
36
|
//
|
|
34
37
|
// The rollup pair was originally left to "the rollup readers" to apply. Task
|
|
35
38
|
// 1002967 moved it here for the reason this file exists: there are two such
|
|
@@ -137,6 +140,47 @@ function isRecruiterDiscoverable(account) {
|
|
|
137
140
|
return account.account_visibility === 'public';
|
|
138
141
|
}
|
|
139
142
|
|
|
143
|
+
// The SAME reach answer as a SQL fragment, for the read that filters in the
|
|
144
|
+
// database (task 1002290). It is the ONE spelling of that COALESCE outside the
|
|
145
|
+
// JS twin above — tests/effective_visibility_predicate.mjs fails any other file
|
|
146
|
+
// that writes it — so a directory query and a route can never disagree about who
|
|
147
|
+
// opted out. NULL still means "follow visibility", exactly as the column comment
|
|
148
|
+
// in platform_identity_012_account_privacy.sql says.
|
|
149
|
+
function recruiterReachSql(alias) {
|
|
150
|
+
if (alias && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(String(alias))) {
|
|
151
|
+
throw new Error('recruiterReachSql: alias must be a bare SQL identifier');
|
|
152
|
+
}
|
|
153
|
+
const q = alias ? `${alias}.` : '';
|
|
154
|
+
return `COALESCE(${q}recruiter_discoverable, ${q}account_visibility = 'public')`;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// scoutingListableSql — THE predicate the recruiting directory joins: the rollup
|
|
158
|
+
// axis AND the reach axis (ADR 0210, task 1002290). listScoutingRollup is the
|
|
159
|
+
// one reader; /scouting and the hall's /people are its two surfaces.
|
|
160
|
+
//
|
|
161
|
+
// WHY REACH BELONGS HERE AND NOT IN rollupVisibleSql. The two axes answer
|
|
162
|
+
// different questions and only one surface asks both. `hide_stats` suppresses
|
|
163
|
+
// NUMBERS everywhere, for every reader; the recruiting opt-out removes an
|
|
164
|
+
// account from the RECRUITING SURFACE only. The community spec's D3 leaderboards
|
|
165
|
+
// say so out loud — "the recruiting opt-out does not affect leaderboards; it
|
|
166
|
+
// governs the recruiting surface only" — so folding reach into rollupVisibleSql
|
|
167
|
+
// would silently empty a future public leaderboard of every opted-out builder.
|
|
168
|
+
// A leaderboard reader must therefore join rollupVisibleSql, never this.
|
|
169
|
+
//
|
|
170
|
+
// Composed from rollupVisibleSql and recruiterReachSql rather than restated, so
|
|
171
|
+
// changing what "visible", "hidden" or "reachable" means moves this with it.
|
|
172
|
+
function scoutingListableSql(alias) {
|
|
173
|
+
// Validated HERE as well as in both delegates. The delegates do refuse a bad
|
|
174
|
+
// alias, but only because rollupVisibleSql happens to be evaluated first in the
|
|
175
|
+
// template literal below — a security property should not rest on the
|
|
176
|
+
// evaluation order of a string interpolation, and the error a caller sees
|
|
177
|
+
// should name the function they actually called.
|
|
178
|
+
if (alias && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(String(alias))) {
|
|
179
|
+
throw new Error('scoutingListableSql: alias must be a bare SQL identifier');
|
|
180
|
+
}
|
|
181
|
+
return `(${rollupVisibleSql(alias)} AND ${recruiterReachSql(alias)})`;
|
|
182
|
+
}
|
|
183
|
+
|
|
140
184
|
module.exports = {
|
|
141
185
|
isAccountVisible,
|
|
142
186
|
accountVisibleSql,
|
|
@@ -146,4 +190,6 @@ module.exports = {
|
|
|
146
190
|
isRollupVisible,
|
|
147
191
|
ACCOUNT_VISIBILITY,
|
|
148
192
|
isRecruiterDiscoverable,
|
|
193
|
+
recruiterReachSql,
|
|
194
|
+
scoutingListableSql,
|
|
149
195
|
};
|