@bongos/core 1.19.430 → 1.19.431

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.
@@ -0,0 +1,197 @@
1
+ // modules/platform-identity/recruiter-sliver.js — THE PRE-ACCEPT BOUNDARY: what
2
+ // a recruiter may see of a builder BEFORE that builder has accepted anything
3
+ // (privacy spec D3, owner-signed 2026-08-16; ADR 0215, task 1002968).
4
+ //
5
+ // WHAT IT IS. One bounded projection — handle, display name, avatar, the
6
+ // disciplines union, and a COARSE "active in the last 30 days" boolean — and
7
+ // nothing else. It exists so a recruiter has enough to decide whether to reach
8
+ // out, and so a builder who has decided nothing yet gives up no more than that.
9
+ // Under `hide_stats` the last two drop (they are rollup-derived) and the sliver
10
+ // is NAME-ONLY, exactly as the Settings toggle promises.
11
+ //
12
+ // NEVER IN IT, and each absence is load-bearing rather than an oversight:
13
+ // credits / works shipped / karma — the numbers are the post-accept record.
14
+ // project names / project count — "the aggregate is not the leak, the name
15
+ // is" (ADR 0171 D6), and a count beside a
16
+ // hidden total is a leak by subtraction,
17
+ // so there is no count either.
18
+ // last_active as a TIMESTAMP — the signal is coarse ON PURPOSE; a date
19
+ // is a behavioural trace, a boolean is a
20
+ // yes/no. Only the boolean crosses.
21
+ // github_id / github_login as FIELDS — neither is a key of the sliver. Note
22
+ // the LIMIT, stated rather than glossed:
23
+ // the avatar D3 grants is GitHub's own URL
24
+ // and embeds the account id verbatim, so
25
+ // granting the avatar grants the id inside
26
+ // it. Recorded in ADR 0215's honest limits
27
+ // and asserted precisely in the proof (the
28
+ // id may ride the avatar and nothing else).
29
+ // a profile-page link — spec D3 forbids it outright. The handle
30
+ // is an ADDRESS, not a door: for a private
31
+ // account GET /profiles/:handle joins
32
+ // accountVisibleSql and answers 404, so
33
+ // handing a recruiter the handle hands
34
+ // them no page. That is the whole meaning
35
+ // of "pre-accept": the sliver is the
36
+ // ceiling, not a step toward the rollup.
37
+ //
38
+ // WHY ITS OWN FILE. The spec says "a port beside getPublicProfileExtras", and
39
+ // platform-identity.js sits ON the 1500-line ratchet with no headroom — the
40
+ // account-visibility.js / scouting-authz.js / application-echo.js precedent.
41
+ // Beside in the module, not in the file.
42
+ //
43
+ // WHO MAY READ IT is not this file's question — routes/scouting.js gates the one
44
+ // surface, on the same door the recruiting directory uses. WHO MAY APPEAR IN IT
45
+ // is enforced HERE, in the query, by the shared recruiterSliverVisibleSql:
46
+ // consent (profile_state='active' — a provisional account is never
47
+ // recruiter-visible) AND recruiting reach. Deliberately NOT the existence axis:
48
+ // the private, explicitly-discoverable account is the row D3 exists to serve,
49
+ // and reach for a private account is an opt-IN the owner had to set by hand.
50
+ //
51
+ // NOT AUTHORITY (ADR 0016). Appearing here confers nothing on the builder and
52
+ // buys the recruiter nothing but a name to write to; nothing in this repo reads
53
+ // the sliver to decide anything. The rollup it is derived from is descriptive
54
+ // analytics, and a projection of descriptive data is still descriptive.
55
+ //
56
+ // Fail-closed everywhere: an absent row, an unreachable account, a non-numeric
57
+ // principal and an unusable handle all answer the SAME null, so a caller cannot
58
+ // tell "no such builder" from "not discoverable" (the ADR 0171 D4 rule).
59
+ //
60
+ // tests/recruiter_sliver_boundary.mjs is the proof — a whitelist over the exact
61
+ // key set, planted sentinels asserted absent from the whole response body, and
62
+ // the 404 that shows the boundary holds.
63
+ 'use strict';
64
+
65
+ const pi = require('./platform-identity');
66
+ const { recruiterSliverVisibleSql } = require('./account-visibility');
67
+
68
+ // The complete, closed key set of a FULL sliver, in the order D3 names them.
69
+ // Exported because the proof asserts against this list rather than a copy of it:
70
+ // a key added to the projection and quietly added to the test's own expectation
71
+ // is the failure mode a whitelist is supposed to make impossible.
72
+ const SLIVER_KEYS = ['handle', 'display_name', 'avatar_url', 'disciplines', 'active_recently'];
73
+
74
+ // The key set that survives `hide_stats`. The other two are rollup-derived, so
75
+ // hiding the cross-project record hides them too — and they are ABSENT, not
76
+ // null: the sliver does not carry an empty box where a number used to be.
77
+ const SLIVER_KEYS_HIDDEN = ['handle', 'display_name', 'avatar_url'];
78
+
79
+ // "Recently" is 30 days (spec D3). A named constant because it is a privacy
80
+ // parameter, not a tuning knob — widening it widens what the boolean discloses.
81
+ const ACTIVE_WINDOW_DAYS = 30;
82
+
83
+ // The two columns a sliver may be addressed BY, as a closed vocabulary. The
84
+ // lookup key is the one thing interpolated into the statement below, so it may
85
+ // never come from a caller's string: a route passes one of these constants or
86
+ // nothing at all (the account-visibility.js alias-guard posture).
87
+ const BY_GITHUB_ID = 'github_id';
88
+ const BY_HANDLE = 'lower(handle)';
89
+ const ADDRESSABLE_BY = [BY_GITHUB_ID, BY_HANDLE];
90
+
91
+ // readSliverAccount(by, value, { pool }) — the ONE account read behind both
92
+ // entry points: the identity columns of an account that passes the shared
93
+ // pre-accept predicate, or null. `hide_stats` is fetched because it decides the
94
+ // SHAPE of the answer; it never crosses into one.
95
+ async function readSliverAccount(by, value, { pool }) {
96
+ if (!ADDRESSABLE_BY.includes(by)) {
97
+ throw new Error('readSliverAccount: unknown lookup column');
98
+ }
99
+ const { rows } = await pool.query(
100
+ `SELECT github_id, handle, display_name, avatar_url, hide_stats
101
+ FROM platform_identity_accounts
102
+ WHERE ${recruiterSliverVisibleSql()} AND ${by} = $1
103
+ LIMIT 1`,
104
+ [value],
105
+ );
106
+ return rows[0] || null;
107
+ }
108
+
109
+ // readSliverActivity(githubId, { pool }) — the rollup-derived half: the
110
+ // disciplines union across every project, and whether ANY of them saw activity
111
+ // inside the window. Aggregated over the UN-FANNED activity table with the
112
+ // disciplines union computed separately, mirroring getRollupForBuilder (the
113
+ // pre-GROUP-BY unnest fan-out was bug task 1002911).
114
+ //
115
+ // The comparison happens in SQL and only its BOOLEAN comes back — the timestamp
116
+ // never enters this process, so no later refactor can accidentally serialize it.
117
+ // A builder with no activity rows at all yields NULL, coerced to false by the
118
+ // caller: "not known to be active" and "not active" are the same answer here.
119
+ async function readSliverActivity(githubId, { pool }) {
120
+ const { rows } = await pool.query(
121
+ `WITH act AS (
122
+ SELECT max(a.last_active) AS last_active
123
+ FROM platform_identity_builder_project_activity a
124
+ WHERE a.github_id = $1
125
+ ), disc AS (
126
+ SELECT array_agg(DISTINCT x.d) AS disciplines
127
+ FROM (SELECT unnest(a.disciplines) AS d
128
+ FROM platform_identity_builder_project_activity a
129
+ WHERE a.github_id = $1) x
130
+ WHERE x.d IS NOT NULL
131
+ )
132
+ SELECT (act.last_active > now() - make_interval(days => $2::int)) AS active_recently,
133
+ disc.disciplines
134
+ FROM act LEFT JOIN disc ON true`,
135
+ [githubId, ACTIVE_WINDOW_DAYS],
136
+ );
137
+ return rows[0] || null;
138
+ }
139
+
140
+ // projectSliver(account, activity) — the WHITELIST, built literally. Every key
141
+ // is written out by hand; no row is ever spread, so a column added to either
142
+ // query cannot ride along into a response. `activity` is null for a hidden
143
+ // account, and the two rollup-derived keys are then absent entirely.
144
+ function projectSliver(account, activity) {
145
+ const sliver = {
146
+ handle: account.handle ?? null,
147
+ display_name: account.display_name ?? null,
148
+ avatar_url: account.avatar_url ?? null,
149
+ };
150
+ if (!activity) return sliver;
151
+ return {
152
+ ...sliver,
153
+ disciplines: Array.isArray(activity.disciplines) ? activity.disciplines : [],
154
+ active_recently: activity.active_recently === true,
155
+ };
156
+ }
157
+
158
+ // sliverFor(account, { pool }) — shared tail of both entry points: decide the
159
+ // shape, and for a hidden account DO NOT ISSUE the activity read at all. Not
160
+ // merely dropping the fields after fetching them is the point — the cheapest way
161
+ // for a future edit to leak a number is for the number to already be in hand.
162
+ async function sliverFor(account, { pool }) {
163
+ if (!account) return null;
164
+ if (account.hide_stats) return projectSliver(account, null);
165
+ return projectSliver(account, await readSliverActivity(account.github_id, { pool }));
166
+ }
167
+
168
+ // getRecruiterSliver(githubId) — THE port the spec names (D3), keyed by the hub
169
+ // account id. Null unless the account consents and is recruiter-reachable.
170
+ // A non-numeric principal (core's 'system:bfg' shape) answers null rather than
171
+ // throwing a bigint cast mid-request, like every other read in this module.
172
+ async function getRecruiterSliver(githubId, { pool } = {}) {
173
+ if (!pi.isNumericGithubId(githubId)) return null;
174
+ return sliverFor(await readSliverAccount(BY_GITHUB_ID, String(githubId), { pool }), { pool });
175
+ }
176
+
177
+ // getRecruiterSliverByHandle(handle) — the same answer, addressed the way the
178
+ // platform addresses a builder publicly (spec Decision 3). An unusable handle
179
+ // answers null without a query.
180
+ //
181
+ // A PREVIOUS handle is deliberately NOT resolved here, unlike resolveHandle: the
182
+ // public page answers 301 so a shared link survives a rename, and a recruiting
183
+ // read has no shared links to keep working. Answering 404 instead of a redirect
184
+ // also keeps this surface from confirming that a rename happened.
185
+ async function getRecruiterSliverByHandle(handle, { pool } = {}) {
186
+ const norm = pi.normalizeHandle(handle);
187
+ if (!norm) return null;
188
+ return sliverFor(await readSliverAccount(BY_HANDLE, norm, { pool }), { pool });
189
+ }
190
+
191
+ module.exports = {
192
+ SLIVER_KEYS,
193
+ SLIVER_KEYS_HIDDEN,
194
+ ACTIVE_WINDOW_DAYS,
195
+ getRecruiterSliver,
196
+ getRecruiterSliverByHandle,
197
+ };
@@ -16,6 +16,19 @@
16
16
  // surface honors both the hide flag and the recruiting opt-out by construction,
