@bongos/core 1.19.430 → 1.19.432

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 (41) hide show
  1. package/.bongos-core.json +64 -44
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +3 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0215-the-recruiter-sliver-is-a-ceiling-not-a-step.md +116 -0
  8. package/docs/adr/0216-a-fake-pool-interpreter-must-fail-loudly-not-silently-ignore.md +102 -0
  9. package/docs/adr/README.md +2 -0
  10. package/docs/api/openapi.json +64 -3
  11. package/docs/api-reference.md +3 -2
  12. package/docs/copy-inventory.md +48 -48
  13. package/docs/copy-registry.json +57 -57
  14. package/docs/file-map.md +1 -0
  15. package/docs/module-api-changelog.md +4 -0
  16. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  17. package/modules/hall-ui/public/settings.html +1 -1
  18. package/modules/hall-ui/public/settings.js +17 -8
  19. package/modules/platform-identity/account-visibility.js +47 -0
  20. package/modules/platform-identity/platform-identity.js +4 -4
  21. package/modules/platform-identity/recruiter-sliver.js +197 -0
  22. package/modules/platform-identity/routes/scouting.js +58 -0
  23. package/package-lock.json +2 -2
  24. package/package.json +1 -1
  25. package/src/module-api.js +1 -1
  26. package/tests/account_privacy_flags.mjs +115 -9
  27. package/tests/application_echo.mjs +43 -9
  28. package/tests/application_lifecycle.mjs +18 -7
  29. package/tests/connections_ownership.mjs +44 -4
  30. package/tests/effective_visibility_predicate.mjs +137 -50
  31. package/tests/government_require_permission.mjs +22 -0
  32. package/tests/handle_machinery.mjs +15 -1
  33. package/tests/hub_profile_leak.mjs +18 -4
  34. package/tests/own_session_revoke.mjs +77 -9
  35. package/tests/profile_connections_ui.mjs +49 -10
  36. package/tests/profile_privacy_owner_view.mjs +68 -37
  37. package/tests/profile_rollup_consent.mjs +46 -14
  38. package/tests/profile_route.mjs +6 -0
  39. package/tests/recruiter_sliver_boundary.mjs +841 -0
  40. package/tests/scouting_owner_access_and_reach.mjs +175 -39
  41. package/tests/scouting_rollup_fanout.mjs +15 -4
@@ -22,13 +22,31 @@
22
22
  // including from their own copy of it.
23
23
  //
24
24
  // WHY THE FAKE POOL IS SHAPED THE WAY IT IS. Every filter below is built FROM THE
25
- // STATEMENT'S OWN WHERE CLAUSE — the tests/own_session_revoke.mjs and
26
- // tests/effective_visibility_predicate.mjs pattern. So deleting the reach term
27
- // from listScoutingRollup genuinely stops the clause from carrying the COALESCE,
28
- // the opted-out row genuinely comes back, and the assertions below genuinely go
29
- // red. A statement carrying a placeholder or a literal comparison the
30
- // interpreter cannot read is a LOUD ERROR, never a silently absent filter — a
31
- // guard that degrades to "no WHERE at all" passes forever and protects nothing.
25
+ // STATEMENT'S OWN WHERE CLAUSE — the tests/recruiter_sliver_boundary.mjs pattern.
26
+ // So deleting the reach term from listScoutingRollup genuinely stops the clause
27
+ // from carrying the COALESCE, the opted-out row genuinely comes back, and the
28
+ // assertions below genuinely go red. A statement carrying a placeholder, a
29
+ // literal comparison, a NULL test or a boolean SHAPE the interpreter cannot read
30
+ // is a LOUD ERROR, never a silently absent filter — a guard that degrades to "no
31
+ // WHERE at all" passes forever and protects nothing.
32
+ //
33
+ // IT READS STRUCTURE, NOT JUST TERMS, and that is why each filter is a CONSUMING
34
+ // parse rather than a list of `.test()` calls. Two ways a term-collecting
35
+ // interpreter lies, both found and both closed here (task 1003354):
36
+ //
37
+ // THE MISSING TERM. ADR 0213 made consent two columns and this file modelled
38
+ // only the first, so `terms_accepted_at IS NOT NULL` was silently ignored —
39
+ // the exact failure the paragraph above claims is impossible. An unmodelled
40
+ // term now survives in the residue and the structure check refuses it.
41
+ // THE REWRITTEN JOINER. An interpreter that COLLECTS the terms it recognises
42
+ // and ANDs them re-imposes an `AND` the SQL may no longer contain: rewrite
43
+ // scoutingListableSql's `AND` to `OR` and Postgres serves every active
44
+ // account on the platform, while a term-collecting fake answers exactly as
45
+ // before (idea 1000877, from the adversarial review of task 1002968).
46
+ //
47
+ // So each recognised term is REMOVED from the text as it is matched, and whatever
48
+ // survives must be nothing but whitespace, parentheses and `AND`. Both mutations
49
+ // are pinned by executable checks below, on the REAL predicates.
32
50
  //
