@bongos/core 1.19.1016 → 1.19.1017

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 (39) hide show
  1. package/.bongos-core.json +78 -38
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +4 -0
  4. package/clients/bongos-client/index.cjs +4 -0
  5. package/clients/bongos-client/index.d.ts +6 -1
  6. package/clients/bongos-client/index.mjs +4 -0
  7. package/docs/adr/0334-the-recruiting-directory-lists-a-hidden-builder-by-name.md +47 -0
  8. package/docs/adr/README.md +1 -0
  9. package/docs/api/openapi.json +56 -5
  10. package/docs/api-reference.md +7 -1
  11. package/docs/copy-inventory.md +5 -3
  12. package/docs/copy-registry.json +22 -4
  13. package/docs/file-map.md +1 -1
  14. package/docs/module-api-changelog.md +2 -0
  15. package/modules/hall-ui/public/people.html +11 -0
  16. package/modules/hall-ui/public/people.js +77 -11
  17. package/modules/hall-ui/public/people.states.json +1 -1
  18. package/modules/platform-identity/account-visibility.js +33 -2
  19. package/modules/platform-identity/builder-search.js +153 -0
  20. package/modules/platform-identity/migrations/platform_identity_023_builder_search_trgm.sql +40 -0
  21. package/modules/platform-identity/module.json +1 -1
  22. package/modules/platform-identity/routes/community-search.js +120 -0
  23. package/modules/platform-identity/routes/scouting.js +14 -7
  24. package/modules/platform-identity/scouting-name-only.js +47 -0
  25. package/modules/public-landing/public/projects-floor.states.json +9 -2
  26. package/modules/public-landing/public/projects.html +69 -4
  27. package/modules/ui-design/styles/expedition/mock.html +16 -3
  28. package/package-lock.json +2 -2
  29. package/package.json +1 -1
  30. package/src/module-api.js +1 -1
  31. package/tests/account_privacy_flags.mjs +4 -1
  32. package/tests/card_apply_ui.mjs +297 -0
  33. package/tests/community_search_route.mjs +385 -0
  34. package/tests/effective_visibility_predicate.mjs +49 -1
  35. package/tests/people_name_only.mjs +262 -0
  36. package/tests/profile_route.mjs +23 -5
  37. package/tests/recruiter_sliver_boundary.mjs +3 -0
  38. package/tests/scouting_owner_access_and_reach.mjs +112 -2
  39. package/tests/ui_design_styles.mjs +13 -0
@@ -4,13 +4,13 @@
4
4
  > Deterministic on a given tree — regenerate and commit rather than hand-editing.
5
5
  > Machine-readable twin: [`copy-registry.json`](copy-registry.json).
6
6
 
7
- **2035 strings** across 3 surfaces · 1736 distinct pieces of copy · 2035 certain, 0 heuristic · 212 shown in more than one place.
7
+ **2037 strings** across 3 surfaces · 1738 distinct pieces of copy · 2037 certain, 0 heuristic · 212 shown in more than one place.
8
8
 
9
9
  ## Surfaces
10
10
 
11
11
  | surface | lives at | files | strings |
12
12
  |---|---|---|---|
13
- | `builders-hall` | `modules/hall-ui/public` | 102 | 1544 |
13
+ | `builders-hall` | `modules/hall-ui/public` | 102 | 1546 |
14
14
  | `status-dashboard` | `modules/status-ui/public` | 4 | 100 |
15
15
  | `landing` | `modules/public-landing/public` | 5 | 391 |
16
16
 
@@ -78,7 +78,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
78
78
  | `7926e30955d2` | 3x | ← Back to goal | modules/hall-ui/public/goals.html:199, modules/hall-ui/public/goals.html:210, modules/hall-ui/public/goals.html:222 |
79
79
  | … | | _172 more in the JSON registry_ | |
80
80
 
81
- ## `builders-hall` — 1544 strings
81
+ ## `builders-hall` — 1546 strings
82
82
 
83
83
  | id | conf | text | where |
84
84
  |---|---|---|---|
@@ -618,6 +618,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
618
618
  | `a53e0cde7ff7` | certain | Link first — your rank role is set the moment you join. | `modules/hall-ui/public/settings.js:840` |
619
619
  | `64e85f1691f9` | certain | Link your Discord account and you're added to the {{worldName}} server with your rank role already set — no in | `modules/hall-ui/public/settings.html:61` |
620
620
  | `9a31cf5fe05b` | certain | Linking opens Discord's consent screen; unlinking applies at once. | `modules/hall-ui/public/settings.html:66` |
621
+ | `8e067a257eed` | certain | Listed by name only | `modules/hall-ui/public/people.html:53` |
621
622
  | `daf84bba41b1` | certain | Loading goals… | `modules/hall-ui/public/goals.html:44` |
622
623
  | `93ceeaa4e226` | certain | Loading idea | `modules/hall-ui/public/idea.html:32` |
623
624
  | `2b5826012538` | certain | Loading profile… | `modules/hall-ui/public/profile.html:34` |