17
17
  // and widening WHO may read it could not widen WHAT is shown.
18
18
  //
19
+ // TWO READS, TWO AUDIENCES — and the difference is the point (ADR 0215 D4, task
20
+ // 1002968). `GET /scouting` is the DIRECTORY: the full rollup for builders who
21
+ // are publicly visible, unhidden and reachable, read by a `project.curate` holder
22
+ // OR a project owner. `GET /scouting/:handle` is the PRE-ACCEPT SLIVER: a bounded
23
+ // projection (name, avatar, disciplines, a coarse activity boolean) of ONE
24
+ // builder — including the private-but-discoverable account the directory
25
+ // deliberately omits — and it is gated at the ARCHON floor, because it is the
26
+ // first surface here that discloses a private account and privacy spec D3 pins it
27
+ // to an archon-floor atom that has not been minted. Sharing one gate would have
28
+ // been tidier; it would also have widened an owner-signed privacy decision by
29
+ // implementation convenience. The blocker on the route below asks the owner
30
+ // whether the wider recruiting audience is authorized.
31
+ //
19
32
  // Reaches core only through the doorway (ADR 0083).
20
33
  'use strict';
21
34
 
@@ -23,6 +36,7 @@ const express = require('express');
23
36
  const api = require('../../../src/module-api');
