@bongos/core 1.19.430 → 1.19.432

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/.bongos-core.json +64 -44
  2. package/clients/bongos-client/README.md +1 -1
  3. package/clients/bongos-client/bongos-client.global.js +2 -0
  4. package/clients/bongos-client/index.cjs +2 -0
  5. package/clients/bongos-client/index.d.ts +3 -0
  6. package/clients/bongos-client/index.mjs +2 -0
  7. package/docs/adr/0215-the-recruiter-sliver-is-a-ceiling-not-a-step.md +116 -0
  8. package/docs/adr/0216-a-fake-pool-interpreter-must-fail-loudly-not-silently-ignore.md +102 -0
  9. package/docs/adr/README.md +2 -0
  10. package/docs/api/openapi.json +64 -3
  11. package/docs/api-reference.md +3 -2
  12. package/docs/copy-inventory.md +48 -48
  13. package/docs/copy-registry.json +57 -57
  14. package/docs/file-map.md +1 -0
  15. package/docs/module-api-changelog.md +4 -0
  16. package/modules/dev-box/app/src/vendor/bongos-client.cjs +2 -0
  17. package/modules/hall-ui/public/settings.html +1 -1
  18. package/modules/hall-ui/public/settings.js +17 -8
  19. package/modules/platform-identity/account-visibility.js +47 -0
  20. package/modules/platform-identity/platform-identity.js +4 -4
  21. package/modules/platform-identity/recruiter-sliver.js +197 -0
  22. package/modules/platform-identity/routes/scouting.js +58 -0
  23. package/package-lock.json +2 -2
  24. package/package.json +1 -1
  25. package/src/module-api.js +1 -1
  26. package/tests/account_privacy_flags.mjs +115 -9
  27. package/tests/application_echo.mjs +43 -9
  28. package/tests/application_lifecycle.mjs +18 -7
  29. package/tests/connections_ownership.mjs +44 -4
  30. package/tests/effective_visibility_predicate.mjs +137 -50
  31. package/tests/government_require_permission.mjs +22 -0
  32. package/tests/handle_machinery.mjs +15 -1
  33. package/tests/hub_profile_leak.mjs +18 -4
  34. package/tests/own_session_revoke.mjs +77 -9
  35. package/tests/profile_connections_ui.mjs +49 -10
  36. package/tests/profile_privacy_owner_view.mjs +68 -37
  37. package/tests/profile_rollup_consent.mjs +46 -14
  38. package/tests/profile_route.mjs +6 -0
  39. package/tests/recruiter_sliver_boundary.mjs +841 -0
  40. package/tests/scouting_owner_access_and_reach.mjs +175 -39
  41. package/tests/scouting_rollup_fanout.mjs +15 -4
@@ -71,6 +71,22 @@ const row = (id) => rows.find((r) => r.id === id);
71
71
  * Build a row filter from a statement's own WHERE text. Every placeholder the
72
72
  * clause mentions must be consumed by a fragment below; one that is not throws,
73
73
  * so a predicate this cannot read can never degrade into "no constraint".
74
+ *
75
+ * IT READS STRUCTURE, NOT JUST TERMS (task 1003354, idea 1000877). Every matched
76
+ * term is now BLANKED to ` TRUE `, and whatever survives must be a pure
77
+ * conjunction — whitespace, parentheses and `AND`. Two things that used to slip
78
+ * through in silence:
79
+ *
80
+ * A LITERAL-ONLY term binds no placeholder, so the sweep below cannot see it.
81
+ * `AND seen_at IS NULL` appearing (or disappearing) changed nothing here.
82
+ * AN `AND` REWRITTEN TO `OR` is the dangerous one on THIS table: accept and
83
+ * decline are `id = $1 AND addressee_github_id = $2::bigint AND status =
84
+ * 'pending'`, so an `OR` would let anyone accept anyone's request by id —
85
+ * the exact isolation this file exists to prove. A term-collecting
86
+ * interpreter re-imposes the `AND` and stays green through it.
87
+ *
88
+ * The remove path's either-side disjunction is the ONE legitimate `OR`, and it
89
+ * is consumed as a whole unit before the check, which is what lets it survive.
74
90
  */
