@bongos/core 1.19.426 → 1.19.428

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 (30) hide show
  1. package/.bongos-core.json +65 -30
  2. package/docs/adr/0212-connections-on-the-profile-surface-and-addressing.md +61 -0
  3. package/docs/adr/0213-platform-terms-acceptance-is-the-hubs-own-witness.md +71 -0
  4. package/docs/adr/README.md +2 -0
  5. package/docs/copy-inventory.md +34 -18
  6. package/docs/copy-registry.json +179 -25
  7. package/docs/file-map.md +2 -1
  8. package/docs/module-api-changelog.md +4 -0
  9. package/modules/hall-ui/public/profile-connections.js +304 -0
  10. package/modules/hall-ui/public/profile.html +16 -1
  11. package/modules/hall-ui/public/profile.js +5 -0
  12. package/modules/hall-ui/public/style.css +38 -0
  13. package/modules/platform-identity/account-visibility.js +31 -12
  14. package/modules/platform-identity/connections.js +4 -3
  15. package/modules/platform-identity/migrations/platform_identity_019_terms_acceptance.sql +47 -0
  16. package/modules/platform-identity/platform-identity.js +12 -10
  17. package/modules/platform-identity/routes/connections.js +4 -3
  18. package/modules/platform-identity/terms-acceptance.js +97 -0
  19. package/package-lock.json +2 -2
  20. package/package.json +1 -1
  21. package/public-landing/terms.html +8 -0
  22. package/scripts/hall-preview/server.js +3 -0
  23. package/src/module-api.js +1 -1
  24. package/tests/connections_api.mjs +13 -8
  25. package/tests/connections_ownership.mjs +6 -1
  26. package/tests/effective_visibility_predicate.mjs +89 -14
  27. package/tests/profile_connections_ui.mjs +889 -0
  28. package/tests/profile_privacy_owner_view.mjs +31 -4
  29. package/tests/profile_route.mjs +15 -8
  30. package/tests/terms_acceptance.mjs +573 -0