24
37
  const pi = require('../platform-identity');
25
38
  const scoutingAuthz = require('../scouting-authz');
39
+ const recruiterSliver = require('../recruiter-sliver');
26
40
 
27
41
  const { pool, requireBuilder, parsePagination } = api;
28
42
 
@@ -64,6 +78,10 @@ module.exports = function scoutingRoutes() {
64
78
  const { limit } = parsePagination(req, { defaultLimit: 100, maxLimit: 500 });
65
79
  try {
66
80
  const builders = await pi.listScoutingRollup({ limit, pool });
81
+ // Same rollup-derived data as the sliver, so the same rule: hide and the
82
+ // recruiting opt-out must beat any cache (ADR 0171 D5). This route
83
+ // predates that posture and was the only rollup read still missing it.
84
+ res.set('Cache-Control', 'no-store');
67
85
  res.json({ builders });
68
86
  } catch (err) {
69
87
  log.error(`GET /scouting failed: ${err && err.message}`);
@@ -72,5 +90,45 @@ module.exports = function scoutingRoutes() {
72
90
  },
73
91
  );
74
92
 
93
+ // rank: archon — the PRE-ACCEPT SLIVER for ONE builder (privacy spec D3, ADR
94
+ // 0215 D4, task 1002968). This is the FIRST surface on the platform that
95
+ // discloses a PRIVATE account, and its audience is deliberately NOT the
96
+ // directory's.
97
+ //
98
+ // WHY A RANK GATE AND NOT THE RECRUITING DOOR ABOVE. Spec D3 gates this read on
99
+ // `project.recruit`, an atom whose declared shape is FLOOR ARCHON, widenable
100
+ // per instance (spec D1). That atom does not exist — ADR 0201 D3 and ADR 0210
101
+ // both refuse to mint it outside the project-admin-console wave that defines it
102
+ // (spike 1002786 R3), and this task does not either. The directory's door
103
+ // (`project.curate` at metic, OR any project owner — ADR 0210 D2) is WIDER than
104
+ // the signed floor on both halves, so adopting it here would widen an
105
+ // owner-signed privacy decision by implementation convenience. Until the atom
106
+ // lands, the honest reading of D3 is its floor, held by rank. Whether the wider
107
+ // recruiting audience is authorized for this surface is the owner's call and is
108
+ // filed as a blocker, NOT resolved here; when it is answered, this one line
109
+ // moves and nothing else does.
110
+ //
111
+ // 404, never 403, for a builder who is not recruiter-reachable: a 403 would
112
+ // confirm the handle exists, which is the disclosure the opt-out removes. The
113
+ // sliver's own predicate does the deciding, inside the module.
114
+ router.get(
115
+ '/scouting/:handle',
116
+ requireBuilder,
117
+ api.requireRank('archon'),
118
+ async (req, res) => {
119
+ try {
120
+ const sliver = await recruiterSliver.getRecruiterSliverByHandle(req.params.handle, { pool });
121
+ // The rollup half is live-derived and hide must beat any cache
122
+ // (ADR 0171 D5) — the /profiles/:handle posture, for the same reason.
123
+ res.set('Cache-Control', 'no-store');
124
+ if (!sliver) return res.fail('builder_not_found', 404);
125
+ res.json({ sliver });
126
+ } catch (err) {
127
+ log.error(`GET /scouting/:handle failed: ${err && err.message}`);
128
+ res.fail('sliver_read_failed', { status: 500, message: 'internal error' });
129
+ }
130
+ },
131
+ );
132
+
75
133
  return router;
76
134
  };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.430",