75
91
  function predicateFor(rawWhere, params) {
76
92
  let where = rawWhere;
@@ -89,13 +105,17 @@ function predicateFor(rawWhere, params) {
89
105
  }
90
106
 
91
107
  let m;
92
- if ((m = /\bid = \$(\d+)\b/.exec(where))) { const v = p(m[1]); tests.push((r) => String(r.id) === String(v)); }
93
- if ((m = /\baddressee_github_id = \$(\d+)::bigint/.exec(where))) { const v = p(m[1]); tests.push((r) => String(r.addressee_github_id) === String(v)); }
94
- if ((m = /\brequester_github_id = \$(\d+)::bigint/.exec(where))) { const v = p(m[1]); tests.push((r) => String(r.requester_github_id) === String(v)); }
95
- if ((m = /\bstatus = '(\w+)'/.exec(where))) { const v = m[1]; tests.push((r) => r.status === v); }
108
+ // Each matcher BLANKS what it consumed, so the residue below is exactly the
109
+ // part of the clause this interpreter did not understand.
110
+ const eat = () => { where = where.replace(m[0], ' TRUE '); };
111
+ if ((m = /\bid = \$(\d+)\b/.exec(where))) { const v = p(m[1]); tests.push((r) => String(r.id) === String(v)); eat(); }
112
+ if ((m = /\baddressee_github_id = \$(\d+)::bigint/.exec(where))) { const v = p(m[1]); tests.push((r) => String(r.addressee_github_id) === String(v)); eat(); }
113
+ if ((m = /\brequester_github_id = \$(\d+)::bigint/.exec(where))) { const v = p(m[1]); tests.push((r) => String(r.requester_github_id) === String(v)); eat(); }
114
+ if ((m = /\bstatus = '(\w+)'/.exec(where))) { const v = m[1]; tests.push((r) => r.status === v); eat(); }
96
115
  if ((m = /created_at > now\(\) - make_interval\(days => \$(\d+)::int\)/.exec(where))) {
97
116
  const days = Number(p(m[1]));
98
117
  tests.push((r) => r.created_at > Date.now() - days * DAY);
118
+ eat();
99
119
  }
100
120
 
101
121
  for (const ph of rawWhere.matchAll(/\$(\d+)/g)) {
@@ -103,6 +123,10 @@ function predicateFor(rawWhere, params) {
103
123
  throw new Error(`fake pool: unread predicate on $${ph[1]} in "${rawWhere}" — the interpreter must never silently ignore a filter`);
104
124
  }
105
125
  }
126
+ const residual = where.replace(/\bTRUE\b/gi, ' ');
127
+ if (!/^[\s()]*(?:AND[\s()]*)*$/.test(residual)) {
128
+ throw new Error(`fake pool: unmodelled boolean structure "${residual.trim()}" in "${rawWhere}" — every recognised term is ANDed here, so a predicate the interpreter cannot read as a pure conjunction must be a loud error, never a silently different filter`);
129
+ }
106
130
  return (r) => tests.every((fn) => fn(r));
107
131
  }
108
132
 
@@ -209,6 +233,22 @@ test('the interpreter refuses a predicate it cannot read, rather than ignoring i
209
233
  /unread predicate on \$2/,
210
234
  'a filter the fake cannot model must be a loud error — otherwise a rewritten WHERE silently widens'
211
235
  );
236
+ assert.throws(
237
+ () => predicateFor("id = $1 AND seen_at IS NULL", [1]),
238
+ /unmodelled boolean structure/,
239
+ 'a literal-only term binds no placeholder, so the sweep above cannot see it at all'
240
+ );
241
+ assert.throws(
242
+ () => predicateFor("id = $1 OR addressee_github_id = $2::bigint", [1, ME]),
243
+ /unmodelled boolean structure/,
244
+ 'and the one that matters here: with OR, accept/decline would act on ANY row matching the id '
245
+ + 'alone — anyone accepting anyone\'s request. A term-collecting interpreter re-imposes the AND '
246
+ + 'and every isolation assertion below stays green (idea 1000877, task 1003354)'
247
+ );
248
+ assert.doesNotThrow(
249
+ () => predicateFor("id = $1 AND addressee_github_id = $2::bigint AND status = 'pending'", [1, ME]),
250
+ 'and the real accept/decline clause still parses clean'
251
+ );
212
252
  });
213
253
 