33
51
  // Everything above the pool is real: the real route file, the real
34
52
  // requirePermission gate over the real government catalog, real HTTP. Only the
@@ -75,27 +93,43 @@ async function t(name, fn) {
75
93
  // ===========================================================================
76
94
 
77
95
  // Accounts. Only the columns the predicate reads matter; the rest is display.
96
+ //
97
+ // CONSENT IS TWO COLUMNS (ADR 0213, task 1002913): profile_state = 'active' AND
98
+ // terms_accepted_at IS NOT NULL, both stamped by the same upsertAccount branch.
99
+ // So a consented fixture carries both, and a row carrying only the first is an
100
+ // account activated BEFORE the platform terms existed — a real shape in the
101
+ // database, and the one that makes the acceptance term below load-bearing here
102
+ // rather than merely satisfied by every row.
103
+ const ACCEPTED_AT = '2026-08-29T00:00:00.000Z';
104
+
78
105
  const ACCOUNTS = [
79
106
  // The control: public, unhidden, reach unset (NULL = follow visibility = in).
80
107
  { github_id: '4242', github_login: 'octo', display_name: 'Octo',
81
- profile_state: 'active', account_visibility: 'public', hide_stats: false, recruiter_discoverable: null },
108
+ profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'public', hide_stats: false, recruiter_discoverable: null },
82
109
  // THE ROW THIS TASK EXISTS FOR: public, unhidden, and explicitly opted OUT.
83
110
  // Before R14 this account appeared in /scouting anyway.
84
111
  { github_id: '5000', github_login: 'optout', display_name: 'Opt Out',
85
- profile_state: 'active', account_visibility: 'public', hide_stats: false, recruiter_discoverable: false },
112
+ profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'public', hide_stats: false, recruiter_discoverable: false },
86
113
  // Explicitly opted IN while public — the same effective answer as NULL, from
87
114
  // the other side of the tri-state. Proves the column is read, not the default.
88
115
  { github_id: '8000', github_login: 'optin', display_name: 'Opt In',
89
- profile_state: 'active', account_visibility: 'public', hide_stats: false, recruiter_discoverable: true },
116
+ profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'public', hide_stats: false, recruiter_discoverable: true },
90
117
  // Private AND discoverable — the spec's D2 "private / discoverable" row. Reach
91
118
  // never buys addressability: the full rollup is never its disclosure.
92
119
  { github_id: '5151', github_login: 'scout', display_name: 'Scout',
93
- profile_state: 'active', account_visibility: 'private', hide_stats: false, recruiter_discoverable: true },
120
+ profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'private', hide_stats: false, recruiter_discoverable: true },
94
121
  // Hidden, and provisional — the two floors that already held before R14.
