@bongos/core 1.19.431 → 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.
@@ -3,9 +3,11 @@
3
3
  // Proves the consent + hide semantics against real SQL — pg-mem cannot execute
4
4
  // unnest(), so the rollup queries need a live Postgres (the task-1002911
5
5
  // precedent). Replays the module's actual migrations, then asserts:
6
- // 1. listScoutingRollup lists ONLY active + not-hidden builders (D2/D3) —
7
- // provisional and hidden accounts drop out of /people + /scouting by
8
- // construction.
6
+ // 1. listScoutingRollup lists ONLY consented + not-hidden builders (D2/D3) —
7
+ // provisional, hidden and never-accepted accounts drop out of /people +
8
+ // /scouting by construction. Consent is TWO columns since ADR 0213
9
+ // (profile_state='active' AND terms_accepted_at IS NOT NULL), and the
10
+ // LEGACY fixture is the pre-terms row that proves the second one.
9
11
  // 2. getRollupForBuilder aggregates un-fanned and names ONLY projects whose
10
12
  // catalog row is publicly listed (featured + active); everything else is
11
13
  // counted in more_projects, never named. client_id never crosses.
@@ -44,8 +46,12 @@ try {
44
46
  }
45
47
 
46
48
  // High-numbered fixture ids so a shared DB can never collide with real data.
47
- const ACTIVE = 90002278, HIDDEN = 90002279, PROV = 90002280, LAZY = 90002281;
48
- const ALL_IDS = [ACTIVE, HIDDEN, PROV, LAZY];
49
+ // LEGACY is the ADR 0213 shape: profile_state='active' from before the platform
50
+ // terms existed, so terms_accepted_at is NULL. Consent is TWO columns now, and
51
+ // this is the row that makes that second column load-bearing here — delete the
52
+ // acceptance term from accountActiveSql and this fixture is the one that flips.
53
+ const ACTIVE = 90002278, HIDDEN = 90002279, PROV = 90002280, LAZY = 90002281, LEGACY = 90002283;
54
+ const ALL_IDS = [ACTIVE, HIDDEN, PROV, LAZY, LEGACY];
49
55
  const PUB_CLIENT = 't1002278-pub', PRIV_CLIENT = 't1002278-priv', LOST_CLIENT = 't1002278-lost';
50
56
  const ALL_CLIENTS = [PUB_CLIENT, PRIV_CLIENT, LOST_CLIENT];
51
57
  const PUB_ORIGIN = 'https://t1002278-pub.example', PRIV_ORIGIN = 'https://t1002278-priv.example';
@@ -63,19 +69,32 @@ let pass = 0;
63
69
  const ok = (cond, msg) => { assert.ok(cond, msg); pass++; console.log(` ok - ${msg}`); };
64
70
 