214
254
  test('the interpreter really does narrow on the owner column', () => {
@@ -79,6 +79,8 @@ import {
79
79
  isRecruiterDiscoverable,
80
80
  recruiterReachSql,
81
81
  scoutingListableSql,
82
+ recruiterSliverVisibleSql,
83
+ isRecruiterSliverVisible,
82
84
  } from '../modules/platform-identity/account-visibility.js';
83
85
 
84
86
  const require = createRequire(import.meta.url);
@@ -119,6 +121,10 @@ const SPELLING_EXEMPTIONS = [
119
121
  text: 'hide_stats = true',
120
122
  count: 1,
121
123
  why: "hiddenGithubIds asks the OPPOSITE question to visibility — 'which of these accounts have hidden themselves' — for the closeness-graph consent gate. It must stay fail-closed on hide alone (ADR 0171 D4), so it is not expressible as the shared predicate and is not a variant of it.",
124
+ }, {
125
+ text: "account_visibility = '",
126
+ count: 1,
127
+ why: "setAccountPrivacy's ON CONFLICT branch, and a WRITE rather than a read gate — it reads EXCLUDED, the value being written, to decide whether this PATCH re-arms recruiter_discoverable to its 'follow visibility' default. Going private without naming reach must clear an explicit true, because that true was set while PUBLIC and meant 'recruiters may see my public profile' (ADR 0215, task 1002968); the D3 sliver has no existence floor and would otherwise read that stale consent as fresh. It gates no read, so it cannot be expressed as the shared predicate and is not a variant of one — the SPELLINGS comment already excludes writes in principle, and this is the occurrence its regex cannot tell apart.",
122
128
  }],
123
129
  obligation: (code) => /require\(\s*'\.\/account-visibility'\s*\)/.test(code),
124
130
  demands: 'still imports the shared predicate rather than re-deriving one',
@@ -341,8 +347,14 @@ function accountStatements(code) {
341
347
  * recruiterReachSql inside the owner file, so a reader that joins it has adopted
342
348
  * the shared rule — but it must be named here, or wiring the recruiting reach in
343
349
  * would read as a reader that stopped adopting anything.
350
+ * recruiterSliverVisibleSql (task 1002968) is here on the same terms and one
351
+ * caveat: it is the ONE composed answer that deliberately omits the existence
352
+ * axis, because the private-but-discoverable account is the D3 sliver's subject.
353
+ * That is a decision recorded in the owner file and asserted above — not a
354
+ * reader quietly dropping a term, which is what this regex would otherwise let
355
+ * through unnoticed.
344
356
  */
345
- const ADOPTS = /\b(?:accountVisibleSql|accountActiveSql|rollupVisibleSql|scoutingListableSql|isAccountVisible|isAccountActive|isRollupVisible)\s*\(/;
357
+ const ADOPTS = /\b(?:accountVisibleSql|accountActiveSql|rollupVisibleSql|scoutingListableSql|recruiterSliverVisibleSql|isAccountVisible|isAccountActive|isRollupVisible|isRecruiterSliverVisible)\s*\(/;
346
358
 
347
359
  // ===========================================================================
348
360
  // Self-tests — the machinery is the guard, so it gets proved too.
@@ -513,6 +525,30 @@ t('the recruiter-reach predicate still lives here, and is still the only COALESC
513
525
  'reach never buys addressability — that is what the D3 sliver is for');
514
526
  });
515
527
 
528
+ t('recruiterSliverVisibleSql is COMPOSED from consent AND reach, and omits existence ON PURPOSE (task 1002968)', () => {
529
+ // The pre-accept sliver is the one effective answer that must NOT floor on the
530
+ // existence axis: the privacy spec's D2 "private · discoverable" row is the
531
+ // account it exists to serve, and ANDing accountVisibleSql would empty it of
532
+ // exactly that account while leaving every test above still green.
533
+ assert.ok(recruiterSliverVisibleSql('pa').includes(accountActiveSql('pa')),
534
+ 'the consent floor, literally contained — a provisional account is never recruiter-visible (spec D3)');
535
+ assert.ok(recruiterSliverVisibleSql('pa').includes(recruiterReachSql('pa')),
536
+ 'and the reach axis, literally contained — the opt-out empties this surface too');
537
+ assert.ok(!recruiterSliverVisibleSql('pa').includes(accountVisibleSql('pa')),
538
+ 'and NOT the existence axis: dropping it is safe only because reach is an explicit opt-IN for a private account');
539
+ assert.doesNotMatch(recruiterSliverVisibleSql(), /hide_stats/,
540
+ 'hide changes the SHAPE of the sliver (name-only), never who appears in it');
541
+ // The JS twin, over the same D2 matrix the SQL is judged on.
542
+ const accepted = { terms_accepted_at: 'T1' }; // the T&C half of the consent floor (ADR 0213)
543
+ assert.equal(isRecruiterSliverVisible({ ...accepted, profile_state: 'active', account_visibility: 'private', recruiter_discoverable: true }), true);
544
+ assert.equal(isRecruiterSliverVisible({ ...accepted, profile_state: 'active', account_visibility: 'private', recruiter_discoverable: null }), false);
545
+ assert.equal(isRecruiterSliverVisible({ ...accepted, profile_state: 'provisional', account_visibility: 'public', recruiter_discoverable: true }), false);
546
+ assert.equal(isRecruiterSliverVisible({ profile_state: 'active', account_visibility: 'private', recruiter_discoverable: true }), false,
547
+ 'and the sliver inherits the T&C floor by COMPOSITION — an account that never accepted is not recruiter-visible');
548
+ assert.equal(isRecruiterSliverVisible(null), false, 'fail-closed on an absent account');
549
+ assert.throws(() => recruiterSliverVisibleSql('pa; DROP TABLE x --'), /bare SQL identifier/);
550
+ });
551
+
516
552
  // ===========================================================================
517
553
  // 2. The sole-owner rule — nothing else spells a variant
518
554
  // ===========================================================================
@@ -640,6 +676,27 @@ t('the shared predicate has real adopters (the wiring did not silently unwire it
640
676
  }
641
677
  });
642
678
 