3
+ "version": "1.19.431",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.430",
9
+ "version": "1.19.431",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.430",
3
+ "version": "1.19.431",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",
package/src/module-api.js CHANGED
@@ -33,7 +33,7 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
33
33
  // there. scripts/gds/bump-version.js still rewrites the literal below; it appends
34
34
  // the entry to that file. Look for a version's history there, not here.
35
35
  // ---------------------------------------------------------------------------
36
- const CORE_VERSION = '1.19.430'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
36
+ const CORE_VERSION = '1.19.431'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
37
37
 
38
38
  // A namespaced logger so a module's log lines are attributable + consistent.
39
39
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -32,6 +32,12 @@ import { readFileSync } from 'node:fs';
32
32
  import { fileURLToPath } from 'node:url';
33
33
  import { dirname, join } from 'node:path';
34
34
 
35
+ // The sliver's own key lists — the copy guard below is written against THESE,
36
+ // not against a transcription of them, so a sixth granted field cannot be added
37
+ // without the Settings copy being made to name it (privacy spec D3: the toggle's
38
+ // copy "must state exactly this grant").
39
+ import { SLIVER_KEYS, SLIVER_KEYS_HIDDEN } from '../modules/platform-identity/recruiter-sliver.js';
40
+
35
41
  const require = createRequire(import.meta.url);
36
42
  const express = require('express');
37
43
 
@@ -173,6 +179,51 @@ test('setAccountPrivacy accepts an explicit null discoverable — the reset to "
173
179
  assert.equal(calls[0].params[7], true, 'and it counts as sent');
174
180
  });
175
181
 