65
71
  try {
66
- // The module's actual migrations (idempotent).
72
+ // The module's actual migrations (idempotent). 012 and 019 are here because
73
+ // the predicates under test READ their columns: accountVisibleSql compares
74
+ // account_visibility, scoutingListableSql COALESCEs recruiter_discoverable
75
+ // (012), and the consent floor tests terms_accepted_at (019). Without them a
76
+ // FRESH database answers 42703 "column does not exist" — an infrastructure
77
+ // error dressed up as a boundary failure — instead of a clean assertion.
67
78
  await q(migrationSql('platform_identity_001_tables.sql'));
68
79
  await q(migrationSql('platform_identity_004_projects.sql'));
69
80
  await q(migrationSql('platform_identity_005_builder_activity.sql'));
70
81
  await q(migrationSql('platform_identity_007_profile_consent.sql'));
82
+ await q(migrationSql('platform_identity_012_account_privacy.sql'));
83
+ await q(migrationSql('platform_identity_019_terms_acceptance.sql'));
71
84
  await cleanup();
72
85
 
86
+ // CONSENT IS TWO COLUMNS (ADR 0213, task 1002913): profile_state='active' AND
87
+ // an acceptance of the platform terms. A fixture that seeds only the first is
88
+ // a row from BEFORE the terms shipped, and is deliberately not consented — so
89
+ // every consented fixture here stamps both, exactly as upsertAccount's
90
+ // activate branch does, and LEGACY stamps only the first on purpose.
73
91
  await q(
74
- `INSERT INTO platform_identity_accounts (github_id, github_login, display_name, profile_state, hide_stats)
75
- VALUES ($1, 'active-b', 'Active', 'active', false),
76
- ($2, 'hidden-b', 'Hidden', 'active', true),
77
- ($3, 'prov-b', 'Prov', 'provisional', false)`,
78
- [ACTIVE, HIDDEN, PROV],
92
+ `INSERT INTO platform_identity_accounts (github_id, github_login, display_name, profile_state, hide_stats, terms_accepted_at)
93
+ VALUES ($1, 'active-b', 'Active', 'active', false, now()),
94
+ ($2, 'hidden-b', 'Hidden', 'active', true, now()),
95
+ ($3, 'prov-b', 'Prov', 'provisional', false, NULL),
96
+ ($4, 'legacy-b', 'Legacy', 'active', false, NULL)`,
97
+ [ACTIVE, HIDDEN, PROV, LEGACY],
79
98
  );
80
99
  // Three reporting projects: one publicly catalogued (featured+active), one
81
100
  // catalogued but NOT featured (→ never named), one with no catalog row at all.
@@ -96,15 +115,21 @@ try {
96
115
  ($2, $4, 50, 2, 1, 'archon', '{artist}', '2026-02-01T00:00:00Z'),
97
116
  ($3, $4, 25, 1, 0, NULL, '{}', '2026-03-01T00:00:00Z'),
98
117
  ($1, $5, 60, 4, 2, 'metic', '{engineer}', '2026-01-01T00:00:00Z'),
99
- ($1, $6, 10, 1, 0, 'xenos', '{engineer}', '2026-01-01T00:00:00Z')`,
100
- [...ALL_CLIENTS, ACTIVE, HIDDEN, PROV],
118
+ ($1, $6, 10, 1, 0, 'xenos', '{engineer}', '2026-01-01T00:00:00Z'),
119
+ ($1, $7, 90, 3, 1, 'metic', '{engineer}', '2026-01-01T00:00:00Z')`,
120
+ [...ALL_CLIENTS, ACTIVE, HIDDEN, PROV, LEGACY],
101
121
  );
102
122
 
103
123
  // 1 — the scouting/people predicate (D2/D3).
104
124
  const listed = (await pi.listScoutingRollup({ limit: 500, pool })).map((r) => Number(r.github_id));
105
- ok(listed.includes(ACTIVE), 'active + visible builder is listed');
125
+ ok(listed.includes(ACTIVE), 'active + accepted + visible builder is listed');
106
126
  ok(!listed.includes(HIDDEN), 'hide_stats builder drops out of the scouting rollup');
107
127
  ok(!listed.includes(PROV), 'provisional (no hub-session consent) builder drops out');
128
+ // The ADR 0213 half, against real SQL. LEGACY differs from ACTIVE in ONE
129
+ // column, so this is the acceptance term and nothing else: an account
130
+ // activated before the platform terms existed has no consent to aggregate on
131
+ // and does not publish until it re-accepts at its next hub sign-in.
132
+ ok(!listed.includes(LEGACY), 'active but never-accepted (pre-terms) builder drops out too — consent is two columns');
108
133
 
109
134
  // 2 — getRollupForBuilder semantics.
110
135
  const r = await pi.getRollupForBuilder(ACTIVE, { pool });
@@ -124,6 +149,7 @@ try {
124
149
  // 3 — the port reads.
125
150
  ok(await pi.getPublicProfileExtras(HIDDEN, { pool }) === null, 'public read → null for a hidden builder');
126
151
  ok(await pi.getPublicProfileExtras(PROV, { pool }) === null, 'public read → null for a provisional builder');
152
+ ok(await pi.getPublicProfileExtras(LEGACY, { pool }) === null, 'public read → null for an active builder who never accepted the terms (ADR 0213)');
127
153
  ok(await pi.getPublicProfileExtras(99999999999, { pool }) === null, 'public read → null with no account');
128
154
  ok(await pi.getPublicProfileExtras('system:bfg', { pool }) === null, 'non-numeric principal → null, no bigint cast error');
129
155
  const pub = await pi.getPublicProfileExtras(ACTIVE, { pool });
@@ -138,9 +164,15 @@ try {
138
164
  await pi.upsertAccount({ githubId: LAZY, githubLogin: 'lazy-b', activate: true }, { pool });
139
165
  acct = await pi.getAccount(LAZY, { pool });
140
166
  ok(acct.profile_state === 'active', 'an authorize-path upsert (activate) flips it active');
167
+ // BOTH consent columns come from that ONE branch (ADR 0213), which is what
168
+ // makes "active without an acceptance" a legacy shape rather than a state the
169
+ // code can still produce — and what makes the LEGACY fixture above honest.
170
+ ok(acct.terms_accepted_at !== null, 'the same activate branch stamps the T&C acceptance');
171
+ const firstAccept = acct.terms_accepted_at;
141
172
  await pi.upsertAccount({ githubId: LAZY, githubLogin: 'lazy-b' }, { pool });
142
173
  acct = await pi.getAccount(LAZY, { pool });
143
174
  ok(acct.profile_state === 'active', 'a later plain upsert never demotes an active account');
175
+ ok(String(acct.terms_accepted_at) === String(firstAccept), 'and never rewrites the acceptance — the FIRST one stands (idempotent by COALESCE)');
144
176
 
145
177
  // 4b — the live hide toggle (R03, task 1002279): flipping hide_stats takes
146
178
  // effect IMMEDIATELY on every public read — the profile port and the
@@ -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
  }
@@ -439,7 +521,8 @@ await t('and the whole matrix lands exactly where the specs put it', async () =>
439
521
  assert.deepEqual(shown, ['octo', 'optin'],
440
522
  'in: reach-unset-and-public (octo) + explicitly-opted-in (optin). '
441
523
  + 'out: optout (opted out), scout (private, and reach never buys addressability), '
442
- + `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)}`);
443
526
  });
444
527
 
445
528
  await t('opting back IN restores them — the toggle is a toggle, not a tombstone', async () => {
@@ -472,6 +555,55 @@ await t('MUTATION CHECK: drop the reach half and the opted-out builder comes str
472
555
  'WITHOUT it: optout is readmitted — so the passing assertions above are load-bearing, not decorative');
473
556
  });
474
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
+
475
607
  await t('/people is the SAME endpoint, so "absent from /people" is true by construction', async () => {
476
608
  const page = fs.readFileSync(path.join(repo, 'modules/hall-ui/public/people.js'), 'utf8');
477
609
  assert.match(page, /fetch\('\/api\/gds\/scouting/,
@@ -503,7 +635,7 @@ await t('opting out of recruiting grants and denies NO permission', async () =>
503
635
 
504
636
  await t('being scoutable buys nothing either — the response is the same descriptive whitelist', async () => {
505
637
  const res = await getScouting(OWNER);
506
- 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']) {
507
639
  assert.ok(!res.text.includes(leak), `${leak} must never serialize into the directory body`);
508
640
  }
509
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