@@ -0,0 +1,97 @@
1
+ // modules/platform-identity/terms-acceptance.js — THE platform T&C acceptance
2
+ // record: which text is current, and the SQL that stamps a builder's acceptance
3
+ // of it (ADR 0213, task 1002913).
4
+ //
5
+ // WHAT THIS ANSWERS: "did the hub itself witness this human accept the platform
6
+ // terms, and which text did they accept?" — the artifact ADR 0141 §4 deferred
7
+ // consent for cross-project aggregation to, and ADR 0171 D3 reserved a place for
8
+ // ("the one path where a human is demonstrably at the hub, and where T&C
9
+ // acceptance can later be captured, task 1002913").
10
+ //
11
+ // WHY ITS OWN FILE. platform-identity.js sits at the 1500-line fitness ratchet
12
+ // with `oversized_file_count: 0` as the baseline — there is no headroom, and
13
+ // four sessions have already set the precedent that new logic lands beside it
14
+ // (account-visibility.js, application-echo.js, routes/connections.js,
15
+ // src/bongos/routes/my-sessions.js). This file is that, for consent.
16
+ //
17
+ // THE WRITE SIDE ONLY — read the record through account-visibility.js. That file
18
+ // is the SOLE owner of every effective-visibility answer, and acceptance is now
19
+ // part of the consent axis it publishes (accountActiveSql / isAccountActive).
20
+ // Nothing here compares terms_accepted_at to anything; these are the statements
21
+ // that SET it. tests/effective_visibility_predicate.mjs enforces that split —
22
+ // its SPELLINGS list fails any file but the owner that writes the acceptance
23
+ // predicate, and deliberately does not match a write.
24
+ //
25
+ // WHY THE CALLER CANNOT FORGE ONE. These fragments have exactly one call site:
26
+ // upsertAccount's `activate` branch, whose only caller is /sso/authorize
27
+ // (routes/sso.js). There `activate: true` is a SOURCE LITERAL and every other
28
+ // field comes from the hub's own resolved session — the ADR 0205 D3 property
29
+ // established for recordFederatedSignin by task 1003340. No request-body field
30
+ // reaches these statements, which is the whole point: an acceptance record a
31
+ // caller could assert would be evidence of nothing.
32
+ //
33
+ // IDEMPOTENT BY CONSTRUCTION: the conflict branch is COALESCE(existing, new), so
34
+ // the FIRST acceptance stands and a second sign-in never rewrites or duplicates
35
+ // it. The two columns are only ever written together, so they cannot diverge.
36
+ //
37
+ // A LATER REVISION IS A KNOWN GAP, NOT A SILENT ONE. Because the version is
38
+ // recorded and never overwritten, publishing a new TERMS_VERSION leaves every
39
+ // existing row naming the text it actually agreed to — so the platform can see
40
+ // who has not accepted the new one. Re-acceptance on revision is deliberately
41
+ // NOT built here (ADR 0213 D4): it is a product flow, not a storage shape.
42
+ 'use strict';
43
+
44
+ // The version of the platform terms currently served at /terms. It is stamped
45
+ // onto every acceptance, so it must match the version the page states — the
46
+ // pairing is asserted by tests/terms_acceptance.mjs, which reads both.
47
+ //
48
+ // Date-stamped rather than numbered: the page says "Version <this>", and a
49
+ // reader comparing page to record should not have to translate.
50
+ const TERMS_VERSION = '2026-08-29';
51
+
52
+ // A Postgres placeholder, e.g. '$5'. Interpolated into SQL, so it is validated
53
+ // rather than trusted — the account-visibility.js alias-guard pattern.
54
+ function assertPlaceholder(fn, value) {
55
+ if (!/^\$[1-9][0-9]*$/.test(String(value))) {
56
+ throw new Error(`${fn}: expected a Postgres placeholder like '$5', got ${JSON.stringify(value)}`);
57
+ }
58
+ }
59
+
60
+ // The acceptance columns, for an INSERT column list. Leading comma: it appends
61
+ // to an existing list rather than standing alone.
62
+ function acceptanceInsertColumns() {
63
+ return ', terms_accepted_at, terms_version';
64
+ }
65
+
66
+ // The matching VALUES fragment. A row created by a NON-activating caller
67
+ // (ensureAccount's check-in / device-poll path) gets NULL — fail-closed: no hub
68
+ // session witnessed that human, so there is no acceptance to record.
69
+ function acceptanceInsertValues(activateParam, versionParam) {
70
+ assertPlaceholder('acceptanceInsertValues', activateParam);
71
+ assertPlaceholder('acceptanceInsertValues', versionParam);
72
+ return `, CASE WHEN ${activateParam}::boolean THEN now() ELSE NULL END`
73
+ + `, CASE WHEN ${activateParam}::boolean THEN ${versionParam}::text ELSE NULL END`;
74
+ }
75
+
76
+ // The ON CONFLICT DO UPDATE SET fragment — the idempotent half. COALESCE keeps
77
+ // the FIRST acceptance, so signing in again re-reads the terms but does not
78
+ // restamp them; a non-activating upsert leaves both columns exactly as they are,
79
+ // mirroring the one-way rule profile_state already follows.
80
+ //
81
+ // Trailing comma: it sits between two other assignments in the SET list.
82
+ function acceptanceConflictSet(activateParam, versionParam) {
83
+ assertPlaceholder('acceptanceConflictSet', activateParam);
84
+ assertPlaceholder('acceptanceConflictSet', versionParam);
85
+ const self = 'platform_identity_accounts';
86
+ return `terms_accepted_at = CASE WHEN ${activateParam}::boolean`
87
+ + ` THEN COALESCE(${self}.terms_accepted_at, now()) ELSE ${self}.terms_accepted_at END,`
88
+ + ` terms_version = CASE WHEN ${activateParam}::boolean`
89
+ + ` THEN COALESCE(${self}.terms_version, ${versionParam}::text) ELSE ${self}.terms_version END,`;
90
+ }
91
+
92
+ module.exports = {
93
+ TERMS_VERSION,
94
+ acceptanceInsertColumns,
95
+ acceptanceInsertValues,
96
+ acceptanceConflictSet,
97
+ };
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.426",
3
+ "version": "1.19.428",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.426",
9
+ "version": "1.19.428",
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.426",
3
+ "version": "1.19.428",
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",
@@ -61,6 +61,14 @@
61
61
  <p>A project stood up from the hub runs on the platform's servers at your request. Any cost is shown before you confirm. Tearing it down stops its server; it keeps its database and backups so it can come back, and its web address stays with it while it can.</p>
