@bongos/core 1.19.427 → 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.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.427",
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.427",
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.427",
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
 
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.427'; // 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();
@@ -51,6 +51,16 @@ import vm from 'node:vm';
51
51
  import { readFileSync } from 'node:fs';
52
52
  import { fileURLToPath } from 'node:url';
53
53
  import { dirname, join } from 'node:path';
54
+ // The REAL predicates, not a copy. accountFloor() below used to spell these
55
+ // itself, which made it a hand-maintained mirror of account-visibility.js —
56
+ // and it drifted the moment ADR 0213 made consent two columns. Imported, the
57
+ // next change to the shared rule moves this guard with it instead.
58
+ import {
59
+ accountVisibleSql,
60
+ accountActiveSql,
61
+ isAccountVisible,
62
+ isAccountActive,
63
+ } from '../modules/platform-identity/account-visibility.js';
54
64
 
55
65
  const require = createRequire(import.meta.url);
56
66
  const express = require('express');
@@ -86,7 +96,8 @@ let nextEdgeId = 1;
86
96
 
87
97
  const acct = (githubId, handle, name, over = {}) => ({
88
98
  id: githubId, github_id: String(githubId), github_login: handle, display_name: name,
89
- avatar_url: null, profile_state: 'active', hide_stats: false, account_visibility: 'public',
99
+ avatar_url: null, profile_state: 'active', terms_accepted_at: '2026-08-29T00:00:00.000Z',
100
+ hide_stats: false, account_visibility: 'public',
90
101
  recruiter_discoverable: null, handle, previous_handle: null, handle_changed_at: null,
91
102
  bio: null, links: null, display_name_source: 'github',
92
103
  created_at: new Date(0).toISOString(), updated_at: new Date(0).toISOString(), ...over,
@@ -111,7 +122,7 @@ function seed() {
111
122
  acct(CY, 'cy', 'Cy'),
112
123
  acct(ZED, 'zed', 'Zed'),
113
124
  acct(VIC, 'vic', 'Vic', { account_visibility: 'private' }),
114
- acct(PROV, 'prov', 'Prov', { profile_state: 'provisional' }),
125
+ acct(PROV, 'prov', 'Prov', { profile_state: 'provisional', terms_accepted_at: null }),
115
126
  acct(SEC, 'sec', 'Sec', { account_visibility: 'private' }),
116
127
  acct(NEW, 'newbie', 'Newbie'),
117
128
  acct(COOL, 'cooled', 'Cooled'),
@@ -195,14 +206,14 @@ function predicateFor(rawWhere, params) {
195
206
  // read that stopped joining one of them stops matching here and throws, which
196
207
  // is the point: the pool must not be more permissive than the SQL.
197
208
  function accountFloor(sql, { alias = 'pa', axis }) {
198
- const visible = `(${alias}.profile_state = 'active' AND ${alias}.account_visibility = 'public')`;
199
- const active = `(${alias}.profile_state = 'active')`;
209
+ const visible = accountVisibleSql(alias);
210
+ const active = accountActiveSql(alias);
200
211
  if (axis === 'visible') {
201
212
  if (!sql.includes(visible)) throw new Error(`fake pool: expected the public visibility floor ${visible} in — ${sql}`);
202
- return (a) => !!a && a.profile_state === 'active' && a.account_visibility === 'public';
213
+ return isAccountVisible;
203
214
  }
204
215
  if (!sql.includes(active)) throw new Error(`fake pool: expected the consent floor ${active} in — ${sql}`);
205
- return (a) => !!a && a.profile_state === 'active';
216
+ return isAccountActive;
206
217
  }
207
218
 
208
219
  const iso = (ms) => (ms == null ? null : new Date(ms).toISOString());
@@ -230,9 +241,13 @@ const pool = {
230
241
  const hit = accounts
231
242
  .filter((a) => String(a.handle).toLowerCase() === want || String(a.previous_handle).toLowerCase() === want)
232
243
  .sort((x, y) => Number(String(y.handle).toLowerCase() === want) - Number(String(x.handle).toLowerCase() === want));
244
+ // Mirrors findAccountByHandle's real projection. terms_accepted_at is part
245
+ // of it since ADR 0213 — a row missing it reads as unconsented to the JS
246
+ // twin, which is the silent deny-all this projection must not fake.
233
247
  return { rows: hit.slice(0, 1).map((a) => ({
234
248
  github_id: a.github_id, handle: a.handle, display_name: a.display_name,
235
- avatar_url: a.avatar_url, profile_state: a.profile_state, account_visibility: a.account_visibility,
249
+ avatar_url: a.avatar_url, profile_state: a.profile_state,
250
+ terms_accepted_at: a.terms_accepted_at, account_visibility: a.account_visibility,
236
251
  })), rowCount: Math.min(hit.length, 1) };
237
252
  }
238
253
 
@@ -76,23 +76,28 @@ const GHOST = '5150'; // active · PRIVATE — the owner-renders subjec
76
76
  const SHY = '6060'; // active · public · HIDDEN — the no-partial-numbers subject
77
77
  const NOSY = '7777'; // active · public — the stranger at the side door
78
78
 
79
+ // ADR 0213 (task 1002913): consent is profile_state AND an acceptance of the
80
+ // platform terms, both written by the same statement — so a consented fixture
81
+ // carries both, and one carrying only the first is a row from before the terms.
82
+ const ACCEPTED_AT = '2026-08-29T00:00:00.000Z';
83
+
79
84
  const ACCOUNTS = [
80
85
  { id: 1, github_id: OCTO, github_login: 'octo', display_name: 'Octo', avatar_url: null,
81
86
  handle: 'octo', previous_handle: null, handle_changed_at: null, bio: null, links: null,
82
87
  display_name_source: 'github', created_at: 'T0', updated_at: 'T0',
83
- profile_state: 'active', account_visibility: 'public', hide_stats: false, recruiter_discoverable: null },
88
+ profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'public', hide_stats: false, recruiter_discoverable: null },
84
89
  { id: 2, github_id: GHOST, github_login: 'ghost', display_name: 'Ghost', avatar_url: null,
85
90
  handle: 'ghost', previous_handle: null, handle_changed_at: null, bio: 'ghost-bio-515007', links: null,
86
91
  display_name_source: 'github', created_at: 'T0', updated_at: 'T0',
87
- profile_state: 'active', account_visibility: 'private', hide_stats: false, recruiter_discoverable: null },
92
+ profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'private', hide_stats: false, recruiter_discoverable: null },
88
93
  { id: 3, github_id: SHY, github_login: 'shy', display_name: 'Shy', avatar_url: null,
89
94
  handle: 'shy', previous_handle: null, handle_changed_at: null, bio: null, links: null,
90
95
  display_name_source: 'github', created_at: 'T0', updated_at: 'T0',
91
- profile_state: 'active', account_visibility: 'public', hide_stats: true, recruiter_discoverable: null },
96
+ profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'public', hide_stats: true, recruiter_discoverable: null },
92
97
  { id: 4, github_id: NOSY, github_login: 'nosy', display_name: 'Nosy', avatar_url: null,
93
98
  handle: 'nosy', previous_handle: null, handle_changed_at: null, bio: null, links: null,
94
99
  display_name_source: 'github', created_at: 'T0', updated_at: 'T0',
95
- profile_state: 'active', account_visibility: 'public', hide_stats: false, recruiter_discoverable: null },
100
+ profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'public', hide_stats: false, recruiter_discoverable: null },
96
101
  ];