@@ -1114,6 +1115,7 @@ Identical copy within one surface. Sometimes right (a repeated button), sometime
1114
1115
  | `bcdbded93d4e` | certain | The work board failed to load. Reload to try again. | `modules/hall-ui/public/work.js:1492` |
1115
1116
  | `25794c439321` | certain | Then copy the project onto your machine and run the one-shot bootstrap (it checks your setup, installs depende | `modules/hall-ui/public/hall-render.js:890` |
1116
1117
  | `df316c3484de` | certain | Then drive it however you like: | `modules/hall-ui/public/hall-render.js:825` |
1118
+ | `48ace42c55ef` | certain | These builders are open to recruiting but keep their cross-project record private, so only who they are is sho | `modules/hall-ui/public/people.html:54` |
1117
1119
  | `a0140098c0e0` | certain | They already have an invitation or a join-request pending on this goal. | `modules/hall-ui/public/goals-render.js:1303` |
1118
1120
  | `5151cf635644` | certain | They have asked to connect. | `modules/hall-ui/public/profile-connections.js:203` |
1119
1121
  | `10219b02433e` | certain | They join as a member once they accept — you can change that afterwards. Nobody is added until they do. | `modules/hall-ui/public/goals-render.js:1235` |
@@ -3,9 +3,9 @@
3
3
  "generator": "scripts/gds/copy-inventory.js",
4
4
  "goal": 1000074,
5
5
  "counts": {
6
- "strings": 2035,
7
- "unique_ids": 1736,
8
- "certain": 2035,
6
+ "strings": 2037,
7
+ "unique_ids": 1738,
8
+ "certain": 2037,
9
9
  "heuristic": 0,
10
10
  "duplicate_ids": 212
11
11
  },
@@ -13,7 +13,7 @@
13
13
  "builders-hall": {
14
14
  "at": "modules/hall-ui/public",
15
15
  "files": 102,
16
- "strings": 1544
16
+ "strings": 1546
17
17
  },
18
18
  "status-dashboard": {
19
19
  "at": "modules/status-ui/public",
@@ -7071,6 +7071,15 @@
7071
7071
  "origin": "html-text",
7072
7072
  "confidence": "certain"
7073
7073
  },
7074
+ {
7075
+ "id": "8e067a257eed",
7076
+ "surface": "builders-hall",
7077
+ "text": "Listed by name only",
7078
+ "file": "modules/hall-ui/public/people.html",
7079
+ "line": 53,
7080
+ "origin": "html-text",
7081
+ "confidence": "certain"
7082
+ },
7074
7083
  {
7075
7084
  "id": "daf84bba41b1",
7076
7085
  "surface": "builders-hall",
@@ -11535,6 +11544,15 @@
11535
11544
  "origin": "js-markup",
11536
11545
  "confidence": "certain"
11537
11546
  },
11547
+ {
11548
+ "id": "48ace42c55ef",
11549
+ "surface": "builders-hall",
11550
+ "text": "These builders are open to recruiting but keep their cross-project record private, so only who they are is shown. They are never ranked, and a role filter never finds them.",
11551
+ "file": "modules/hall-ui/public/people.html",
11552
+ "line": 54,
11553
+ "origin": "html-text",
11554
+ "confidence": "certain"
11555
+ },
11538
11556
  {
11539
11557
  "id": "a0140098c0e0",
11540
11558
  "surface": "builders-hall",
package/docs/file-map.md CHANGED
@@ -375,7 +375,7 @@ tests/
375
375
  ├── canonical_profile_url.mjs ← (task 1002804, goal 1000045 criterion C1, ADR 0214): the P08 proof — the canonical profile URL is resolved in ONE place, `handle` reaches `GET /builders/:id/profile` under the EXISTENCE predicate (so a hidden builder keeps their address), absent/provisional/private are BYTE-IDENTICAL so the numeric-id redirect cannot become an existence oracle, and the widened response still leaks no private column. Real core router over a WHERE-derived fake pool on a real socket; the real browser script in a vm above a stub DOM, run twice — as shipped (every surface dark, because `/u/<handle>` still 404s) and with the liveness constant flipped, so the three live behaviours are proved rather than deferred. Asserts its own DB-free lane membership. DB-free `unit` lane.
376
376
  ├── 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.
377
377
  ├── 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.
378
- ├── 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.
378
+ ├── 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. Since task 1002291 (R15, ADR 0334) it also proves the directory's NAME-ONLY tier: a hidden, reachable builder is listed by login, name and avatar alone, never ranked, and none of their record reaches the body. Fake pool filtered by each statement's own WHERE clause, real route, real `requirePermission` over the real government catalog, real HTTP. Mutation-checked five ways. DB-free `unit` lane. The page half is `people_name_only.mjs` (the real people.js + kit in a vm).
379
379
  ├── 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.
380
380
  ├── terms_acceptance.mjs ← (task 1002913, goal 1000045, ADR 0213): the platform T&C acceptance record. Proves the four properties that make it evidence rather than a claim — the /terms page carries the cross-project consent ADR 0141 §4 deferred to it and it is apex-only; acceptance is written ONLY by the hub's own /sso/authorize (driven over the real router with an attacker-controlled query string, so a forged timestamp/version/activate flag reaches nothing); it is written ONCE (the fake pool interprets the upsert's own column assignments, so COALESCE is executed, not string-matched, and an assignment shape it cannot read throws instead of reading as "unchanged"); and consent is two columns in the shared predicate, so an account the hub witnessed before the terms existed publishes nothing. Plus the projection rule — every read whose row a JS twin judges must select both consent columns, or the twin denies everyone silently. DB-free `unit` lane, asserts its own lane membership.
381
381
  ├── applicant_profile_boundary.mjs ← (task 1002972, goal 1000045 C4, privacy spec D7, ADR 0241): THE D7 BOUNDARY proof for the applicant view a reviewer sees. Whitelists read from the MODULES’ own exports — the private branch against `recruiter-sliver`’s `SLIVER_KEYS`/`SLIVER_KEYS_HIDDEN`, the public branch against the public-page payload’s key list — so a third shape (the “new disclosure class” D7 forbids) reds this file. Pins THE CONSENT SWAP in both directions on the same account: a private builder who never opted into recruiting gets an applicant view and gets `null` from `getRecruiterSliver`, so a refactor routing the port through the recruiter sliver cannot pass quietly. `hide_stats` beats the application in both branches; provisional and terms-never-accepted disclose nothing; planted sentinels asserted absent from the whole body with the avatar-embedded `github_id` pinned as the one honest exception. Plus the federated gate (ADR 0205): no echo → 404 and NO account row read (asserted on the statement log), the witness matched against the AUTHENTICATED client, an out-of-window echo stops working, and `Cache-Control: no-store`. Real router + real HTTP over in-memory tables filtered by each statement’s own WHERE clause. DB-free `unit` lane, asserted in the file itself.
@@ -2506,5 +2506,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
2506
2506
  landed since 1.19.1014 with no explicit bump. run 36078624472. (task 1002620)
2507
2507
  1.19.1016 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2508
2508
  landed since 1.19.1015 with no explicit bump. run 36080388706. (task 1002620)
2509
+ 1.19.1017 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
2510
+ landed since 1.19.1016 with no explicit bump. run 36081753087. (task 1002620)
2509
2511
  ---------------------------------------------------------------------------
2510
2512
  ```
@@ -44,6 +44,17 @@
44
44
  rollup offers; people.js writes it. -->
45
45
  <p class="ledger__note" id="people-foot" hidden></p>
46
46
  </section>
47
+
48
+ <!-- task 1002291 (ADR 0334): builders who hid their cross-project record
49
+ but are still open to recruiting, listed by name alone (privacy spec
50
+ D2). Its own panel because they are never ranked; hidden unless the
51
+ response carries a row, and people.js writes it. -->
52
+ <section class="scroll" id="people-named-scroll" aria-labelledby="people-named-h" hidden>
53
+ <h2 id="people-named-h" class="scroll__h">Listed by name only<span class="count-pill" id="people-named-count" hidden></span></h2>
54
+ <p class="scroll__lede" id="people-named-lede">These builders are open to recruiting but keep their cross-project record private, so only who they are is shown. They are never ranked, and a role filter never finds them.</p>
55
+ <div id="people-named-body"></div>
56
+ <p class="ledger__note" id="people-named-foot" hidden></p>
57
+ </section>
47
58
  </main>
48
59
 
49
60
  <footer class="app-foot" id="app-foot"></footer>
@@ -16,6 +16,13 @@
16
16
  // exists); then the four figures in tabular columns, the crafts as chips, and
17
17
  // when they were last seen. Search, a sort and a craft lens are the kit's
18
18
  // filter bar; the rows page through the kit's pager.
19
+ //
20
+ // THE SECOND TIER (task 1002291, ADR 0334). The response also carries
21
+ // `name_only`: builders who hid their cross-project record but are still open to
22
+ // recruiting, which privacy spec D2 lists "name-only". They get their own panel
23
+ // of identity cells because they have no record to rank, sort or filter by role:
24
+ // the lenses below leave them alphabetical, a search reads their name, and a
25
+ // role filter never matches them.
19
26
  // All network values render through OTBKit.esc — never raw innerHTML.
20
27
  (function () {
21
28
  'use strict';
@@ -26,6 +33,10 @@
26
33
  var sub = document.getElementById('people-sub');
27
34
  var countPill = document.getElementById('people-count');
28
35
  var foot = document.getElementById('people-foot');
36
+ var namedScroll = document.getElementById('people-named-scroll');
37
+ var namedCount = document.getElementById('people-named-count');
38
+ var namedBody = document.getElementById('people-named-body');
39
+ var namedFoot = document.getElementById('people-named-foot');
29
40
 
30
41
  function fmtRecency(iso) {
31
42
  if (!iso) return '—';
@@ -97,6 +108,7 @@
97
108
  var DIRECTORY_LIMIT = 500;
98
109
 
99
110
  var all = [];
111
+ var named = [];
100
112
  var query = '';
101
113
  var craft = '';
102
114
  var sortBy = '';
@@ -108,6 +120,17 @@
108
120
  (Array.isArray(b.disciplines) ? b.disciplines.join(' ') : '')).toLowerCase();
109
121
  return hay.indexOf(query) !== -1;
110
122
  }
123
+ // A name-only row carries no roles (they are part of the hidden record), so a
124
+ // role filter never matches it; a search reads the name.
125
+ function matchesNamed(b) {
126
+ if (craft) return false;
127
+ if (!query) return true;
128
+ return ((b.github_login || '') + ' ' + (b.display_name || '')).toLowerCase().indexOf(query) !== -1;
129
+ }
130
+ // A count that cannot overstate a clipped read: a full response means "at least".
131
+ function countText(n) {
132
+ return n >= DIRECTORY_LIMIT ? fmtInt(DIRECTORY_LIMIT) + '+' : fmtInt(n);
133
+ }
111
134
  // The server's order IS "track record" (ADR 0141 §4 ranks the rollup); the
112
135
  // other lenses re-order the loaded set client-side, ties keeping that order.
113
136
  var SORTS = {
@@ -128,6 +151,8 @@
128
151
 
129
152
  var pager = kit.makePager('people', 'hall-people-per-page');
130
153
  var ledger = null;
154
+ var namedPager = kit.makePager('people-named', 'hall-people-named-per-page');
155
+ var namedLedger = null;
131
156
 
132
157
  function rowHtml(b) {
133
158
  return '<tr>' +
@@ -141,12 +166,45 @@
141
166
  '</tr>';
142
167
  }
143
168
 
169
+ // The identity cell and nothing else: every other column is the record hidden.
170
+ function namedRowHtml(b) {
171
+ return '<tr><td>' + whoHtml(b) + '</td></tr>';
172
+ }
173
+
144
174
  function rerender() {
145
175
  if (ledger) ledger.render(ordered(all.filter(matches)));
176
+ if (namedLedger) namedLedger.render(named.filter(matchesNamed));
177
+ }
178
+
179
+ function mountNamed() {
180
+ if (!namedScroll || !named.length) return;
181
+ namedScroll.hidden = false;
182
+ if (namedCount) {
183
+ namedCount.textContent = countText(named.length);
184
+ namedCount.hidden = false;
185
+ }
186
+ // The ranked footnote's rule, for the one lens that reads this list: a full
187
+ // response is clipped, and a search cannot find a builder it never loaded.
188
+ var namedClipped = named.length >= DIRECTORY_LIMIT;
189
+ if (namedFoot) {
190
+ namedFoot.textContent = namedClipped
191
+ ? 'This list is clipped at the first ' + fmtInt(DIRECTORY_LIMIT) + ' by login. Search only reads what this page loaded, so a builder past that ' + fmtInt(DIRECTORY_LIMIT) + ' will not appear in it.'
192
+ : '';
193
+ namedFoot.hidden = !namedClipped;
194
+ }
195
+ namedLedger = kit.makeLedger({
196
+ mount: namedBody,
197
+ columns: ['Builder'],
198
+ rowHtml: namedRowHtml,
199
+ emptyMsg: 'No builder listed by name only matches these filters.',
200
+ pager: namedPager,
201
+ });
146
202
  }
147
203
 
148
- function mountDirectory(builders) {
204
+ function mountDirectory(builders, nameOnly) {
149
205
  all = Array.isArray(builders) ? builders : [];
206
+ named = Array.isArray(nameOnly) ? nameOnly : [];
207
+ if (namedScroll) namedScroll.hidden = true;
150
208
  // An EMPTY directory is almost never missing data — it is ADR 0171's
151
209
  // fail-closed consent boundary doing exactly what it was ratified to do.
152
210
  // `listScoutingRollup` filters on `profile_state = 'active'`, every account
@@ -160,7 +218,7 @@
160
218
  // leaderboard; the accounts simply are not publicly listed — and that wrong
161
219
  // explanation is why an empty directory got filed as a bug (task 1002937).
162
220
  // Say which of the two it is, so the page is never mistaken for broken.
163
- if (!all.length) {
221
+ if (!all.length && !named.length) {
164
222
  notice('Nobody is publicly listed here yet. A builder joins this directory once they have '
165
223
  + 'signed in on the main platform site at least once — accounts stay unlisted until then, '
166
224
  + 'and none are listed without it. Their work still shows on each project’s own ledger.');
@@ -169,16 +227,21 @@
169
227
  // A FULL PAGE IS A CLIPPED PAGE. Every sentence below has to hedge when this
170
228
  // is true, because each of them otherwise states a total the page cannot know.
171
229
  var clipped = all.length >= DIRECTORY_LIMIT;
230
+ // The second tier, named in the same sentence so the ranked count is never
231
+ // read as everyone the directory lists.
232
+ var namedPart = named.length ? '; ' + countText(named.length) + ' more by name only' : '';
172
233
  if (countPill) {
173
234
  countPill.textContent = clipped ? fmtInt(DIRECTORY_LIMIT) + '+' : String(all.length);
174
235
  countPill.hidden = false;
175
236
  }
176
237
  if (sub) {
177
- sub.textContent = clipped
178
- ? 'Showing the first ' + fmtInt(DIRECTORY_LIMIT) + ' builders across hosted projects, ranked by track record — there are more than this page can load. '
179
- + 'Descriptive only — cross-project numbers never carry authority here.'
180
- : fmtInt(all.length) + ' builder' + (all.length === 1 ? '' : 's') + ' listed across hosted projects, ranked by track record. '
181
- + 'Descriptive only — cross-project numbers never carry authority here.';
238
+ var listed = clipped
239
+ ? 'Showing the first ' + fmtInt(DIRECTORY_LIMIT) + ' builders across hosted projects, ranked by track record — there are more than this page can load' + namedPart
240
+ : all.length
241
+ ? fmtInt(all.length) + ' builder' + (all.length === 1 ? '' : 's') + ' listed across hosted projects, ranked by track record' + namedPart
242
+ // Nobody ranked yet: "0 builders ranked; 2 more by name only" would read as a count of nothing.
243
+ : countText(named.length) + ' builder' + (named.length === 1 ? '' : 's') + ' listed across hosted projects by name only';
244
+ sub.textContent = listed + '. Descriptive only — cross-project numbers never carry authority here.';
182
245
  }
183
246
  // THE LENSES LIE HARDER THAN THE COUNT DOES, and that is worth its own
184
247
  // sentence. Search, sort and the craft filter all re-order the set this page
@@ -214,6 +277,7 @@
214
277
  craft = String(vals['people-craft'] || '');
215
278
  sortBy = String(vals['people-sort'] || '');
216
279
  pager.page = 1;
280
+ namedPager.page = 1;
217
281
  rerender();
218
282
  },
219
283
  });
@@ -224,11 +288,13 @@
224
288
  columns: ['Builder', { label: 'Projects', class: 'ledger__num' }, { label: 'Ships', class: 'ledger__num' },
225
289
  { label: currencyLabel(), class: 'ledger__num' }, { label: 'Karma', class: 'ledger__num' }, `${window.OTB.roleAxisLabel()}s`, 'Last active'],
226
290
  rowHtml: rowHtml,
227
- // Reached only when the directory HAS builders and the lenses excluded them
228
- // all — the empty-API case returns above with its own explanation.
229
- emptyMsg: 'No listed builder matches that search.',
291
+ // With ranked builders, reached only when the lenses excluded them all;
292
+ // without, the directory lists hidden builders alone (the panel below) —
293
+ // the fully empty case returns above with its own explanation.
294
+ emptyMsg: all.length ? 'No listed builder matches that search.' : 'No builder with a public track record is listed yet.',
230
295
  pager: pager,
231
296
  });
297
+ mountNamed();
232
298
  rerender();
233
299
  }
234
300
 
@@ -244,7 +310,7 @@
244
310
  if (!r.ok) throw new Error('http ' + r.status);
245
311
  return r.json();
246
312
  })
247
- .then(function (data) { mountDirectory(data && data.builders); })
313
+ .then(function (data) { mountDirectory(data && data.builders, data && data.name_only); })
248
314
  .catch(function (e) {
249
315
  // The floor is the pack's label for metic, never a hardcoded rank name —
250
316
  // and the page used to say "Archon-only", which was wrong since ADR 0210.
@@ -1,5 +1,5 @@
1
1
  {
2
- "_": "People — the cross-project directory behind requireProjectCuratePage (metic+ via project.curate, or a project owner). Descriptive only; it grants no authority, and the page says so above the fold. Written with the re-skin (task 1003307); the redesign (task 1003470) added the identity cell, the sort and craft lenses, and the `search` state. Its legacy door /scouting was deleted in task 1003440; the URL 301s here. Populated look: the hall-preview harness (scripts/hall-preview/fixtures/scouting.json carries five builders, three with avatars).",
2
+ "_": "People — the cross-project directory behind requireProjectCuratePage (metic+ via project.curate, or a project owner). Descriptive only; it grants no authority, and the page says so above the fold. Written with the re-skin (task 1003307); the redesign (task 1003470) added the identity cell, the sort and craft lenses, and the `search` state. Its legacy door /scouting was deleted in task 1003440; the URL 301s here. Populated look: the hall-preview harness (scripts/hall-preview/fixtures/scouting.json carries five builders, three with avatars, plus two listed by name only — task 1002291 added that second panel; the `search` state shows its empty case).",
3
3
  "page": "/builders/people",
4
4
  "surface": "hall-ui",
5
5
  "stub": {
@@ -32,8 +32,13 @@
32
32
  // recruiterReachSql / isRecruiterDiscoverable REACH — the COALESCE, privacy
33
33
  // spec D1. A reach predicate only: it never buys addressability.
34
34
  // scoutingListableSql THE DIRECTORY — numbers AND reach,
35
- // the ONE predicate the recruiting directory joins (ADR 0210, task
36
- // 1002290). Composed, never restated.
35
+ // the predicate the recruiting directory's ranked tier joins (ADR 0210,
36
+ // task 1002290). Composed, never restated.
37
+ // scoutingNameOnlySql THE DIRECTORY'S NAME-ONLY TIER —
38
+ // existence AND hidden AND reach (ADR 0334, task 1002291; privacy spec D2:
39
+ // a hidden builder is "listed name-only (if not opted out)"). The same
40
+ // predicate as the one above with the hide term flipped, so the two tiers
41
+ // partition the directory: hide decides a row's SHAPE, reach its presence.
37
42
  // recruiterSliverVisibleSql / isRecruiterSliverVisible THE PRE-ACCEPT
38
43
  // SLIVER — consent AND reach, WITHOUT the existence axis (ADR 0215, task
39
44
  // 1002968; privacy spec D3). The one predicate that deliberately does not
@@ -205,6 +210,31 @@ function scoutingListableSql(alias) {
205
210
  return `(${rollupVisibleSql(alias)} AND ${recruiterReachSql(alias)})`;
206
211
  }
207
212
 
213
+ // scoutingNameOnlySql — WHO the recruiting directory lists by NAME ALONE: a
214
+ // publicly visible, reachable account that hid its cross-project record (ADR
215
+ // 0334, task 1002291). Privacy spec D2's recruiter column lists that account
216
+ // "name-only (if not opted out)"; before this the directory dropped it outright,
217
+ // reading hide as a second opt-out while Settings told the builder recruiters
218
+ // would see their name and picture.
219
+ //
220
+ // It is scoutingListableSql with the hide term FLIPPED and nothing else, shaped
221
+ // identically so the two tiers cannot overlap or leave a gap
222
+ // (tests/effective_visibility_predicate.mjs holds that). It keeps the EXISTENCE
223
+ // floor, so a private account is never listed even by name — that is the D3
224
+ // sliver's subject, at the archon floor (ADR 0215/0217).
225
+ //
226
+ // DANGEROUS OUTSIDE ITS ONE READER. It admits hidden accounts by design, so a
227
+ // read that joined it and returned a number would hand out exactly the record
228
+ // hide_stats withholds. scouting-name-only.js is its only reader and selects
229
+ // three identity columns; the guard pins both.
230
+ function scoutingNameOnlySql(alias) {
231
+ if (alias && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(String(alias))) {
232
+ throw new Error('scoutingNameOnlySql: alias must be a bare SQL identifier');
233
+ }
234
+ const q = alias ? `${alias}.` : '';
235
+ return `((${accountVisibleSql(alias)} AND ${q}hide_stats) AND ${recruiterReachSql(alias)})`;
236
+ }
237
+
208
238
  // recruiterSliverVisibleSql — THE predicate the PRE-ACCEPT recruiter sliver
209
239
  // joins: the CONSENT floor AND the reach axis (ADR 0215, privacy spec D3; task
210
240
  // 1002968). modules/platform-identity/recruiter-sliver.js is its one reader.
@@ -256,6 +286,7 @@ module.exports = {
256
286
  isRecruiterDiscoverable,
257
287
  recruiterReachSql,
258
288
  scoutingListableSql,
289
+ scoutingNameOnlySql,
259
290
  recruiterSliverVisibleSql,
260
291
  isRecruiterSliverVisible,
261
292
  };
@@ -0,0 +1,153 @@
1
+ // modules/platform-identity/builder-search.js — the PUBLIC builder search (D4,
2
+ // docs/specs/bongos-v1-community-tab.md; task 1002979, goal 1000110 CT3). Answers
3
+ // "find a builder by name, optionally narrowed to a discipline" for the future
4
+ // /community page (tasks 1002980 / 1003363) — NOT the recruiting directory
5
+ // (routes/scouting.js), which stays a separate, project.curate/owner-gated
6
+ // surface with its own consent predicate, and NOT its archon-floor pre-accept
7
+ // sliver (recruiter-sliver.js). This surface reveals nothing beyond the same
8
+ // public-profile rules /profiles/:handle already applies (D8: no new disclosure
9
+ // class).
10
+ //
11
+ // TWO PREDICATES FROM account-visibility.js, ONE PER AXIS D4 NAMES:
12
+ // NAME MATCH — accountVisibleSql (existence: active + public). A
13
+ // hide_stats account is still findable BY NAME — hiding removes the
14
+ // rollup, not the page (the same rule /profiles/:handle already honors).
15
+ // DISCIPLINE MATCH — rollupVisibleSql (existence AND NOT hide_stats),
16
+ // because a discipline is ROLLUP-DERIVED
17
+ // (platform_identity_builder_project_activity.disciplines) and the rollup
18
+ // is exactly what hide_stats suppresses. A hidden account's disciplines are
19
+ // therefore never attached and never filterable — which is what keeps a
20
+ // discipline facet from re-exposing a hidden account's rollup by a side
21
+ // door. Private and provisional accounts never reach either predicate, so
22
+ // they are absent from every result regardless of query or filter.
23
+ // Both predicates are IMPORTED, never re-derived — the sole-owner rule
24
+ // tests/effective_visibility_predicate.mjs enforces.
25
+ //
26
+ // Matching: handle / display_name PREFIX (escaped LIKE) OR pg_trgm fuzzy (the
27
+ // `%` similarity operator) — the two legs D4 asks for. BOTH legs compare
28
+ // lower(column) against lower($1)/lower($2) — the `%` operator is
29
+ // case-SENSITIVE (unlike ILIKE, pg_trgm has no case-insensitive variant), so
30
+ // without the explicit lower() a query differing only in case would silently
31
+ // miss the fuzzy leg while the `sim` column (already lower()-folded) still
32
+ // scored it. Indexed by migration platform_identity_023_builder_search_trgm.sql
33
+ // — a GIN gin_trgm_ops index on lower(handle) and lower(display_name), which
34
+ // accelerates both legs since both now query that same expression.
35
+ // github_id never crosses the projection — handle is the address, exactly as
36
+ // every sibling public read in this module already treats it.
37
+ 'use strict';
38
+
39
+ const { pool: defaultPool } = require('../../src/module-api');
40
+ const { accountVisibleSql, rollupVisibleSql } = require('./account-visibility');
41
+
42
+ const DEFAULT_LIMIT = 20;
43
+ const MAX_LIMIT = 50;
44
+ // Deep OFFSET pagination on an unauthenticated route is a free scan-and-discard
45
+ // knob (?offset=999999999 still walks and throws away every matched row before
46
+ // the LIMIT) — capped so a caller can page a reasonable distance and no further.
47
+ const MAX_OFFSET = 1000;
48
+ // A 1-character query has no usable trigram and a near-unselective LIKE
49
+ // prefix, so it would materialize and sort a large share of the table before
50
+ // LIMIT on every anonymous request. 2 chars is the floor at which a trigram
51
+ // exists at all.
52
+ const MIN_QUERY_CHARS = 2;
53
+ const MAX_QUERY_CHARS = 100;
54
+ const MAX_DISCIPLINE_CHARS = 32; // mirrors upsertActivity's per-discipline cap
55
+
56
+ function clampLimit(limit) {
57
+ const n = parseInt(limit, 10);
58
+ if (!Number.isFinite(n) || n <= 0) return DEFAULT_LIMIT;
59
+ return Math.min(n, MAX_LIMIT);
60
+ }
61
+
62
+ function clampOffset(offset) {
63
+ const n = parseInt(offset, 10);
64
+ if (!Number.isFinite(n) || n < 0) return 0;
65
+ return Math.min(n, MAX_OFFSET);
66
+ }
67
+
68
+ // Escape LIKE's own wildcard characters so a searcher's literal % or _ can't
69
+ // turn a prefix match into an unbounded pattern.
70
+ function escapeLikePrefix(s) {
71
+ return s.replace(/[\\%_]/g, (c) => `\\${c}`);
72
+ }
73
+
74
+ // searchBuilders — the one read behind GET /community/search. `q` is required
75
+ // (too short/empty/oversized → no results, never a throw — the route
76
+ // validates and 400s before calling this; this stays defensive on its own).
77
+ // `discipline` is optional; '' means no filter. Returns
78
+ // { results, limit, offset } — `limit`/`offset` are the CLAMPED values
79
+ // actually used, so a caller (the route) never re-derives them to echo back.
80
+ // A result's `disciplines` is null for a hidden account or one with no
81
+ // rollup activity.
82
+ async function searchBuilders({ q, discipline, limit, offset, pool = defaultPool } = {}) {
83
+ const query = typeof q === 'string' ? q.trim() : '';
84
+ const lim = clampLimit(limit);
85
+ const off = clampOffset(offset);
86
+ if (query.length < MIN_QUERY_CHARS || query.length > MAX_QUERY_CHARS) {
87
+ return { results: [], limit: lim, offset: off };
88
+ }
89
+ const disc = typeof discipline === 'string' ? discipline.trim().slice(0, MAX_DISCIPLINE_CHARS) : '';
90
+
91
+ const { rows } = await pool.query(
92
+ `WITH matches AS (
93
+ SELECT pa.github_id, pa.handle, pa.github_login, pa.display_name, pa.avatar_url,
94
+ (lower(pa.handle) = lower($1)) AS exact_handle,
95
+ GREATEST(similarity(lower(pa.handle), lower($1)),
96
+ similarity(lower(COALESCE(pa.display_name, '')), lower($1))) AS sim
97
+ FROM platform_identity_accounts pa
98
+ WHERE ${accountVisibleSql('pa')}
99
+ AND (
100
+ lower(pa.handle) LIKE lower($2) ESCAPE '\\'
101
+ OR lower(pa.display_name) LIKE lower($2) ESCAPE '\\'
102
+ OR lower(pa.handle) % lower($1)
103
+ OR lower(pa.display_name) % lower($1)
104
+ )
105
+ ), disc AS (
106
+ -- Scoped to the name matches only (never the whole activity table),
107
+ -- which is strictly smaller — though 'matches' itself is bounded only
108
+ -- by MIN_QUERY_CHARS' selectivity, not by a hard cap, since the
109
+ -- discipline filter (below) needs every match's disciplines before
110
+ -- LIMIT can apply.
111
+ SELECT x.github_id, array_agg(DISTINCT x.d) AS disciplines
112
+ FROM (SELECT a.github_id, unnest(a.disciplines) AS d
113
+ FROM platform_identity_builder_project_activity a
114
+ WHERE a.github_id IN (SELECT github_id FROM matches)
115
+ AND EXISTS (
116
+ SELECT 1 FROM platform_identity_accounts pa2
117
+ WHERE pa2.github_id = a.github_id AND ${rollupVisibleSql('pa2')})) x
118
+ WHERE x.d IS NOT NULL
119
+ GROUP BY x.github_id
120
+ )
121
+ SELECT m.handle, m.github_login, m.display_name, m.avatar_url, d.disciplines
122
+ FROM matches m
123
+ LEFT JOIN disc d ON d.github_id = m.github_id
124
+ WHERE ($3::text = '' OR d.disciplines @> ARRAY[$3]::text[])
125
+ ORDER BY m.exact_handle DESC, m.sim DESC, m.handle
126
+ LIMIT $4 OFFSET $5`,
127
+ [query, `${escapeLikePrefix(query)}%`, disc, lim, off],
128
+ );
129
+ return {
130
+ results: rows.map((r) => ({
131
+ handle: r.handle,
132
+ github_login: r.github_login,
133
+ display_name: r.display_name,
134
+ avatar_url: r.avatar_url,
135
+ disciplines: Array.isArray(r.disciplines) ? r.disciplines : null,
136
+ })),
137
+ limit: lim,
138
+ offset: off,
139
+ };
140
+ }
141
+
142
+ module.exports = {
143
+ searchBuilders,
144
+ clampLimit,
145
+ clampOffset,
146
+ escapeLikePrefix,
147
+ DEFAULT_LIMIT,
148
+ MAX_LIMIT,
149
+ MAX_OFFSET,
150
+ MIN_QUERY_CHARS,
151
+ MAX_QUERY_CHARS,
152
+ MAX_DISCIPLINE_CHARS,
153
+ };
@@ -0,0 +1,40 @@
1
+ -- platform_identity_023_builder_search_trgm.sql — GIN trigram indexes for the
2
+ -- public builder search (D4, docs/specs/bongos-v1-community-tab.md; task
3
+ -- 1002979). builder-search.js's searchBuilders() queries
4
+ -- lower(handle) LIKE lower($2) OR lower(handle) % lower($1) (and the same
5
+ -- pair for display_name) against platform_identity_accounts on EVERY request
6
+ -- to an UNAUTHENTICATED, rate-limited-but-public route — an unindexed
7
+ -- sequential scan there is a cost every anonymous caller can trigger.
8
+ --
9
+ -- INDEXED ON THE EXPRESSION, NOT THE BARE COLUMN. Both query legs compare
10
+ -- lower(handle)/lower(display_name), so a plain gin_trgm_ops index on the raw
11
+ -- column would never be used — Postgres only matches a functional index
12
+ -- against the identical expression. One index per column then serves BOTH
13
+ -- legs (prefix LIKE and % fuzzy), because pg_trgm's GIN opclass supports LIKE
14
+ -- as well as the trigram similarity operators.
15
+ --
16
+ -- pg_trgm itself is already enabled repo-wide by migrations/103_search_chunks.sql
17
+ -- (a core migration, so it runs before any module migration) — nothing to
18
+ -- create here.
19
+ --
20
+ -- WHY NOT CONCURRENTLY. Same reasoning core_222 and modules/ideas/migrations/-
21
+ -- ideas_004_lineage_route_index.sql recorded for their own indexes: CREATE
22
+ -- INDEX CONCURRENTLY cannot run inside a transaction block, and this file is
23
+ -- wrapped BEGIN/COMMIT so it lands atomically. Acceptable here specifically
24
+ -- because platform_identity_accounts is a hub-only, human-signup-paced table
25
+ -- (one row per platform account) — the build is a light, one-time lock against
26
+ -- a small table, not a write-hot one.
27
+ --
28
+ -- Additive + platform_identity_-namespaced (ADR 0083 §5). Forward-safe: index
29
+ -- creation only, no column/type change, so the previous release runs
30
+ -- unmodified against this schema. Idempotent (IF NOT EXISTS).
31
+
32
+ BEGIN;
33
+
34
+ CREATE INDEX IF NOT EXISTS platform_identity_accounts_handle_trgm
35
+ ON platform_identity_accounts USING gin (lower(handle) gin_trgm_ops);
36
+
37
+ CREATE INDEX IF NOT EXISTS platform_identity_accounts_display_name_trgm
38
+ ON platform_identity_accounts USING gin (lower(display_name) gin_trgm_ops);
39
+
40
+ COMMIT;
@@ -10,7 +10,7 @@
10
10
  "maintainer": "Cloud Bongos maintainers",
11
11
  "maintenance": { "status": "core-maintained" },
12
12
  "contributes": {
13
- "routes": ["sso", "projects", "my-projects", "scouting", "profile", "public-profile", "connections"],
13
+ "routes": ["sso", "projects", "my-projects", "scouting", "profile", "public-profile", "connections", "community-search"],
14
14
  "migrations": true,
15
15
  "pollers": ["code-sweep", "visibility-pull"]
16
16
  },