62
62
  <!--apex-only:end-->
63
63
 
64
+ <!--apex-only:start-->
65
+ <h2>Your work across projects</h2>
66
+ <p>Each {{productName}} project you build in reports a summary of your work there to the platform: the {{currency}} you earned, the works you shipped, your karma, the rank that project gave you, the disciplines you worked in, and when you were last active. The platform adds these up across every project into one cross-project record. Projects report this themselves — the platform does not read their databases.</p>
67
+ <p><b>These numbers describe; they do not govern.</b> Your cross-project record is descriptive analytics and never authority. What you may do inside any project is decided by that project alone, from its own records, at the moment you ask. A large record grants nothing and a hidden one takes nothing away; no project is required to read it, and none may treat it as permission. A rank shown here is that project&rsquo;s own standing for you, as that project reported it &mdash; the platform holds no rank of its own.</p>
68
+ <p><b>What is public.</b> Once you have signed in here and accepted these terms, your profile and that record are public by default: at your handle&rsquo;s page, in the people directory, to project owners looking for people to work with, and to search engines. An account that has never signed in here stays provisional and is published nowhere.</p>
69
+ <p><b>What you control.</b> From your settings you can hide your statistics, drop out of the recruiting directory, or make your account private. Each takes effect immediately, everywhere, including on a page someone already has open. Hiding removes the platform&rsquo;s combined view; it does not retract the per-project numbers each project publishes itself, so a determined reader could still add them up the hard way.</p>
70
+ <p><b>Your acceptance is recorded.</b> The platform stores the date you accepted these terms, and which version, on your account. It is written only by signing in here, and only once &mdash; signing in again does not change the original date. This is version <b>2026-08-29</b>.</p>
71
+ <!--apex-only:end-->
64
72
  <h2>Fair use</h2>
65
73
  <p>Don't use {{productName}} to break the law, to attack this site or other people's projects, to mine, spam or otherwise abuse hosted resources, or to harass other builders. The owner may deactivate a builder or take a project offline for breaking these, and may set further house rules inside the hall.</p>
66
74
 
@@ -116,6 +116,9 @@ const FIXTURE_FALLBACKS = [
116
116
  // craft and the page window are query string, which fixture matching ignores.
117
117
  [/^builders\/\d+\/activity$/, (q) => (q.section === 'goals' ? 'builders__activity-goals' : 'builders__activity-tasks')],
118
118
  [/^inbox\/by-builder\/\d+$/, 'inbox__by-builder'],
119
+ // the profile's connections section (task 1002296) — one degree fixture serves
120
+ // any handle, since the handle is the address and the label is the payload.
121
+ [/^connections\/degree\/[^/]+$/, 'connections__degree'],
119
122
  ];
120
123
 
121
124
  function loadFixture(name) {
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.426'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
36
+ const CORE_VERSION = '1.19.428'; // 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');
@@ -126,12 +126,15 @@ function call(method, path, body) {
126
126
  });
127
127
  }
128
128
 
129
- // The first query every route makes is the caller's own account (the
130
- // profile_state='active' floor, ADR 0202 D4 §1).
131
- const meActive = () => result([{ github_id: String(ME.github_id), profile_state: 'active', account_visibility: 'public', handle: 'octo' }]);
129
+ // The first query every route makes is the caller's own account (the CONSENT
130
+ // floor, ADR 0202 D4 §1). Since ADR 0213 that floor is two columns: the hub
131
+ // witnessed the human AND that human accepted the platform terms, both written
132
+ // by the same upsertAccount branch — so a consented fixture carries both.
133
+ const ACCEPTED_AT = '2026-08-29T00:00:00.000Z';
134
+ const meActive = () => result([{ github_id: String(ME.github_id), profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'public', handle: 'octo' }]);
132
135
  const account = (over = {}) => ({
133
136
  github_id: String(ADA), handle: 'ada', display_name: 'Ada', avatar_url: null,
134
- profile_state: 'active', account_visibility: 'public', ...over,
137
+ profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'public', ...over,
135
138
  });