182
+ /**
183
+ * Resolve what the UPDATE branch would store in `recruiter_discoverable`, from
184
+ * the statement's OWN text — the WHERE-derived-fake principle applied to a CASE.
185
+ * Each arm is recognised, and an arm this reader cannot see simply is not
186
+ * modelled, so deleting one from the SQL genuinely changes the answer here.
187
+ */
188
+ function resolveStoredReach(sql, params, stored) {
189
+ const from = sql.indexOf('recruiter_discoverable = CASE');
190
+ assert.ok(from >= 0, 'the reach column lost its CASE entirely');
191
+ const body = sql.slice(from, sql.indexOf('END', from));
192
+ const [visibilityWritten, reachWritten, setVisibility, setReach] = params.slice(4, 8);
193
+ assert.match(body, /ELSE platform_identity_accounts\.recruiter_discoverable/,
194
+ 'the CASE must still fall through to the STORED value — without the ELSE every PATCH would clear the flag');
195
+ if (/WHEN \$8::boolean THEN EXCLUDED\.recruiter_discoverable/.test(body) && setReach) return reachWritten;
196
+ if (/WHEN \$7::boolean AND EXCLUDED\.account_visibility = 'private' THEN NULL/.test(body)
197
+ && setVisibility && visibilityWritten === 'private') return null;
198
+ return stored;
199
+ }
200
+
201
+ test('GOING PRIVATE re-arms the reach default, so a consent given while public is not silently reused', async () => {
202
+ // THE DEFECT THIS CLOSES (found in adversarial review of task 1002968). A
203
+ // public builder toggles "Discoverable by recruiters" off and on again: the
204
+ // column is now an explicit `true`, not NULL. They later set the profile to
205
+ // Private, reading a row that promises the page "answers not found to every
206
+ // visitor". That stale `true` used to be INERT — every recruiting surface
207
+ // floored on the existence axis. The D3 sliver does not, by design, so it
208
+ // would have served them under a consent they gave to a different question.
209
+ //
210
+ // Each row is resolved from the statement's own CASE, so removing the new arm
211
+ // makes the first row answer `true` and this test red.
212
+ const cases = [
213
+ ['private, reach unnamed → RESET', { account_visibility: 'private' }, true, null],
214
+ ['private, reach named true → the owner re-consented, so it stands', { account_visibility: 'private', recruiter_discoverable: true }, true, true],
215
+ ['private, reach named false → explicit off', { account_visibility: 'private', recruiter_discoverable: false }, true, false],
216
+ ['public, reach unnamed → untouched', { account_visibility: 'public' }, true, true],
217
+ ['reach alone, no visibility change → untouched', { recruiter_discoverable: false }, true, false],
218
+ ['private on an account that never opted in → still the default', { account_visibility: 'private' }, null, null],
219
+ ];
220
+ for (const [name, fields, stored, want] of cases) {
221
+ expect({ rows: [row({ recruiter_discoverable: want })] });
222
+ await pi.setAccountPrivacy({ githubId: SESSION.github_id, fields }, { pool });
223
+ assert.equal(resolveStoredReach(calls[0].text, calls[0].params, stored), want, name);
224
+ }
225
+ });
226
+
176
227
  test('setAccountPrivacy refuses a value outside the model, before any query', async () => {
177
228
  expect();
178
229
  assert.equal((await pi.setAccountPrivacy(
@@ -271,15 +322,45 @@ test('neither flag crosses a PUBLIC response — the /u/<handle> read stays a wh
271
322
 
272
323
  // ---- 5. the settings copy states the grant ---------------------------------
273
324
 
274
- test('the discoverable toggle names EXACTLY what it grants — the D3 sliver, name-only when hidden', () => {
325
+ // How each granted FIELD has to read to a person. Keyed by the module's export,
326
+ // so this map is the place a sixth key must be described before it can ship —
327
+ // and the "every key is described" assertion below is what makes that binding.
328
+ const GRANT_WORDS = {
329
+ handle: /your name/,
330
+ display_name: /your name/,
331
+ avatar_url: /your picture/,
332
+ disciplines: /your disciplines/,
333
+ active_recently: /active in the last 30 days/,
334
+ };
335
+
336
+ test('the discoverable toggle names EXACTLY what it grants — checked against SLIVER_KEYS itself', () => {
275
337
  const js = readFileSync(join(ROOT, 'modules/hall-ui/public/settings.js'), 'utf8');
276
338
  const html = readFileSync(join(ROOT, 'modules/hall-ui/public/settings.html'), 'utf8');
277
- assert.match(js, /your name, your disciplines, and whether you have been active in the last 30 days/,
278
- 'the sliver is name + disciplines + a coarse activity signal — spelled out, not implied');
339
+ // Privacy spec D3 makes this a MUST: "The discoverable toggle's settings copy
340
+ // must state exactly this grant." It was checked against three transcribed
341
+ // phrases, and the transcription had gone stale in the direction that matters:
342
+ // the copy said the hidden sliver was "your name and nothing else" while
343
+ // SLIVER_KEYS_HIDDEN also ships avatar_url, and the shown branch never named
344
+ // the picture at all. A user was being told something false about their own
345
+ // privacy. It is now checked against the shipped key lists.
346
+ const shown = /: 'They will see ([^']+)'/.exec(js);
347
+ const hidden = /\? 'Because your cross-project record is hidden, ([^']+)'/.exec(js);
348
+ assert.ok(shown && hidden, 'both branches of the grant string must still be findable');
349
+
350
+ for (const key of SLIVER_KEYS) {
351
+ assert.ok(GRANT_WORDS[key], `SLIVER_KEYS grew "${key}" and nothing here says how to describe it — spec D3 says the copy must state the grant, so a new granted field cannot ship undescribed`);
352
+ assert.match(shown[1], GRANT_WORDS[key], `the toggle must name "${key}" — it is in the sliver a recruiter receives`);
353
+ }
354
+ for (const key of SLIVER_KEYS_HIDDEN) {
355
+ assert.match(hidden[1], GRANT_WORDS[key], `hidden, the sliver still ships "${key}", so the name-only copy must still name it`);
356
+ }
357
+ for (const key of SLIVER_KEYS.filter((k) => !SLIVER_KEYS_HIDDEN.includes(k))) {
358
+ assert.doesNotMatch(hidden[1], GRANT_WORDS[key], `"${key}" drops under hide_stats, so the copy must not keep promising it`);
359
+ }
279
360
  assert.match(js, /never your credits, works shipped, karma, or the projects you build on/,
280
361
  'and the copy says what it does NOT grant');
281
- assert.match(js, /cross-project record is hidden, they will see your name and nothing else/,
282
- 'hide_stats shrinks the grant to name-only, because the rest is rollup-derived');
362
+ assert.match(js, /the one thing a recruiter can see while your profile page is private/,
363
+ 'a private account is told the sliver is the exception to "not found to every visitor" — the surprising half of D2/D3');
283
364
  assert.match(html, /<section[^>]*id="privacy-scroll"[^>]*hidden>/,
284
365
  'the section ships hidden — it reveals only where the hub module answers');
285
366
  });
@@ -304,13 +385,38 @@ test('each toggle claims exactly what is enforced — and BOTH are now in force'
304
385
  'recruiting reach is enforced now (task 1002290), so the row promises it in the present tense');
305
386
  assert.doesNotMatch(js, /will be able to find you/,
306
387
  'the future tense outlived its truth the moment listScoutingRollup joined the reach predicate');
307
- assert.match(js, /takes you out of the People directory straight away/,
308
- 'and the row names the ONE surface that reads the flag today — a promise you can go and check');
388
+ // WIDENED BY TASK 1002968, on the same rule that narrowed it. 1002290 cut this
389
+ // to "the People directory" because the D3 sliver was unbuilt and no guard here
390
+ // could hold a wider claim. The sliver is now built and gated, so BOTH
391
+ // recruiting reads join the reach axis and the row may name both — and the
392
+ // wider claim is held by the guard below rather than by this sentence.
393
+ assert.match(js, /removes you from both recruiting surfaces/,
394
+ 'the row names both surfaces that read the flag today — a promise you can go and check');
309
395
  assert.doesNotMatch(js, /every recruiting view/,
310
- 'never a promise over surfaces that do not exist yet: the D3 recruiter sliver is unbuilt, so no '
311
- + 'guard here could hold that claim, and the copy rule is that a toggle promises exactly what is enforced');
396
+ 'still never an OPEN-ENDED promise: "both" is a claim a guard can hold, "every" is not — a third '
397
+ + 'recruiting read would make it false with nothing to notice');
312
398
  assert.match(html, /profile page setting applies now[\s\S]*Recruiter discoverability applies now too/,
313
399
  'the section note now says both are live, rather than splitting them');
314
400
  assert.doesNotMatch(html, /starts applying as the recruiting surfaces land/,
315
401
  'the "recorded and waiting" note must not outlive the surface that started reading it');
316
402
  });
403
+
404
+ test('the recruiting surface really is exactly two reads, and each one joins REACH', () => {
405
+ // This is what makes the widened copy above checkable rather than a slogan.
406
+ // Both halves are enumerated from source: a third route on the recruiting
407
+ // surface, or either reader dropping the reach axis, reds here — which is the
408
+ // signal that the toggle's promise has stopped being true.
409
+ const route = readFileSync(join(ROOT, 'modules/platform-identity/routes/scouting.js'), 'utf8');
410
+ const mounted = route.match(/router\.get\(\s*\n?\s*'([^']+)'/g) || [];
411
+ assert.equal(mounted.length, 2,
412
+ `the recruiting surface mounts ${mounted.length} route(s), not 2 — the Settings copy promises "both recruiting surfaces" and can no longer name them`);
413
+ assert.match(route, /'\/scouting'/, 'the directory');
414
+ assert.match(route, /'\/scouting\/:handle'/, 'and the pre-accept sliver');
415
+
416
+ const av = readFileSync(join(ROOT, 'modules/platform-identity/account-visibility.js'), 'utf8');
417
+ for (const composed of ['scoutingListableSql', 'recruiterSliverVisibleSql']) {
418
+ const body = av.slice(av.indexOf(`function ${composed}(`));
419
+ assert.match(body.slice(0, body.indexOf('\n}')), /recruiterReachSql\(alias\)/,
420
+ `${composed} stopped composing the reach axis — opting out would no longer empty that surface`);
421
+ }
422
+ });
@@ -79,6 +79,8 @@ import {
79
79
  isRecruiterDiscoverable,
80
80
  recruiterReachSql,
81
81
  scoutingListableSql,
82
+ recruiterSliverVisibleSql,
83
+ isRecruiterSliverVisible,
82
84
  } from '../modules/platform-identity/account-visibility.js';
83
85
 
84
86
  const require = createRequire(import.meta.url);
@@ -119,6 +121,10 @@ const SPELLING_EXEMPTIONS = [
119
121
  text: 'hide_stats = true',
120
122
  count: 1,
121
123
  why: "hiddenGithubIds asks the OPPOSITE question to visibility — 'which of these accounts have hidden themselves' — for the closeness-graph consent gate. It must stay fail-closed on hide alone (ADR 0171 D4), so it is not expressible as the shared predicate and is not a variant of it.",
124
+ }, {
125
+ text: "account_visibility = '",
126
+ count: 1,
127
+ why: "setAccountPrivacy's ON CONFLICT branch, and a WRITE rather than a read gate — it reads EXCLUDED, the value being written, to decide whether this PATCH re-arms recruiter_discoverable to its 'follow visibility' default. Going private without naming reach must clear an explicit true, because that true was set while PUBLIC and meant 'recruiters may see my public profile' (ADR 0215, task 1002968); the D3 sliver has no existence floor and would otherwise read that stale consent as fresh. It gates no read, so it cannot be expressed as the shared predicate and is not a variant of one — the SPELLINGS comment already excludes writes in principle, and this is the occurrence its regex cannot tell apart.",
122
128
  }],