679
+ t('the EXISTENCE-LESS predicate has exactly one reader, and it is the sliver (task 1002968)', () => {
680
+ // recruiterSliverVisibleSql counts as adoption in ADOPTS above, which is what
681
+ // lets the sliver's own read pass the classification rule — and that is a hole
682
+ // unless the predicate is also PINNED to its one caller. It is the only shared
683
+ // answer that omits `account_visibility = 'public'`, so a future public read
684
+ // that joined it would look adopted while quietly serving private accounts.
685
+ // BOTH FORMS, because the same diff put both into ADOPTS. Pinning only the SQL
686
+ // one left the JS twin as an open door: a new module file doing
687
+ // `rows.filter(isRecruiterSliverVisible)` over an ungated
688
+ // `SELECT … FROM platform_identity_accounts WHERE handle ILIKE $1` passed this
689
+ // entire guard — that is the hub profile search this file's own header names
690
+ // as the read that must fail the build, sailing through on the one predicate
691
+ // that omits the existence axis (found in adversarial review, task 1002968).
692
+ // Enumerated from source, so a second reader fails here rather than in review.
693
+ const readers = scanned
694
+ .filter((f) => f.rel !== PREDICATE_OWNER && /\b(?:recruiterSliverVisibleSql|isRecruiterSliverVisible)\s*\(/.test(f.code))
695
+ .map((f) => f.rel);
696
+ assert.deepEqual(readers, ['modules/platform-identity/recruiter-sliver.js'],
697
+ 'the pre-accept predicate is the recruiting sliver\'s alone — every other read must floor on the existence axis');
698
+ });
699
+
643
700
  t('getPublicProfileExtras gates on the shared JS port (its SQL carries no predicate to join)', () => {
644
701
  const pi = scanned.find((f) => f.rel === 'modules/platform-identity/platform-identity.js');
645
702
  const span = functionSpans(pi.code).find((s) => s.name === 'getPublicProfileExtras');
@@ -725,55 +782,72 @@ const ACCOUNTS = [
725
782
  // clause throws — the guard against a predicate quietly degrading to no filter.
726
783
  const KNOWN_COLUMNS = new Set(['profile_state', 'account_visibility', 'hide_stats', 'github_id', 'handle', 'previous_handle']);
727
784
 
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
-
733
785
  /**
734
786
  * Build a row test from a statement's own WHERE clause. Literal comparisons
735
787
  * (profile_state = 'active') matter as much as placeholders here, because the
736
788
  * effective-visibility predicate is made ENTIRELY of literal comparisons — a
737
789
  * fake pool that only modelled $N filters would score the privacy terms as
738
790
  * absent and pass no matter what.
791
+ *
792
+ * IT READS STRUCTURE, NOT JUST TERMS, which is why this is a CONSUMING parse.
793
+ * Two ways a term-collecting interpreter lies, and this shape closes both:
794
+ *
795
+ * THE MISSING TERM. The ADR 0213 acceptance half is a NULL TEST — a third
796
+ * syntax neither the placeholder sweep nor the literal sweep can see. An
797
+ * unmodelled one degrades to no filter exactly like an unread $N.
798
+ * THE REWRITTEN JOINER. Collecting recognised terms and ANDing them re-imposes
799
+ * an `AND` the SQL may no longer contain: rewrite accountVisibleSql's `AND`
800
+ * to `OR` and Postgres serves every ACTIVE account, private ones included,
801
+ * while the fake answers exactly as before (idea 1000877, from the
802
+ * adversarial review of task 1002968).
803
+ *
804
+ * So each recognised term is REMOVED from the text as it is matched and the
805
+ * residue must be a pure conjunction. That single check subsumes the NULL-test
806
+ * bookkeeping this function used to carry — recognition and application can no
807
+ * longer come apart, because an unapplied term is simply still there.
739
808
  */
740
809
  function accountFilter(where, params) {
741
810
  const tests = [];
742
811
  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();
746
-
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
- }
759
- if (/\baccount_visibility = 'public'/.test(where)) tests.push((r) => r.account_visibility === 'public');
760
- if (/\bNOT (?:[a-z_]+\.)?hide_stats\b/.test(where)) tests.push((r) => !r.hide_stats);
761
- // The recruiting-reach COALESCE (task 1002290). Modelled through the shared JS
762
- // twin, not re-implemented — an interpreter that read this term its own way
763
- // could agree with a WRONG query. Unmodelled it would degrade to no filter,
764
- // which is precisely what this interpreter exists to refuse.
765
- if (/COALESCE\((?:[a-z_]+\.)?recruiter_discoverable,/.test(where)) tests.push(isRecruiterDiscoverable);
766
-
767
- const byId = /\b(?:[a-z_]+\.)?github_id = \$(\d+)/.exec(where);
768
- if (byId) { consumed.add(byId[1]); const v = params[Number(byId[1]) - 1]; tests.push((r) => String(r.github_id) === String(v)); }
769
-
770
- const byHandle = /lower\(handle\) = \$(\d+) OR lower\(previous_handle\) = \$(\d+)/.exec(where);
771
- if (byHandle) {
772
- consumed.add(byHandle[1]); consumed.add(byHandle[2]);
773
- const v = String(params[Number(byHandle[1]) - 1]);
774
- tests.push((r) => String(r.handle || '').toLowerCase() === v || String(r.previous_handle || '').toLowerCase() === v);
775
- }
812
+ let residue = where;
813
+ // Match a term, model it, and CONSUME its text so the leftovers can be judged.
814
+ const take = (re, make) => {
815
+ const m = re.exec(residue);
816
+ if (!m) return null;
817
+ residue = residue.slice(0, m.index) + residue.slice(m.index + m[0].length);
818
+ tests.push(make(m));
819
+ return m;
820
+ };
776
821
 
822
+ // The recruiting-reach COALESCE (task 1002290) FIRST, because it spells
823
+ // `account_visibility = 'public'` inside itself as its own fallback —
824
+ // consuming it first stops that fallback being scored as a separate existence
825
+ // check. Modelled through the shared JS twin, not re-implemented: an
826
+ // interpreter that read this term its own way could agree with a WRONG query.
827
+ take(/\bCOALESCE\((?:[a-z_]+\.)?recruiter_discoverable,\s*(?:[a-z_]+\.)?account_visibility = 'public'\s*\)/,
828
+ () => isRecruiterDiscoverable);
829
+ take(/\b(?:[a-z_]+\.)?profile_state = 'active'/, () => (r) => r.profile_state === 'active');
830
+ take(/\b(?:[a-z_]+\.)?terms_accepted_at IS NOT NULL/, () => (r) => r.terms_accepted_at != null);
831
+ take(/\b(?:[a-z_]+\.)?account_visibility = 'public'/, () => (r) => r.account_visibility === 'public');
832
+ take(/\bNOT (?:[a-z_]+\.)?hide_stats\b/, () => (r) => !r.hide_stats);
833
+
834
+ take(/\b(?:[a-z_]+\.)?github_id = \$(\d+)/, (m) => {
835
+ consumed.add(m[1]);
836
+ const v = params[Number(m[1]) - 1];
837
+ return (r) => String(r.github_id) === String(v);
838
+ });
839
+
840
+ // The rename window's disjunction is the ONE legitimate OR in this repo's
841
+ // account reads, so it is consumed as a single unit — leaving a bare `OR`
842
+ // behind would trip the structure check below.
843
+ take(/lower\(handle\) = \$(\d+) OR lower\(previous_handle\) = \$(\d+)/, (m) => {
844
+ consumed.add(m[1]); consumed.add(m[2]);
845
+ const v = String(params[Number(m[1]) - 1]);
846
+ return (r) => String(r.handle || '').toLowerCase() === v || String(r.previous_handle || '').toLowerCase() === v;
847
+ });
848
+
849
+ // The TERM guards run before the STRUCTURE guard, so an unreadable term is
850
+ // reported as the unreadable term it is rather than as leftover text.
777
851
  for (const m of where.matchAll(/\$(\d+)/g)) {
778
852
  if (!consumed.has(m[1])) {
779
853
  throw new Error(`fake pool: unrecognised predicate on $${m[1]} in "${where}" — a filter this interpreter cannot read must never degrade to no filter`);
@@ -784,17 +858,8 @@ function accountFilter(where, params) {
784
858
  throw new Error(`fake pool: unrecognised literal comparison on ${m[1]} in "${where}"`);
785
859
  }
786
860
  }
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
- }
861
+ if (!/^[\s()]*(?:AND[\s()]*)*$/.test(residue)) {
862
+ 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`);
798
863
  }
799
864
  return (r) => tests.every((fn) => fn(r));
800
865
  }
@@ -886,6 +951,28 @@ function get(path_, headers = {}) {
886
951
  });
887
952
  }
888
953
 
954
+ t('CALIBRATION: the interpreter refuses what it cannot model, rather than ignoring it', () => {
955
+ // The behavioural half below is worth exactly as much as this. Every 404 it
956
+ // asserts is produced by accountFilter, so an interpreter that silently
957
+ // dropped a term would keep them all green while the predicate rotted.
958
+ assert.throws(() => accountFilter("profile_state = 'active' AND tier = 'gold'", []),
959
+ /unrecognised literal comparison on tier/);
960
+ assert.throws(() => accountFilter("profile_state = 'active' AND cohort = $1", ['beta']),
961
+ /unrecognised predicate on \$1/);
962
+ assert.throws(() => accountFilter("profile_state = 'active' AND deleted_at IS NULL", []),
963
+ /unmodelled boolean structure/,
964
+ 'a NULL TEST is a third syntax, invisible to both sweeps above — the ADR 0213 shape');
965
+ assert.throws(() => accountFilter("profile_state = 'active' OR account_visibility = 'public'", []),
966
+ /unmodelled boolean structure/,
967
+ 'an AND rewritten to OR serves every ACTIVE account including the private ones; a '
968
+ + 're-imposed AND would keep every 404 below green (idea 1000877)');
969
+ // ...and the real predicates still parse clean, in every composed form.
970
+ for (const clause of [accountActiveSql('pa'), accountVisibleSql('pa'), rollupVisibleSql('pa'),
971
+ scoutingListableSql('pa'), recruiterSliverVisibleSql('pa')]) {
972
+ assert.doesNotThrow(() => accountFilter(clause, []), `the shared predicate ${clause} must still be readable`);
973
+ }
974
+ });
975
+
889
976
  await ta('CONTROL: the fixture and the interpreter really work — a public account resolves', async () => {
890
977
  // Without this, every 404 below could be a broken fake rather than a boundary.
891
978
  const res = await get('/api/gds/profiles/octo');
@@ -352,6 +352,28 @@ test('every modules/*/routes gate is migrated, bar the one documented holdout',
352
352
  // worth doing — it means minting an atom over the write — but it is the
353
353
  // continuing R107/R108 migration's job, not this ADR's.
354
354
  'security/routes/scans.js',
355
+ // Still requireRank after BV1.R105 — and for a reason the other holdouts do
356
+ // not have: the atom this gate WANTS is specified, named, and deliberately
357
+ // owned by another wave. `GET /scouting/:handle` is the privacy spec's D3
358
+ // pre-accept recruiter sliver (task 1002968, ADR 0215 D4), and D3 gates it on
359
+ // `project.recruit` — declared in spec D1 as FLOOR ARCHON, widenable per
360
+ // instance. That atom does not exist: ADR 0201 D3 and ADR 0210 both refuse to
361
+ // mint it outside the project-admin-console wave that defines it (spike
362
+ // 1002786 R3), because a third definition before any had shipped is the drift
363
+ // they were avoiding.
364
+ //
365
+ // The three ways out are all worse. Minting it here forks its definition.
366
+ // Borrowing a different archon atom (`access_request.review`, say) would name
367
+ // the wrong authority on a privacy surface. And reusing the sibling route's
368
+ // `project.curate`-or-project-owner door — the tidy option, and what the first
369
+ // draft did — is WIDER than the signed floor on both halves, on the first
370
+ // surface on this platform that discloses a PRIVATE account.
371
+ //
372
+ // So the floor is held by rank until the atom lands, which is the one spelling
373
+ // that neither invents authority nor widens it. It is temporary by
374
+ // construction: blocker 1000126 carries the audience question to the owner,
375
+ // and spike 1002786 R3 mints the atom that removes this entry.
376
+ 'platform-identity/routes/scouting.js',
355
377
  ].sort();
356
378
 
357
379
  const modulesDir = path.join(ROOT, 'modules');
@@ -64,6 +64,12 @@ try {
64
64
  // simply land with handle NULL — the re-run seeds only those. Idempotence is
65
65
  // part of what this proves.
66
66
  await q(migrationSql('platform_identity_008_handle.sql')).catch(() => {});
67
+ // getAccount projects them and resolveHandle floors on accountVisibleSql,
68
+ // which reads account_visibility (012) and terms_accepted_at (019) — without
69
+ // these a FRESH database answers 42703 "column does not exist" rather than a
70
+ // handle assertion.
71
+ await q(migrationSql('platform_identity_012_account_privacy.sql'));
72
+ await q(migrationSql('platform_identity_019_terms_acceptance.sql'));
67
73
  await cleanup();
68
74
 
69
75
  await q(
@@ -116,7 +122,15 @@ try {
116
122
  const prov = await pi.resolveHandle('p01-again', { pool });
117
123
  ok(prov === null, 'a provisional account is not addressable — even by its current handle');
118
124
 
119
- await q(`UPDATE platform_identity_accounts SET profile_state = 'active' WHERE github_id = ANY($1::bigint[])`, [[FRESH, TWIN_A]]);
125
+ // Both consent columns, because since ADR 0213 addressability floors on BOTH
126
+ // and they are stamped by the SAME upsertAccount branch — an UPDATE that set
127
+ // only profile_state would model a state the code cannot produce, and the
128
+ // handle would stay unaddressable for a reason this file is not about.
129
+ await q(
130
+ `UPDATE platform_identity_accounts SET profile_state = 'active', terms_accepted_at = now()
131
+ WHERE github_id = ANY($1::bigint[])`,
132
+ [[FRESH, TWIN_A]],
133
+ );
120
134
 
121
135
  const cur = await pi.resolveHandle('P01-Again', { pool });
122
136
  ok(cur && cur.account && cur.account.handle === 'p01-again', 'the current handle resolves case-insensitively');
@@ -104,6 +104,10 @@ function get(port, path) {
104
104
  const NEVER_IN_A_BODY = [
105
105
  SECRET_CLIENT_PUB, SECRET_CLIENT_PRIV, SECRET_HASH, SECRET_PROJECT, SECRET_PREV,
106
106
  'profile_state', 'hide_stats', 'github_id', 'display_name_source',
107
+ // The ADR 0213 consent column joins the list on the same terms as
108
+ // profile_state: it is an INPUT to the public predicate, never an output, and
109
+ // when the account accepted the terms is nobody else's business.
110
+ 'terms_accepted_at',
107
111
  ];
108
112
  function assertNoSentinels(text, where) {
109
113
  for (const s of NEVER_IN_A_BODY) {
@@ -119,16 +123,26 @@ try {
119
123
  await q(migrationSql('platform_identity_007_profile_consent.sql'));
120
124
  await q(migrationSql('platform_identity_008_handle.sql'));
121
125
  await q(migrationSql('platform_identity_009_profile_fields.sql'));
126
+ // resolveHandle floors on accountVisibleSql, which reads account_visibility
127
+ // (012) and terms_accepted_at (019). Without these a FRESH database answers
128
+ // 42703 "column does not exist" instead of a 404-vs-200 assertion.
129
+ await q(migrationSql('platform_identity_012_account_privacy.sql'));
130
+ await q(migrationSql('platform_identity_019_terms_acceptance.sql'));
122
131
  await cleanup();
123
132
 
124
133
  // The active account carries the P02 fields + a user-set name source, so the
125
134
  // 200 proves bio/links serialize while display_name_source never does.
135
+ //
136
+ // The consented rows stamp terms_accepted_at as well as profile_state: since
137
+ // ADR 0213 addressability floors on BOTH, and a fixture seeding only the
138
+ // first is a pre-terms account that 404s. The provisional row keeps NULL —
139
+ // it is not consented on either column.
126
140
  await q(
127
141
  `INSERT INTO platform_identity_accounts
128
- (github_id, github_login, display_name, avatar_url, profile_state, hide_stats, handle, previous_handle, bio, links, display_name_source)
129
- VALUES ($1, 'p05-active-login', 'P05 Active', 'https://a.example/a.png', 'active', false, 'p05-active', $4, 'Builds things.', '{"website":"https://p05.example/"}', 'user'),
130
- ($2, 'p05-hidden-login', 'P05 Hidden', NULL, 'active', true, 'p05-hidden', NULL, NULL, NULL, 'github'),
131
- ($3, 'p05-prov-login', 'P05 Prov', NULL, 'provisional', false, 'p05-prov', NULL, NULL, NULL, 'github')`,
142
+ (github_id, github_login, display_name, avatar_url, profile_state, terms_accepted_at, hide_stats, handle, previous_handle, bio, links, display_name_source)
143
+ VALUES ($1, 'p05-active-login', 'P05 Active', 'https://a.example/a.png', 'active', now(), false, 'p05-active', $4, 'Builds things.', '{"website":"https://p05.example/"}', 'user'),
144
+ ($2, 'p05-hidden-login', 'P05 Hidden', NULL, 'active', now(), true, 'p05-hidden', NULL, NULL, NULL, 'github'),
145
+ ($3, 'p05-prov-login', 'P05 Prov', NULL, 'provisional', NULL, false, 'p05-prov', NULL, NULL, NULL, 'github')`,
132
146
  [ACTIVE, HIDDEN, PROV, SECRET_PREV],
133
147
  );
134
148
  await q(
@@ -81,28 +81,65 @@ function seed() {
81
81
  // Build a row filter from the statement's OWN where clause. Every placeholder the
82
82
  // clause mentions must be consumed by a fragment below; one that is not throws,
83
83
  // so a predicate this cannot read can never degrade into "no constraint".
84
+ //
85
+ // IT READS STRUCTURE, NOT JUST TERMS (task 1003354, idea 1000877). Each matched
86
+ // term is REMOVED from the clause, and whatever survives must be a pure
87
+ // conjunction — whitespace, parentheses and `AND`. Three holes this closes, all
88
+ // of them "the interpreter silently answered a question it was not asked":
89
+ //
90
+ // A LITERAL-ONLY term binds no placeholder, so the sweep below cannot see it.
91
+ // `revoked_at IS NULL` could be deleted from the real revoke statement and
92
+ // this fake would keep answering as though it were there.
93
+ // AN `AND` REWRITTEN TO `OR` is the dangerous one on this table: revoke is
94
+ // `id = $1 AND builder_id = $2`, so an `OR` lets any builder revoke any
95
+ // session by id — the exact own-scope isolation this file exists to prove —
96
+ // while a term-collecting interpreter re-imposes the `AND` and stays green.
97
+ // A CLAUSE WITH NO READABLE TERM used to yield an empty `tests` array, which
98
+ // matches EVERY row. A statement that carries a WHERE must narrow.
99
+ //
100
+ // The session-expiry disjunction is the ONE legitimate `OR` here and is consumed
101
+ // as a whole unit, which is what lets it survive the check.
84
102
  function predicateFor(sql, params) {
85
- const where = sql.includes(' WHERE ') ? sql.slice(sql.indexOf(' WHERE ') + 7) : '';
103
+ // The tail is not part of the predicate. It has to go before the residue is
104
+ // judged, or `ORDER BY last_used_at DESC` would read as an unmodelled term.
105
+ const raw = sql.includes(' WHERE ')
106
+ ? sql.slice(sql.indexOf(' WHERE ') + 7).split(/ (?:ORDER BY|GROUP BY|RETURNING|LIMIT|OFFSET) /)[0]
107
+ : '';
86
108
  const tests = [];
87
109
  const consumed = new Set();
110
+ let residue = raw;
111
+ // Some terms bind a placeholder and some are pure literals (`revoked_at IS
112
+ // NULL`), so the bound value is resolved only when there IS a capture group —
113
+ // passing `params[NaN]` and relying on the maker to ignore it would work by
114
+ // accident, and would break silently the day a no-capture maker read its arg.
88
115
  const take = (re, make) => {
89
- const m = re.exec(where);
116
+ const m = re.exec(residue);
90
117
  if (!m) return;
118
+ residue = residue.slice(0, m.index) + residue.slice(m.index + m[0].length);
119
+ if (m[1] === undefined) { tests.push(make()); return; }
91
120
  consumed.add(m[1]);
92
121
  tests.push(make(params[Number(m[1]) - 1]));
93
122
  };
123
+ // The expiry disjunction FIRST, as a unit — otherwise the bare-column matchers
124
+ // would tear it in half and leave a dangling `OR` behind.
125
+ take(/\(?expires_at IS NULL OR expires_at > now\(\)\)?/,
126
+ () => (r) => r.expires_at === null || r.expires_at > new Date());
94
127
  take(/\b(?:s\.)?token = \$(\d+)/, (v) => (r) => r.token === v);
95
128
  take(/(?<!_)\b(?:s\.)?id = \$(\d+)/, (v) => (r) => String(r.id) === String(v));
96
129
  take(/\bbuilder_id = \$(\d+)/, (v) => (r) => String(r.builder_id) === String(v));
97
- if (/revoked_at IS NULL/.test(where)) tests.push((r) => r.revoked_at === null);
98
- if (/expires_at IS NULL OR expires_at > now\(\)/.test(where)) {
99
- tests.push((r) => r.expires_at === null || r.expires_at > new Date());
100
- }
101
- for (const m of where.matchAll(/\$(\d+)/g)) {
130
+ take(/\brevoked_at IS NULL/, () => (r) => r.revoked_at === null);
131
+
132
+ for (const m of raw.matchAll(/\$(\d+)/g)) {
102
133
  if (!consumed.has(m[1])) {
103
- throw new Error(`fake pool: unrecognised predicate on $${m[1]} in "${where}" — the interpreter must not silently ignore a filter`);
134
+ throw new Error(`fake pool: unrecognised predicate on $${m[1]} in "${raw}" — the interpreter must not silently ignore a filter`);
104
135
  }
105
136
  }
137
+ if (!/^[\s()]*(?:AND[\s()]*)*$/.test(residue)) {
138
+ throw new Error(`fake pool: unmodelled boolean structure "${residue.trim()}" in "${raw}" — every recognised term is ANDed here, so a predicate the interpreter cannot read as a pure conjunction must be a loud error, never a silently different filter`);
139
+ }
140
+ if (raw && !tests.length) {
141
+ throw new Error(`fake pool: the predicate in "${raw}" carried NO readable term — an empty filter matches every row, which is the opposite of what a WHERE is for`);
142
+ }
106
143
  return (r) => tests.every((fn) => fn(r));
107
144
  }
108
145
 
@@ -189,7 +226,38 @@ const aliveFor = (builderId) => sessions.filter((s) => s.builder_id === builderI
189
226
  // 1 — a xenos can see and kill their OWN sessions, with no Archon in sight
190
227
  // ===========================================================================
191
228
 
192
- console.log('own scope — a non-admin builder manages their own sessions:');
229
+ console.log('calibration — the instrument is checked before it is trusted:');
230
+
231
+ await t('the interpreter refuses what it cannot model, rather than ignoring it', () => {
232
+ // This file had no calibration at all until task 1003354, which is how it kept
233
+ // three silently-permissive paths: an unread literal, an OR it re-ANDed, and
234
+ // an empty predicate that matched every row. Every isolation assertion below
235
+ // is produced by predicateFor, so each of those made the whole file decorative.
236
+ assert.throws(() => predicateFor('SELECT id FROM builder_sessions WHERE id = $1 AND source = $2', [1, 'cli']),
237
+ /unrecognised predicate on \$2/,
238
+ 'an unread placeholder — the older half of the rule');
239
+ assert.throws(() => predicateFor('SELECT id FROM builder_sessions WHERE id = $1 AND source IS NULL', [1]),
240
+ /unmodelled boolean structure/,
241
+ 'a literal-only term binds nothing, so the placeholder sweep cannot see it');
242
+ assert.throws(() => predicateFor('UPDATE builder_sessions SET revoked_at = now() WHERE id = $1 OR builder_id = $2', [1, 1]),
243
+ /unmodelled boolean structure/,
244
+ 'and the one that matters here: with OR, any builder revokes any session by id — while a '
245
+ + 'term-collecting interpreter re-imposes the AND and every test below stays green (idea 1000877)');
246
+ assert.throws(() => predicateFor('SELECT id FROM builder_sessions WHERE source IS NOT NULL', []),
247
+ /unmodelled boolean structure/,
248
+ 'and a clause with no readable term at all: an empty filter matches every row');
249
+ // ...and the real statements still parse clean.
250
+ assert.doesNotThrow(() => predicateFor(
251
+ 'SELECT id FROM builder_sessions WHERE builder_id = $1 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now()) ORDER BY last_used_at DESC, id DESC',
252
+ [1],
253
+ ), 'the own-scope list, tail and legitimate OR included');
254
+ assert.doesNotThrow(() => predicateFor(
255
+ 'UPDATE builder_sessions SET revoked_at = now() WHERE id = $1 AND builder_id = $2 AND revoked_at IS NULL RETURNING id',
256
+ [1, 1],
257
+ ), 'and the revoke');
258
+ });
259
+
260
+ console.log('\nown scope — a non-admin builder manages their own sessions:');
193
261
 
194
262
  await t('GET /me/sessions lists only the caller\'s LIVE sessions', async () => {
195
263
  seed(); calls = [];