136
139
 
137
140
  // ===========================================================================
@@ -299,8 +302,9 @@ test('list: my connections, the count IS the array length, and paging is real',
299
302
  assert.match(calls[1].text, /LIMIT \$2 OFFSET \$3/,
300
303
  'a hard cap with no OFFSET would make the 501st connection unreachable while count still read 500');
301
304
  assert.deepEqual(calls[1].params, [ME.github_id, 2, 4]);
302
- assert.match(calls[1].text, /\(pa\.profile_state = 'active'\)/,
303
- 'own scope floors on consent alone — a private account you connected with appears HERE (D4 §2)');
305
+ assert.match(calls[1].text, /\(pa\.profile_state = 'active' AND pa\.terms_accepted_at IS NOT NULL\)/,
306
+ 'own scope floors on consent alone — a private account you connected with appears HERE (D4 §2). '
307
+ + 'Consent is BOTH columns since ADR 0213: the hub witnessed them, and they accepted the platform terms.');
304
308
  assert.doesNotMatch(calls[1].text, /account_visibility/,
305
309
  "…and applying the PUBLIC predicate here would erase your own connections from your own list");
306
310
  });
@@ -478,8 +482,9 @@ test('degree: 3rd degree runs exactly ONE more query, capped at FANOUT_1 then FA
478
482
  assertBounded(far.text, 'far');
479
483
  assert.deepEqual(far.params, [ME.github_id, String(ADA), conn.FANOUT_1, conn.FANOUT_2],
480
484
  'the caps are the ADR 0202 D3 constants, bound as parameters');
481
- assert.match(far.text, /\(pa\.profile_state = 'active' AND pa\.account_visibility = 'public'\)/,
482
- 'a hop BEYOND the viewer\'s own connections must clear the full public predicate');
485
+ assert.match(far.text, /\(\(pa\.profile_state = 'active' AND pa\.terms_accepted_at IS NOT NULL\) AND pa\.account_visibility = 'public'\)/,
486
+ 'a hop BEYOND the viewer\'s own connections must clear the full public predicate — the ADR 0213 '
487
+ + 'consent pair AND the existence axis, composed rather than restated');
483
488
  });
484
489
 