123
129
  obligation: (code) => /require\(\s*'\.\/account-visibility'\s*\)/.test(code),
124
130
  demands: 'still imports the shared predicate rather than re-deriving one',
@@ -341,8 +347,14 @@ function accountStatements(code) {
341
347
  * recruiterReachSql inside the owner file, so a reader that joins it has adopted
342
348
  * the shared rule — but it must be named here, or wiring the recruiting reach in
343
349
  * would read as a reader that stopped adopting anything.
350
+ * recruiterSliverVisibleSql (task 1002968) is here on the same terms and one
351
+ * caveat: it is the ONE composed answer that deliberately omits the existence
352
+ * axis, because the private-but-discoverable account is the D3 sliver's subject.
353
+ * That is a decision recorded in the owner file and asserted above — not a
354
+ * reader quietly dropping a term, which is what this regex would otherwise let
355
+ * through unnoticed.
344
356
  */
345
- const ADOPTS = /\b(?:accountVisibleSql|accountActiveSql|rollupVisibleSql|scoutingListableSql|isAccountVisible|isAccountActive|isRollupVisible)\s*\(/;
357
+ const ADOPTS = /\b(?:accountVisibleSql|accountActiveSql|rollupVisibleSql|scoutingListableSql|recruiterSliverVisibleSql|isAccountVisible|isAccountActive|isRollupVisible|isRecruiterSliverVisible)\s*\(/;
346
358
 
347
359
  // ===========================================================================
348
360
  // Self-tests — the machinery is the guard, so it gets proved too.
@@ -513,6 +525,30 @@ t('the recruiter-reach predicate still lives here, and is still the only COALESC
513
525
  'reach never buys addressability — that is what the D3 sliver is for');
514
526
  });
515
527
 
528
+ t('recruiterSliverVisibleSql is COMPOSED from consent AND reach, and omits existence ON PURPOSE (task 1002968)', () => {
529
+ // The pre-accept sliver is the one effective answer that must NOT floor on the
530
+ // existence axis: the privacy spec's D2 "private · discoverable" row is the
531
+ // account it exists to serve, and ANDing accountVisibleSql would empty it of
532
+ // exactly that account while leaving every test above still green.
533
+ assert.ok(recruiterSliverVisibleSql('pa').includes(accountActiveSql('pa')),
534
+ 'the consent floor, literally contained — a provisional account is never recruiter-visible (spec D3)');
535
+ assert.ok(recruiterSliverVisibleSql('pa').includes(recruiterReachSql('pa')),
536
+ 'and the reach axis, literally contained — the opt-out empties this surface too');
537
+ assert.ok(!recruiterSliverVisibleSql('pa').includes(accountVisibleSql('pa')),
538
+ 'and NOT the existence axis: dropping it is safe only because reach is an explicit opt-IN for a private account');
539
+ assert.doesNotMatch(recruiterSliverVisibleSql(), /hide_stats/,
540
+ 'hide changes the SHAPE of the sliver (name-only), never who appears in it');
541
+ // The JS twin, over the same D2 matrix the SQL is judged on.
542
+ const accepted = { terms_accepted_at: 'T1' }; // the T&C half of the consent floor (ADR 0213)
543
+ assert.equal(isRecruiterSliverVisible({ ...accepted, profile_state: 'active', account_visibility: 'private', recruiter_discoverable: true }), true);
544
+ assert.equal(isRecruiterSliverVisible({ ...accepted, profile_state: 'active', account_visibility: 'private', recruiter_discoverable: null }), false);
545
+ assert.equal(isRecruiterSliverVisible({ ...accepted, profile_state: 'provisional', account_visibility: 'public', recruiter_discoverable: true }), false);
546
+ assert.equal(isRecruiterSliverVisible({ profile_state: 'active', account_visibility: 'private', recruiter_discoverable: true }), false,
547
+ 'and the sliver inherits the T&C floor by COMPOSITION — an account that never accepted is not recruiter-visible');
548
+ assert.equal(isRecruiterSliverVisible(null), false, 'fail-closed on an absent account');
549
+ assert.throws(() => recruiterSliverVisibleSql('pa; DROP TABLE x --'), /bare SQL identifier/);
550
+ });
551
+
516
552
  // ===========================================================================
517
553
  // 2. The sole-owner rule — nothing else spells a variant
518
554
  // ===========================================================================
@@ -640,6 +676,27 @@ t('the shared predicate has real adopters (the wiring did not silently unwire it
640
676
  }
641
677
  });