97
102
 
98
103
  // Every number and name the cross-project rollup can produce, per account.
@@ -135,6 +140,10 @@ function sentinelsOf(githubId) {
135
140
  // anything else throws — a WHERE it cannot read must never degrade to no WHERE.
136
141
  const KNOWN_COLUMNS = new Set(['profile_state', 'account_visibility', 'hide_stats', 'github_id', 'handle', 'previous_handle']);
137
142
 
143
+ // Columns this interpreter can answer an IS [NOT] NULL test on — a different
144
+ // syntax from the literal comparisons above, hence its own set (ADR 0213).
145
+ const KNOWN_NULL_TESTS = new Set(['terms_accepted_at']);
146
+
138
147
  /**
139
148
  * Build a row test from a statement's own WHERE text. Literal comparisons carry
140
149
  * as much weight as placeholders here: the effective-visibility predicate is
@@ -144,8 +153,18 @@ const KNOWN_COLUMNS = new Set(['profile_state', 'account_visibility', 'hide_stat
144
153
  function accountFilter(where, params) {
145
154
  const tests = [];
146
155
  const consumed = new Set();
156
+ // NULL-test terms actually turned into a filter, as opposed to column names
157
+ // merely recognised. The final sweep compares the two (ADR 0213).
158
+ const modelled = new Set();
147
159
 
148
160
  if (/\bprofile_state = 'active'/.test(where)) tests.push((r) => r.profile_state === 'active');
161
+ // The ADR 0213 consent half. It is a NULL test, not a literal comparison, so
162
+ // neither sweep below would see it — an unmodelled IS NOT NULL degrades to no
163
+ // filter exactly like an unread $N, which is what those sweeps exist to refuse.
164
+ if (/\bterms_accepted_at IS NOT NULL/.test(where)) {
165
+ modelled.add('terms_accepted_at');
166
+ tests.push((r) => r.terms_accepted_at != null);
167
+ }
149
168
  if (/\baccount_visibility = 'public'/.test(where)) tests.push((r) => r.account_visibility === 'public');
150
169
  if (/\bNOT (?:[a-z_]+\.)?hide_stats\b/.test(where)) tests.push((r) => !r.hide_stats);
151
170
 
@@ -173,6 +192,14 @@ function accountFilter(where, params) {
173
192
  throw new Error(`fake pool: unrecognised literal comparison on ${m[1]} in "${where}"`);
174
193
  }
175
194
  }
195
+ for (const m of where.matchAll(/\b([a-z_]+)\s+IS\s+(?:NOT\s+)?NULL\b/gi)) {
196
+ if (!KNOWN_NULL_TESTS.has(m[1])) {
197
+ throw new Error(`fake pool: unrecognised NULL test on ${m[1]} in "${where}"`);
198
+ }
199
+ if (!modelled.has(m[1])) {
200
+ 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`);
201
+ }
202
+ }
176
203
  return (r) => tests.every((fn) => fn(r));
177
204
  }
178
205
 
@@ -354,25 +354,28 @@ const PROOF_PROJECTS = [
354
354
  const sumOf = (k) => PROOF_PROJECTS.reduce((n, p) => n + p[k], 0);
355
355
 
356
356
  // The one account row the reads consult. setAccountHideStats mutates it.
357
- const hubAccount = { profile_state: 'active', account_visibility: 'public', hide_stats: false, handle: 'r06-handle' };
357
+ // ADR 0213 (task 1002913): consent is profile_state AND an acceptance of the
358
+ // platform terms, both stamped by the same upsertAccount branch.
359
+ const ACCEPTED_AT = '2026-08-29T00:00:00.000Z';
360
+ const hubAccount = { profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'public', hide_stats: false, handle: 'r06-handle' };
358
361
 
359
362
  // The scouting "table": one visible builder plus the THREE that consent and
360
363
  // visibility exclude (the private row joined at task 1002967 — the same axis,
361
364
  // enforced by the same shared predicate).
362
365
  const SCOUTING_TABLE = [
363
- { profile_state: 'active', account_visibility: 'public', hide_stats: false,
366
+ { profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'public', hide_stats: false,
364
367
  row: { github_id: '11', github_login: 'r06-visible', display_name: 'Visible', avatar_url: null,
365
368
  project_count: 2, total_credits: 5, total_tasks_shipped: 1, total_karma: 0,
366
369
  last_active: '2026-08-01T00:00:00.000Z', disciplines: ['engineer'] } },
367
- { profile_state: 'active', account_visibility: 'public', hide_stats: true,
370
+ { profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'public', hide_stats: true,
368
371
  row: { github_id: '12', github_login: HIDDEN_LOGIN, display_name: 'Hidden', avatar_url: null,
369
372
  project_count: 7, total_credits: 88817, total_tasks_shipped: 6613, total_karma: 5519,
370
373
  last_active: '2026-08-02T00:00:00.000Z', disciplines: ['ui'] } },
371
- { profile_state: 'provisional', account_visibility: 'public', hide_stats: false,
374
+ { profile_state: 'provisional', terms_accepted_at: null, account_visibility: 'public', hide_stats: false,
372
375
  row: { github_id: '13', github_login: PROV_LOGIN, display_name: 'Provisional', avatar_url: null,
373
376
  project_count: 1, total_credits: 71713, total_tasks_shipped: 1, total_karma: 0,
374
377
  last_active: '2026-08-03T00:00:00.000Z', disciplines: [] } },
375
- { profile_state: 'active', account_visibility: 'private', hide_stats: false,
378
+ { profile_state: 'active', terms_accepted_at: ACCEPTED_AT, account_visibility: 'private', hide_stats: false,
376
379
  row: { github_id: '14', github_login: PRIVATE_LOGIN, display_name: 'Private', avatar_url: null,
377
380
  project_count: 3, total_credits: 63127, total_tasks_shipped: 4409, total_karma: 2287,
378
381
  last_active: '2026-08-04T00:00:00.000Z', disciplines: ['engineer'] } },
@@ -389,6 +392,7 @@ const engine = {
389
392
  if (/AS project_count/.test(sql)) {
390
393
  let rows = SCOUTING_TABLE;
391
394
  if (/pa\.profile_state = 'active'/.test(sql)) rows = rows.filter((r) => r.profile_state === 'active');
395
+ if (/pa\.terms_accepted_at IS NOT NULL/.test(sql)) rows = rows.filter((r) => r.terms_accepted_at != null);
392
396
  if (/pa\.account_visibility = 'public'/.test(sql)) rows = rows.filter((r) => r.account_visibility === 'public');
393
397
  if (/NOT pa\.hide_stats/.test(sql)) rows = rows.filter((r) => !r.hide_stats);
394
398
  return { rows: rows.map((r) => ({ ...r.row })) };
@@ -398,10 +402,13 @@ const engine = {
398
402
  hubAccount.hide_stats = !!params[4];
399
403
  return { rows: [{ github_id: String(HUB_GITHUB_ID), github_login: 'jaxri', ...hubAccount }] };
400
404
  }
401
- // getPublicProfileExtras' consent read — three columns since task 1002967
402
- // wired the existence axis in (the shared rollupIsVisible port reads them).
403
- if (/SELECT profile_state, account_visibility, hide_stats/.test(sql)) {
405
+ // getPublicProfileExtras' consent read. Matched on the columns it must
406
+ // carry rather than on the exact SELECT list: pinning the full list meant
407
+ // that adding terms_accepted_at (ADR 0213) silently unhooked this branch and
408
+ // the read fell through to the wrong answer with every other test green.
409
+ if (/SELECT profile_state,.*hide_stats/.test(sql) && /FROM platform_identity_accounts/.test(sql)) {
404
410
  return { rows: [{ profile_state: hubAccount.profile_state,
411
+ terms_accepted_at: hubAccount.terms_accepted_at,
405
412
  account_visibility: hubAccount.account_visibility, hide_stats: hubAccount.hide_stats }] };
406
413
  }
407
414
  // getRollupForBuilder's per-project rows (the client_id → origin two-hop).