485
490
  test('degree: past DEGREE_MAX the answer is null — "out of network", not a deeper search', async () => {
@@ -106,9 +106,14 @@ function predicateFor(rawWhere, params) {
106
106
  return (r) => tests.every((fn) => fn(r));
107
107
  }
108
108
 
109
+ // Consent is two columns since ADR 0213 (task 1002913): the hub witnessed this
110
+ // human AND that human accepted the platform terms. Both are written by the same
111
+ // upsertAccount branch, so a genuinely consented account always carries both —
112
+ // and isAccountActive, which every route here floors on, checks both.
109
113
  const ACCOUNT = {
110
114
  id: 1, github_id: String(ME), github_login: 'octo', display_name: 'Octo', avatar_url: null,
111
- profile_state: 'active', hide_stats: false, account_visibility: 'public',
115
+ profile_state: 'active', terms_accepted_at: '2026-08-29T00:00:00.000Z',
116
+ hide_stats: false, account_visibility: 'public',
112
117
  recruiter_discoverable: null, handle: 'octo', previous_handle: null,
113
118
  };
114
119
 
@@ -256,6 +256,14 @@ const SPELLINGS = [
256
256
  [/\bCOALESCE\s*\(\s*(?:[A-Za-z_$][\w$]*|\$\{[^}]*\})?\s*\.?\s*recruiter_discoverable/gi, 'the recruiter-reach COALESCE'],
257
257
  [/\bNOT\s+(?:[A-Za-z_][\w]*\.)?hide_stats\b/g, 'a SQL NOT hide_stats'],
258
258
  [/\bhide_stats\s*(?:=|==|===)\s*(?:true|false)\b/g, 'hide_stats compared to a boolean'],
259
+ // The ADR 0213 acceptance half of the consent axis (task 1002913), in the two
260
+ // syntaxes a drift copy would actually be written in: the SQL NULL test the
261
+ // owner emits (bare, aliased, or through a `${q}` prefix) and the JS twin's
262
+ // null comparison. Written against the form the source REALLY carries, because
263
+ // a rule matching only a spelling nobody uses is the hole task 1002290 found in
264
+ // the reach COALESCE — the whole reason that entry now pins four forms.
265
+ [/\bterms_accepted_at\s+IS\s+(?:NOT\s+)?NULL\b/gi, 'a terms-acceptance NULL test'],
266
+ [/\bterms_accepted_at\s*(?:=|==|===|!=|!==|<>)\s*null\b/gi, 'terms_accepted_at compared to null'],
259
267
  ];
260
268
 
261
269
  function spellingsIn(code) {
@@ -369,6 +377,16 @@ t('SPELLINGS catch a hand-written variant in either language, and leave writes a
369
377
  assert.ok(caught('COALESCE(${alias}.recruiter_discoverable, TRUE)'), 'a template alias AND a dot — the drift copy most likely to be written');
370
378
  assert.ok(!caught('COALESCE(hide_stats, false)'), 'and it is the REACH column it watches, not any COALESCE');
371
379
  assert.ok(caught('AND NOT pa.hide_stats'), 'the hide half of a rollup predicate');
380
+ // The ADR 0213 consent half, in every form that ships or could drift. The bare
381
+ // and `${q}` forms are what the owner emits today; the aliased form is what a
382
+ // drift copy in a joined query would most likely write.
383
+ assert.ok(caught('AND terms_accepted_at IS NOT NULL'), 'the acceptance NULL test, bare');
384
+ assert.ok(caught('AND pa.terms_accepted_at IS NOT NULL'), 'the acceptance NULL test, aliased');
385
+ assert.ok(caught('`AND ${q}terms_accepted_at IS NOT NULL`'), 'the acceptance NULL test, as the builder writes it');
386
+ assert.ok(caught('WHERE terms_accepted_at IS NULL'), 'and the inverted test — the same decision, negated');
387
+ assert.ok(caught('if (a.terms_accepted_at != null) return a;'), 'the JS twin of the acceptance test');
388
+ assert.ok(!caught('COALESCE(platform_identity_accounts.terms_accepted_at, now())'), 'a write is not a predicate');
389
+ assert.ok(!caught('SELECT profile_state, terms_accepted_at, account_visibility'), 'and naming the column in a projection decides nothing');
372
390
  // A WRITE names the column but compares it to nothing — it decides no visibility.
373
391
  assert.ok(!caught("profile_state = CASE WHEN $5::boolean THEN 'active' ELSE x END"), 'an upsert is not a predicate');
374
392
  assert.ok(!caught("account_visibility: (acct && acct.account_visibility) || 'public'"), 'a projection default is not a predicate');
@@ -439,13 +457,24 @@ t('the alias is the one interpolated thing, and every builder refuses a non-iden
439
457
  t('the JS ports answer exactly what the SQL asks, row for row', () => {
440
458
  // The table IS the D2 matrix. Each row is checked against BOTH forms, which is
441
459
  // the only way "a route and a query cannot drift" is more than a slogan.
460
+ // ADR 0213 (task 1002913) made consent TWO columns: the hub witnessed the human
461
+ // (profile_state) AND that human accepted the platform terms (terms_accepted_at).
462
+ // Both are stamped by the same upsertAccount branch, so `active` + NULL only
463
+ // occurs for a row predating the terms — which must NOT publish.
464
+ const ACCEPTED = new Date('2026-08-29T00:00:00Z');
442
465
  const rows = [
443
- { row: { profile_state: 'active', account_visibility: 'public', hide_stats: false }, visible: true, rollup: true },
444
- { row: { profile_state: 'active', account_visibility: 'public', hide_stats: true }, visible: true, rollup: false },
445
- { row: { profile_state: 'active', account_visibility: 'private', hide_stats: false }, visible: false, rollup: false },
446
- { row: { profile_state: 'active', account_visibility: 'private', hide_stats: true }, visible: false, rollup: false },
447
- { row: { profile_state: 'provisional', account_visibility: 'public', hide_stats: false }, visible: false, rollup: false },
448
- { row: { profile_state: 'provisional', account_visibility: 'private', hide_stats: false }, visible: false, rollup: false },
466
+ { row: { profile_state: 'active', terms_accepted_at: ACCEPTED, account_visibility: 'public', hide_stats: false }, visible: true, rollup: true },
467
+ { row: { profile_state: 'active', terms_accepted_at: ACCEPTED, account_visibility: 'public', hide_stats: true }, visible: true, rollup: false },
468
+ { row: { profile_state: 'active', terms_accepted_at: ACCEPTED, account_visibility: 'private', hide_stats: false }, visible: false, rollup: false },
469
+ { row: { profile_state: 'active', terms_accepted_at: ACCEPTED, account_visibility: 'private', hide_stats: true }, visible: false, rollup: false },
470
+ { row: { profile_state: 'provisional', terms_accepted_at: null, account_visibility: 'public', hide_stats: false }, visible: false, rollup: false },
471
+ { row: { profile_state: 'provisional', terms_accepted_at: null, account_visibility: 'private', hide_stats: false }, visible: false, rollup: false },
472
+ // The ADR 0213 axis: witnessed but never accepted (a legacy row, or a state
473
+ // no post-0213 write can produce) is NOT consented, so it publishes nothing.
474
+ { row: { profile_state: 'active', terms_accepted_at: null, account_visibility: 'public', hide_stats: false }, visible: false, rollup: false },
475
+ { row: { profile_state: 'active', terms_accepted_at: null, account_visibility: 'private', hide_stats: false }, visible: false, rollup: false },
476
+ // …and acceptance alone never substitutes for the hub's own witness.
477
+ { row: { profile_state: 'provisional', terms_accepted_at: ACCEPTED, account_visibility: 'public', hide_stats: false }, visible: false, rollup: false },
449
478
  ];
450
479
  for (const { row, visible, rollup } of rows) {
451
480
  const id = JSON.stringify(row);
@@ -454,7 +483,9 @@ t('the JS ports answer exactly what the SQL asks, row for row', () => {
454
483
  // The SQL twin, evaluated the only honest way a DB-free test can: the
455
484
  // fragment's own column comparisons, resolved against this row.
456
485
  const sqlSaysVisible = /profile_state = 'active'/.test(accountVisibleSql())
486
+ && /terms_accepted_at IS NOT NULL/.test(accountVisibleSql())
457
487
  && row.profile_state === 'active'
488
+ && row.terms_accepted_at != null
458
489
  && row.account_visibility === 'public';
459
490
  assert.equal(sqlSaysVisible, visible, `the SQL fragment agrees for ${id}`);
460
491
  }
@@ -646,41 +677,59 @@ t('every declared non-public read still exists (a stale declaration is a blind s
646
677
 
647
678
  console.log('\nbehaviour (an executed privacy boundary):');
648
679
 
680
+ // Every consented fixture carries an acceptance stamp, because after ADR 0213
681
+ // (task 1002913) the consent axis is profile_state AND terms_accepted_at — the
682
+ // same upsertAccount branch writes both, so a real consented row always has one.
683
+ const ACCEPTED_AT = '2026-08-29T00:00:00.000Z';
684
+
649
685
  const ACCOUNTS = [
650
686
  { github_id: '4242', github_login: 'octo', display_name: 'Octo', avatar_url: null,
651
687
  handle: 'octo', previous_handle: null, bio: null, links: null, created_at: 'T',
652
- profile_state: 'active', account_visibility: 'public', hide_stats: false, recruiter_discoverable: null },
688
+ profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'public', hide_stats: false, recruiter_discoverable: null },
653
689
  // The private account this whole task exists for.
654
690
  { github_id: '5150', github_login: 'ghost', display_name: 'Ghost', avatar_url: null,
655
691
  handle: 'ghost', previous_handle: null, bio: null, links: null, created_at: 'T',
656
- profile_state: 'active', account_visibility: 'private', hide_stats: false, recruiter_discoverable: null },
692
+ profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'private', hide_stats: false, recruiter_discoverable: null },
657
693
  // Private AND recruiter-discoverable — the spec's D2 "private · discoverable"
658
694
  // row. Reach must not buy addressability: this one still 404s.
659
695
  { github_id: '5151', github_login: 'scout', display_name: 'Scout', avatar_url: null,
660
696
  handle: 'scout', previous_handle: null, bio: null, links: null, created_at: 'T',
661
- profile_state: 'active', account_visibility: 'private', hide_stats: false, recruiter_discoverable: true },
697
+ profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'private', hide_stats: false, recruiter_discoverable: true },
662
698
  // Public but hidden — the axes are independent: the PAGE resolves, the numbers do not.
663
699
  { github_id: '6060', github_login: 'shy', display_name: 'Shy', avatar_url: null,
664
700
  handle: 'shy', previous_handle: null, bio: null, links: null, created_at: 'T',
665
- profile_state: 'active', account_visibility: 'public', hide_stats: true, recruiter_discoverable: null },
701
+ profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'public', hide_stats: true, recruiter_discoverable: null },
666
702
  { github_id: '7070', github_login: 'newbie', display_name: 'Newbie', avatar_url: null,
667
703
  handle: 'newbie', previous_handle: null, bio: null, links: null, created_at: 'T',
668
- profile_state: 'provisional', account_visibility: 'public', hide_stats: false, recruiter_discoverable: null },
704
+ profile_state: 'provisional', terms_accepted_at: null, account_visibility: 'public', hide_stats: false, recruiter_discoverable: null },
705
+ // ADR 0213 (task 1002913): the hub witnessed this human, but no acceptance of
706
+ // the platform terms is on record — a row from before the terms existed. Public
707
+ // and unhidden on every OTHER axis, so if consent were still one column it would
708
+ // resolve. It must 404: the T&C is what "public by default" was always premised
709
+ // on, and this account never saw one. It re-publishes on its next hub sign-in.
710
+ { github_id: '7171', github_login: 'legacy', display_name: 'Legacy', avatar_url: null,
711
+ handle: 'legacy', previous_handle: null, bio: null, links: null, created_at: 'T',
712
+ profile_state: 'active', terms_accepted_at: null, account_visibility: 'public', hide_stats: false, recruiter_discoverable: null },
669
713
  // A private account inside the P01 rename window. Its OLD handle must 404 too —
670
714
  // a 301 to the current handle would confirm the account exists.
671
715
  { github_id: '8080', github_login: 'moved', display_name: 'Moved', avatar_url: null,
672
716
  handle: 'moved-now', previous_handle: 'moved-was', bio: null, links: null, created_at: 'T',
673
- profile_state: 'active', account_visibility: 'private', hide_stats: false, recruiter_discoverable: null },
717
+ profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'private', hide_stats: false, recruiter_discoverable: null },
674
718
  // ...and a PUBLIC one, so the control proves the 301 window still works.
675
719
  { github_id: '9090', github_login: 'shifted', display_name: 'Shifted', avatar_url: null,
676
720
  handle: 'shifted-now', previous_handle: 'shifted-was', bio: null, links: null, created_at: 'T',
677
- profile_state: 'active', account_visibility: 'public', hide_stats: false, recruiter_discoverable: null },
721
+ profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'public', hide_stats: false, recruiter_discoverable: null },
678
722
  ];
679
723
 
680
724
  // The columns this interpreter knows how to filter on. Anything else in a WHERE
681
725
  // clause throws — the guard against a predicate quietly degrading to no filter.
682
726
  const KNOWN_COLUMNS = new Set(['profile_state', 'account_visibility', 'hide_stats', 'github_id', 'handle', 'previous_handle']);
683
727
 
728
+ // The columns this interpreter knows how to answer an IS [NOT] NULL test on.
729
+ // Separate from KNOWN_COLUMNS because a NULL test is a different syntax the
730
+ // literal-comparison sweep cannot see (ADR 0213 / task 1002913).
731
+ const KNOWN_NULL_TESTS = new Set(['terms_accepted_at']);
732
+
684
733
  /**
685
734
  * Build a row test from a statement's own WHERE clause. Literal comparisons
686
735
  * (profile_state = 'active') matter as much as placeholders here, because the
@@ -691,8 +740,22 @@ const KNOWN_COLUMNS = new Set(['profile_state', 'account_visibility', 'hide_stat
691
740
  function accountFilter(where, params) {
692
741
  const tests = [];
693
742
  const consumed = new Set();
743
+ // NULL-test terms this interpreter actually turned into a filter, as opposed
744
+ // to column names it merely recognises. The final sweep compares the two.
745
+ const modelled = new Set();
694
746
 
695
747
  if (/\bprofile_state = 'active'/.test(where)) tests.push((r) => r.profile_state === 'active');
748
+ // The ADR 0213 acceptance half of the consent axis. It is a NULL test, not a
749
+ // literal comparison, so neither refusal sweep below would have caught it —
750
+ // an unmodelled IS NOT NULL degrades to no filter exactly like an unread $N.
751
+ // Hence the third sweep at the end of this function, and hence `modelled`:
752
+ // the sweep must prove a filter was APPLIED, not merely that the column is a
753
+ // name it recognises. (Checking recognition alone left this interpreter
754
+ // scoring a deleted filter as fine — found by mutation while writing it.)
755
+ if (/\bterms_accepted_at IS NOT NULL/.test(where)) {
756
+ modelled.add('terms_accepted_at');
757
+ tests.push((r) => r.terms_accepted_at != null);
758
+ }
696
759
  if (/\baccount_visibility = 'public'/.test(where)) tests.push((r) => r.account_visibility === 'public');
697
760
  if (/\bNOT (?:[a-z_]+\.)?hide_stats\b/.test(where)) tests.push((r) => !r.hide_stats);
698
761
  // The recruiting-reach COALESCE (task 1002290). Modelled through the shared JS
@@ -721,6 +784,18 @@ function accountFilter(where, params) {
721
784
  throw new Error(`fake pool: unrecognised literal comparison on ${m[1]} in "${where}"`);
722
785
  }
723
786
  }
787
+ // A NULL test is a filter too. Without this sweep the ADR 0213 acceptance term
788
+ // could be dropped from the predicate and this interpreter would score it as
789
+ // absent-and-fine, which is the failure mode the other two sweeps exist to
790
+ // prevent one syntax over.
791
+ for (const m of where.matchAll(/\b([a-z_]+)\s+IS\s+(?:NOT\s+)?NULL\b/gi)) {
792
+ if (!KNOWN_NULL_TESTS.has(m[1])) {
793
+ throw new Error(`fake pool: unrecognised NULL test on ${m[1]} in "${where}" — a filter this interpreter cannot read must never degrade to no filter`);
794
+ }
795
+ if (!modelled.has(m[1])) {
796
+ throw new Error(`fake pool: NULL test on ${m[1]} in "${where}" was recognised but never MODELLED — the clause is present and this interpreter is ignoring it, which is the silent-pass it exists to refuse`);
797
+ }
798
+ }
724
799
  return (r) => tests.every((fn) => fn(r));
725
800
  }
726
801
 
@@ -913,7 +988,7 @@ await ta('private accounts are absent from the leaderboard/directory rollup', as
913
988
  const shown = rows.map((r) => r.github_login).sort();
914
989
  assert.deepEqual(shown, ['octo', 'shifted'],
915
990
  'only active, publicly-visible, unhidden accounts reach /people and /scouting — '
916
- + `got ${JSON.stringify(shown)}; ghost/scout are private, shy is hidden, newbie is provisional, moved is private`);
991
+ + `got ${JSON.stringify(shown)}; ghost/scout are private, shy is hidden, newbie is provisional, moved is private, legacy never accepted the terms (ADR 0213)`);
917
992
  });
918
993
 
919
994
  server.close();