95
122
  { github_id: '6060', github_login: 'shy', display_name: 'Shy',
96
- profile_state: 'active', account_visibility: 'public', hide_stats: true, recruiter_discoverable: null },
123
+ profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'public', hide_stats: true, recruiter_discoverable: null },
97
124
  { github_id: '7070', github_login: 'newbie', display_name: 'Newbie',
98
- profile_state: 'provisional', account_visibility: 'public', hide_stats: false, recruiter_discoverable: null },
125
+ profile_state: 'provisional', terms_accepted_at: null, account_visibility: 'public', hide_stats: false, recruiter_discoverable: null },
126
+ // The ADR 0213 floor, and the ONLY row that differs from `octo` in exactly one
127
+ // column: active and public and unhidden and reachable, but activated before
128
+ // the platform terms existed. Delete the acceptance term from the composition
129
+ // and this is the row that walks back in — which is what makes the term's
130
+ // presence in the SQL provable here rather than merely asserted.
131
+ { github_id: '3030', github_login: 'unaccepted', display_name: 'Unaccepted',
132
+ profile_state: 'active', terms_accepted_at: null, account_visibility: 'public', hide_stats: false, recruiter_discoverable: null },
99
133
  ];
100
134
 
101
135
  // Project memberships, as the hub holds them. `membership_kind` is what the