642
678
 
679
+ t('the EXISTENCE-LESS predicate has exactly one reader, and it is the sliver (task 1002968)', () => {
680
+ // recruiterSliverVisibleSql counts as adoption in ADOPTS above, which is what
681
+ // lets the sliver's own read pass the classification rule — and that is a hole
682
+ // unless the predicate is also PINNED to its one caller. It is the only shared
683
+ // answer that omits `account_visibility = 'public'`, so a future public read
684
+ // that joined it would look adopted while quietly serving private accounts.
685
+ // BOTH FORMS, because the same diff put both into ADOPTS. Pinning only the SQL
686
+ // one left the JS twin as an open door: a new module file doing
687
+ // `rows.filter(isRecruiterSliverVisible)` over an ungated
688
+ // `SELECT … FROM platform_identity_accounts WHERE handle ILIKE $1` passed this
689
+ // entire guard — that is the hub profile search this file's own header names
690
+ // as the read that must fail the build, sailing through on the one predicate
691
+ // that omits the existence axis (found in adversarial review, task 1002968).
692
+ // Enumerated from source, so a second reader fails here rather than in review.
693
+ const readers = scanned
694
+ .filter((f) => f.rel !== PREDICATE_OWNER && /\b(?:recruiterSliverVisibleSql|isRecruiterSliverVisible)\s*\(/.test(f.code))
695
+ .map((f) => f.rel);
696
+ assert.deepEqual(readers, ['modules/platform-identity/recruiter-sliver.js'],
697
+ 'the pre-accept predicate is the recruiting sliver\'s alone — every other read must floor on the existence axis');
698
+ });
699
+
643
700
  t('getPublicProfileExtras gates on the shared JS port (its SQL carries no predicate to join)', () => {
644
701
  const pi = scanned.find((f) => f.rel === 'modules/platform-identity/platform-identity.js');
645
702
  const span = functionSpans(pi.code).find((s) => s.name === 'getPublicProfileExtras');
@@ -352,6 +352,28 @@ test('every modules/*/routes gate is migrated, bar the one documented holdout',
352
352
  // worth doing — it means minting an atom over the write — but it is the
353
353
  // continuing R107/R108 migration's job, not this ADR's.
354
354
  'security/routes/scans.js',
355
+ // Still requireRank after BV1.R105 — and for a reason the other holdouts do
356
+ // not have: the atom this gate WANTS is specified, named, and deliberately
357
+ // owned by another wave. `GET /scouting/:handle` is the privacy spec's D3
358
+ // pre-accept recruiter sliver (task 1002968, ADR 0215 D4), and D3 gates it on
359
+ // `project.recruit` — declared in spec D1 as FLOOR ARCHON, widenable per
360
+ // instance. That atom does not exist: ADR 0201 D3 and ADR 0210 both refuse to
361
+ // mint it outside the project-admin-console wave that defines it (spike
362
+ // 1002786 R3), because a third definition before any had shipped is the drift
363
+ // they were avoiding.
364
+ //
365
+ // The three ways out are all worse. Minting it here forks its definition.
366
+ // Borrowing a different archon atom (`access_request.review`, say) would name
367
+ // the wrong authority on a privacy surface. And reusing the sibling route's
368
+ // `project.curate`-or-project-owner door — the tidy option, and what the first
369
+ // draft did — is WIDER than the signed floor on both halves, on the first
370
+ // surface on this platform that discloses a PRIVATE account.
371
+ //
372
+ // So the floor is held by rank until the atom lands, which is the one spelling
373
+ // that neither invents authority nor widens it. It is temporary by
374
+ // construction: blocker 1000126 carries the audience question to the owner,
375
+ // and spike 1002786 R3 mints the atom that removes this entry.
376
+ 'platform-identity/routes/scouting.js',
355
377
  ].sort();
356
378
 
357
379
  const modulesDir = path.join(ROOT, 'modules');
@@ -159,6 +159,9 @@ require.cache[authPath] = {
159
159
  requireBuilder: (_req, _res, next) => next(),
160
160
  requireRank: () => (_req, _res, next) => next(),
161
161
  requirePermission: () => (_req, _res, next) => next(),
162
+ // ADR 0215 D4: the sliver route beside the directory gates on RANK, so the
163
+ // partial doorway must answer requireRank too or the router will not load.
164
+ requireRank: () => (_req, _res, next) => next(),
162
165
  },
163
166
  };
164
167
 
@@ -434,6 +437,9 @@ require.cache[moduleApiPath] = {
434
437
  pool: engine,
435
438
  requireBuilder: (_req, _res, next) => next(),
436
439
  requirePermission: () => (_req, _res, next) => next(),
440
+ // ADR 0215 D4: the sliver route beside the directory gates on RANK, so the
441
+ // partial doorway must answer requireRank too or the router will not load.
442
+ requireRank: () => (_req, _res, next) => next(),
437
443
  parsePagination: () => ({ limit: 100, offset: 0 }),
438
444
  corsPublicGet: () => (_req, _res, next) => next(),
439
445
  },