@@ -133,24 +167,75 @@ const CLIENTS = [
133
167
 
134
168
  const KNOWN_ACCOUNT_COLUMNS = new Set(['profile_state', 'account_visibility', 'hide_stats', 'recruiter_discoverable']);
135
169
 
170
+ // What a fully-consumed WHERE may leave behind: whitespace, the parentheses the
171
+ // predicates compose with, and the `AND` that joins them. Anything else — an
172
+ // `OR`, a `NOT`, a `CASE`, or a term this interpreter never learned — means the
173
+ // fake would be applying a predicate the database was never given.
174
+ const PURE_CONJUNCTION = /^[\s()]*(?:AND[\s()]*)*$/;
175
+
176
+ /**
177
+ * Make a consuming term-matcher over one WHERE string. `take(re, make)` matches,
178
+ * models, and SPLICES the matched text out, so `residue()` is exactly the part
179
+ * of the clause this interpreter did not understand — and `assertPureConjunction`
180
+ * can then refuse a boolean shape it cannot faithfully reproduce.
181
+ *
182
+ * Written out per file rather than shared: each interpreter's TERM vocabulary is
183
+ * its own table's, and a shared registry would be a small SQL engine. What must
184
+ * not drift is the REFUSAL, so the guard below is identical everywhere and each
185
+ * copy pins it with its own calibration test.
186
+ */
187
+ function consumingParse(where) {
188
+ let residue = where;
189
+ const tests = [];
190
+ return {
191
+ tests,
192
+ take(re, make) {
193
+ const m = re.exec(residue);
194
+ if (!m) return null;
195
+ residue = residue.slice(0, m.index) + residue.slice(m.index + m[0].length);
196
+ tests.push(make(m));
197
+ return m;
198
+ },
199
+ assertPureConjunction() {
200
+ if (!PURE_CONJUNCTION.test(residue)) {
201
+ throw new Error(`fake pool: unmodelled boolean structure "${residue.trim()}" in "${where}" — every recognised term is ANDed here, so a predicate that is not a pure conjunction must be a loud error, never a silently different filter`);
202
+ }
203
+ },
204
+ };
205
+ }
206
+
136
207
  /**
137
208
  * A row test for platform_identity_accounts, built from `where`. Literal
138
209
  * comparisons count as much as placeholders: the effective-visibility predicate
139
210
  * is made ENTIRELY of literals, so an interpreter that modelled only $N filters
140
- * would score every privacy term as absent and pass no matter what.
211
+ * would score every privacy term as absent and pass no matter what. And the
212
+ * consent half is a NULL TEST, a third syntax again — which is precisely how it
213
+ * went unmodelled and unnoticed until task 1003354.
141
214
  */
142
215
  function accountFilter(where, params) {
143
- const tests = [];
216
+ const { tests, take, assertPureConjunction } = consumingParse(where);
144
217
  const consumed = new Set();
145
218
 
146
- if (/\bprofile_state = 'active'/.test(where)) tests.push((r) => r.profile_state === 'active');
147
- if (/\baccount_visibility = 'public'/.test(where)) tests.push((r) => r.account_visibility === 'public');
148
- if (/\bNOT (?:[a-z_]+\.)?hide_stats\b/.test(where)) tests.push((r) => !r.hide_stats);
149
- // The reach term, modelled through the SHARED JS twin rather than
150
- // re-implemented — an interpreter that read the COALESCE its own way could
219
+ // The reach axis FIRST, because recruiterReachSql's COALESCE spells
220
+ // `account_visibility = 'public'` inside itself as its own fallback —
221
+ // consuming it first is what stops that fallback being scored as a separate
222
+ // existence check. Modelled through the SHARED JS twin rather than
223
+ // re-implemented: an interpreter that read the COALESCE its own way could
151
224
  // agree with a WRONG query and still call it a pass.
152
- if (/COALESCE\((?:[a-z_]+\.)?recruiter_discoverable,/.test(where)) tests.push(isRecruiterDiscoverable);
153
-
225
+ take(/\bCOALESCE\((?:[a-z_]+\.)?recruiter_discoverable,\s*(?:[a-z_]+\.)?account_visibility = 'public'\s*\)/,
226
+ () => isRecruiterDiscoverable);
227
+ take(/\b(?:[a-z_]+\.)?profile_state = 'active'/, () => (r) => r.profile_state === 'active');
228
+ // The T&C half of the CONSENT floor (ADR 0213). Neither sweep below can see
229
+ // it — the placeholder sweep scans `$N`, the literal sweep matches `col = '…'`
230
+ // — so before this line the term was silently dropped and every fixture sailed
231
+ // through a predicate it did not satisfy. Now it is modelled; if it ever
232
+ // changes shape, the residue check refuses the statement instead.
233
+ take(/\b(?:[a-z_]+\.)?terms_accepted_at IS NOT NULL/, () => (r) => r.terms_accepted_at != null);
234
+ take(/\b(?:[a-z_]+\.)?account_visibility = 'public'/, () => (r) => r.account_visibility === 'public');
235
+ take(/\bNOT (?:[a-z_]+\.)?hide_stats\b/, () => (r) => !r.hide_stats);
236
+
237
+ // The TERM guards run before the STRUCTURE guard, so an unreadable term is
238
+ // reported as the unreadable term it is rather than as leftover text.
154
239
  for (const m of where.matchAll(/\$(\d+)/g)) {
155
240
  if (!consumed.has(m[1])) {
156
241
  throw new Error(`fake pool: unrecognised predicate on $${m[1]} in "${where}" — a filter this interpreter cannot read must never degrade to no filter`);
@@ -161,6 +246,7 @@ function accountFilter(where, params) {
161
246
  throw new Error(`fake pool: unrecognised literal comparison on ${m[1]} in "${where}"`);
162
247
  }
163
248
  }
249
+ assertPureConjunction();
164
250
  assert.ok(tests.length > 0, `fake pool: the accounts predicate in "${where}" carried NO readable term — the boundary would be vacuous`);
165
251
  return (r) => tests.every((fn) => fn(r));
166
252
  }
@@ -172,33 +258,29 @@ function accountFilter(where, params) {
172
258
  * how the client-status join announced itself when it was added.
173
259
  */
174
260
  function membershipFilter(where, params) {
175
- const tests = [];
261
+ const { tests, take, assertPureConjunction } = consumingParse(where);
176
262
  const consumed = new Set();
177
263
  const clientStatus = (clientId) => (CLIENTS.find((c) => c.client_id === clientId) || {}).status;
264
+ const bind = (m) => { consumed.add(m[1]); return params[Number(m[1]) - 1]; };
178
265
 
179
- const byId = /\bgithub_id = \$(\d+)/.exec(where);
180
- if (byId) { consumed.add(byId[1]); const v = params[Number(byId[1]) - 1]; tests.push((r) => String(r.github_id) === String(v)); }
181
-
182
- const byKind = /\bmembership_kind = \$(\d+)/.exec(where);
183
- if (byKind) { consumed.add(byKind[1]); const v = params[Number(byKind[1]) - 1]; tests.push((r) => r.membership_kind === v); }
184
-
185
- if (/\bsignin_verified_at IS NOT NULL\b/.test(where)) tests.push((r) => !!r.signin_verified_at);
186
-
266
+ take(/\b(?:[a-z_]+\.)?github_id = \$(\d+)/, (m) => { const v = bind(m); return (r) => String(r.github_id) === String(v); });
267
+ take(/\b(?:[a-z_]+\.)?membership_kind = \$(\d+)/, (m) => { const v = bind(m); return (r) => r.membership_kind === v; });
268
+ take(/\b(?:[a-z_]+\.)?signin_verified_at IS NOT NULL\b/, () => (r) => !!r.signin_verified_at);
187
269
  // The JOIN's own condition. Modelled as a real lookup against CLIENTS, so a
188
270
  // membership whose project is not in the table drops out the way an INNER JOIN
189
271
  // drops it — not as a term that quietly passes.
190
- const byStatus = /\bc\.status = \$(\d+)/.exec(where);
191
- if (byStatus) {
192
- consumed.add(byStatus[1]);
193
- const v = params[Number(byStatus[1]) - 1];
194
- tests.push((r) => clientStatus(r.client_id) === v);
195
- }
272
+ take(/\bc\.status = \$(\d+)/, (m) => { const v = bind(m); return (r) => clientStatus(r.client_id) === v; });
196
273
 
197
274
  for (const m of where.matchAll(/\$(\d+)/g)) {
198
275
  if (!consumed.has(m[1])) {
199
276
  throw new Error(`fake pool: unrecognised predicate on $${m[1]} in "${where}"`);
200
277
  }
201
278
  }
279
+ // The ownership gate is four ANDed terms and an `OR` anywhere in it would
280
+ // admit on ANY one of them — an un-witnessed claim, or a membership on a
281
+ // suspended project. The same refusal the accounts filter makes, for the same
282
+ // reason: this interpreter must not invent a joiner the database never saw.
283
+ assertPureConjunction();
202
284
  assert.ok(tests.length > 0, `fake pool: the membership predicate in "${where}" carried NO readable term`);
203
285
  return (r) => tests.every((fn) => fn(r));
204
286
  }
@@ -263,6 +345,10 @@ require.cache[moduleApiPath] = {
263
345
  // THE REAL GATE. Not a stub: the 403 a non-owner gets below is produced by
264
346
  // production code resolving production permission atoms.
265
347
  requirePermission: auth.requirePermission,
348
+ // The sliver route beside the directory gates on rank (ADR 0215 D4), so the
349
+ // PARTIAL doorway this proof pins has to carry it — otherwise requiring the
350
+ // router throws and this file fails for a reason that is not its subject.
351
+ requireRank: auth.requireRank,
266
352
  requireBuilder: (req, res, next) => {
267
353
  if (!currentCaller) return res.fail('unauthenticated', 401);
268
354
  req.builder = { ...currentCaller };
@@ -435,7 +521,8 @@ await t('and the whole matrix lands exactly where the specs put it', async () =>
435
521
  assert.deepEqual(shown, ['octo', 'optin'],
436
522
  'in: reach-unset-and-public (octo) + explicitly-opted-in (optin). '
437
523
  + 'out: optout (opted out), scout (private, and reach never buys addressability), '
438
- + `shy (hidden), newbie (provisional) — got ${JSON.stringify(shown)}`);
524
+ + 'shy (hidden), newbie (provisional), unaccepted (never accepted the platform '
525
+ + `terms — ADR 0213) — got ${JSON.stringify(shown)}`);
439
526
  });
440
527
 
441
528
  await t('opting back IN restores them — the toggle is a toggle, not a tombstone', async () => {
@@ -468,6 +555,55 @@ await t('MUTATION CHECK: drop the reach half and the opted-out builder comes str
468
555
  'WITHOUT it: optout is readmitted — so the passing assertions above are load-bearing, not decorative');
469
556
  });
470
557
 
558
+ await t('MUTATION CHECK: drop the ACCEPTANCE half and the pre-terms account walks back in', async () => {
559
+ // The same standard applied to the ADR 0213 term, and the reason a fixture
560
+ // that never accepted the platform terms exists at all: without it every row
561
+ // would satisfy the acceptance clause and deleting the clause would change
562
+ // nothing observable — a modelled term that proves nothing. The clause is
563
+ // removed from the REAL predicate rather than hand-written, so this cannot
564
+ // drift from what account-visibility.js actually composes.
565
+ const withTerms = scoutingListableSql('pa');
566
+ assert.match(withTerms, /pa\.terms_accepted_at IS NOT NULL/,
567
+ 'the directory predicate must still carry the acceptance term at all (ADR 0213)');
568
+ const withoutTerms = withTerms.replace(" AND pa.terms_accepted_at IS NOT NULL", '');
569
+
570
+ const admits = (clause) => ACCOUNTS.filter(accountFilter(clause, [])).map((a) => a.github_login).sort();
571
+ assert.deepEqual(admits(withTerms), ['octo', 'optin'], 'with the acceptance term: the pre-terms account is out');
572
+ assert.deepEqual(admits(withoutTerms), ['octo', 'optin', 'unaccepted'],
573
+ 'WITHOUT it: an account activated before the platform terms existed is served the recruiting '
574
+ + 'directory on a consent premise that was never true — which is what the term prevents');
575
+ });
576
+
577
+ await t('CALIBRATION: the interpreter refuses what it cannot model, rather than ignoring it', () => {
578
+ // Every guard above is worth exactly as much as this test. Before task 1003354
579
+ // the acceptance term fell through all three sweeps in silence and this file
580
+ // reported 17 passed while modelling a predicate the database no longer had.
581
+ assert.throws(() => accountFilter("profile_state = 'active' AND tier = 'gold'", []),
582
+ /unrecognised literal comparison on tier/,
583
+ 'a literal comparison on an unknown column is a term this interpreter cannot apply');
584
+ assert.throws(() => accountFilter("profile_state = 'active' AND github_id = $1", ['4242']),
585
+ /unrecognised predicate on \$1/,
586
+ 'and an unread placeholder, which is the older half of the same rule');
587
+ assert.throws(() => accountFilter("profile_state = 'active' AND deleted_at IS NULL", []),
588
+ /unmodelled boolean structure "[^"]*deleted_at IS NULL"/,
589
+ 'a NULL TEST is a third syntax again — invisible to both sweeps above, and the exact shape '
590
+ + 'that let terms_accepted_at be dropped silently');
591
+ assert.throws(() => accountFilter("profile_state = 'active' OR account_visibility = 'public'", []),
592
+ /unmodelled boolean structure/,
593
+ 'AND rewritten to OR serves every active account on the platform; a term-collecting '
594
+ + 'interpreter would re-impose the AND and stay green (idea 1000877)');
595
+ assert.throws(() => membershipFilter("github_id = $1 OR membership_kind = $2", ['9001', 'owner']),
596
+ /unmodelled boolean structure/,
597
+ 'the ownership gate gets the same refusal — an OR there admits on any one of its four terms');
598
+ // And the shapes it really does model still parse clean, so the guard is a
599
+ // filter on the unreadable and not a blanket refusal.
600
+ assert.doesNotThrow(() => accountFilter(scoutingListableSql('pa'), []));
601
+ assert.doesNotThrow(() => membershipFilter(
602
+ "m.github_id = $1 AND m.membership_kind = $2 AND m.signin_verified_at IS NOT NULL AND c.status = $3",
603
+ ['9001', 'owner', 'active'],
604
+ ));
605
+ });
606
+
471
607
  await t('/people is the SAME endpoint, so "absent from /people" is true by construction', async () => {
472
608
  const page = fs.readFileSync(path.join(repo, 'modules/hall-ui/public/people.js'), 'utf8');
473
609
  assert.match(page, /fetch\('\/api\/gds\/scouting/,
@@ -499,7 +635,7 @@ await t('opting out of recruiting grants and denies NO permission', async () =>
499
635
 
500
636
  await t('being scoutable buys nothing either — the response is the same descriptive whitelist', async () => {
501
637
  const res = await getScouting(OWNER);
502
- for (const leak of ['recruiter_discoverable', 'account_visibility', 'profile_state', 'hide_stats', 'client_id']) {
638
+ for (const leak of ['recruiter_discoverable', 'account_visibility', 'profile_state', 'terms_accepted_at', 'hide_stats', 'client_id']) {
503
639
  assert.ok(!res.text.includes(leak), `${leak} must never serialize into the directory body`);
504
640
  }
505
641
  const row = JSON.parse(res.text).builders[0];
@@ -51,17 +51,28 @@ const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log(` ok - ${
51
51
 
52
52
  try {
53
53
  // The module's actual migrations (idempotent CREATE TABLE IF NOT EXISTS).
54
+ // 012 and 019 are replayed because listScoutingRollup joins scoutingListableSql,
55
+ // which reads account_visibility + recruiter_discoverable (012) and
56
+ // terms_accepted_at (019). Without them a FRESH database answers 42703
57
+ // "column does not exist" rather than an aggregation assertion.
54
58
  await q(migrationSql('platform_identity_001_tables.sql'));
55
59
  await q(migrationSql('platform_identity_005_builder_activity.sql'));
56
60
  await q(migrationSql('platform_identity_007_profile_consent.sql'));
61
+ await q(migrationSql('platform_identity_012_account_privacy.sql'));
62
+ await q(migrationSql('platform_identity_019_terms_acceptance.sql'));
57
63
  await cleanup();
58
64
 
59
- // profile_state='active' — since ADR 0171 the rollup only lists consented,
60
- // visible builders; this test is about AGGREGATION, so fixtures opt in.
65
+ // Since ADR 0171 the rollup only lists consented, visible builders, and since
66
+ // ADR 0213 consent is TWO columns — profile_state='active' AND an acceptance
67
+ // of the platform terms, both stamped by the same upsertAccount branch. This
68
+ // test is about AGGREGATION, so every fixture opts fully in; a row carrying
69
+ // only profile_state would be a pre-terms account and would drop out.
61
70
  // (The predicate itself is proven by tests/profile_rollup_consent.mjs.)
62
71
  await q(
63
- `INSERT INTO platform_identity_accounts (github_id, github_login, display_name, avatar_url, profile_state)
64
- VALUES ($1, 'multi', 'Multi', 'a', 'active'), ($2, 'solo', 'Solo', 'b', 'active'), ($3, 'ghost', 'Ghost', 'c', 'active')`,
72
+ `INSERT INTO platform_identity_accounts (github_id, github_login, display_name, avatar_url, profile_state, terms_accepted_at)
73
+ VALUES ($1, 'multi', 'Multi', 'a', 'active', now()),
74
+ ($2, 'solo', 'Solo', 'b', 'active', now()),
75
+ ($3, 'ghost', 'Ghost', 'c', 'active', now())`,
65
76
  [MULTI, SOLO, GHOST],
66
77
  );
67
78
  // MULTI: two projects, 2 and 3 disciplines — the exact fan-out shape the old