@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.
@@ -3,9 +3,9 @@
3
3
  "generator": "scripts/gds/copy-inventory.js",
4
4
  "goal": 1000074,
5
5
  "counts": {
6
- "strings": 1580,
7
- "unique_ids": 1374,
8
- "certain": 1580,
6
+ "strings": 1588,
7
+ "unique_ids": 1382,
8
+ "certain": 1588,
9
9
  "heuristic": 0,
10
10
  "duplicate_ids": 157
11
11
  },
@@ -23,7 +23,7 @@
23
23
  "landing": {
24
24
  "at": "public-landing",
25
25
  "files": 5,
26
- "strings": 318
26
+ "strings": 326
27
27
  }
28
28
  },
29
29
  "blind_spots": [
@@ -92,7 +92,7 @@
92
92
  "public-landing/index.html:900",
93
93
  "public-landing/privacy.html:80",
94
94
  "public-landing/projects.html:1115",
95
- "public-landing/terms.html:76"
95
+ "public-landing/terms.html:84"
96
96
  ]
97
97
  },
98
98
  {
@@ -105,7 +105,7 @@
105
105
  "public-landing/index.html:900",
106
106
  "public-landing/privacy.html:80",
107
107
  "public-landing/projects.html:1115",
108
- "public-landing/terms.html:76"
108
+ "public-landing/terms.html:84"
109
109
  ]
110
110
  },
111
111
  {
@@ -142,7 +142,7 @@
142
142
  "public-landing/contact.html:71",
143
143
  "public-landing/index.html:900",
144
144
  "public-landing/privacy.html:80",
145
- "public-landing/terms.html:76"
145
+ "public-landing/terms.html:84"
146
146
  ]
147
147
  },
148
148
  {
@@ -1104,7 +1104,7 @@
1104
1104
  "occurrences": 2,
1105
1105
  "at": [
1106
1106
  "public-landing/privacy.html:75",
1107
- "public-landing/terms.html:71"
1107
+ "public-landing/terms.html:79"
1108
1108
  ]
1109
1109
  },
1110
1110
  {
@@ -1414,7 +1414,7 @@
1414
1414
  "occurrences": 2,
1415
1415
  "at": [
1416
1416
  "public-landing/privacy.html:72",
1417
- "public-landing/terms.html:72"
1417
+ "public-landing/terms.html:80"
1418
1418
  ]
1419
1419
  },
1420
1420
  {
@@ -1484,7 +1484,7 @@
1484
1484
  "occurrences": 2,
1485
1485
  "at": [
1486
1486
  "public-landing/contact.html:59",
1487
- "public-landing/terms.html:72"
1487
+ "public-landing/terms.html:80"
1488
1488
  ]
1489
1489
  },
1490
1490
  {
@@ -12257,7 +12257,7 @@
12257
12257
  "surface": "landing",
12258
12258
  "text": "A non-profit, capture-resistant commons.",
12259
12259
  "file": "public-landing/terms.html",
12260
- "line": 76,
12260
+ "line": 84,
12261
12261
  "origin": "html-text",
12262
12262
  "confidence": "certain"
12263
12263
  },
@@ -12518,7 +12518,7 @@
12518
12518
  "surface": "landing",
12519
12519
  "text": "Built by agents, on Cloud Bongos.",
12520
12520
  "file": "public-landing/terms.html",
12521
- "line": 76,
12521
+ "line": 84,
12522
12522
  "origin": "html-text",
12523
12523
  "confidence": "certain"
12524
12524
  },
@@ -12698,7 +12698,7 @@
12698
12698
  "surface": "landing",
12699
12699
  "text": "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.",
12700
12700
  "file": "public-landing/terms.html",
12701
- "line": 65,
12701
+ "line": 73,
12702
12702
  "origin": "html-text",
12703
12703
  "confidence": "certain"
12704
12704
  },
@@ -12779,7 +12779,7 @@
12779
12779
  "surface": "landing",
12780
12780
  "text": "Fair use",
12781
12781
  "file": "public-landing/terms.html",
12782
- "line": 64,
12782
+ "line": 72,
12783
12783
  "origin": "html-text",
12784
12784
  "confidence": "certain"
12785
12785
  },
@@ -12882,6 +12882,15 @@
12882
12882
  "origin": "html-text",
12883
12883
  "confidence": "certain"
12884
12884
  },
12885
+ {
12886
+ "id": "0808df6e434c",
12887
+ "surface": "landing",
12888
+ "text": "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’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.",
12889
+ "file": "public-landing/terms.html",
12890
+ "line": 69,
12891
+ "origin": "html-text",
12892
+ "confidence": "certain"
12893
+ },
12885
12894
  {
12886
12895
  "id": "8bb1f00b1558",
12887
12896
  "surface": "landing",
@@ -13170,6 +13179,15 @@
13170
13179
  "origin": "html-text",
13171
13180
  "confidence": "certain"
13172
13181
  },
13182
+ {
13183
+ "id": "69400ca12477",
13184
+ "surface": "landing",
13185
+ "text": "Once you have signed in here and accepted these terms, your profile and that record are public by default: at your handle’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.",
13186
+ "file": "public-landing/terms.html",
13187
+ "line": 68,
13188
+ "origin": "html-text",
13189
+ "confidence": "certain"
13190
+ },
13173
13191
  {
13174
13192
  "id": "8329bb1a3693",
13175
13193
  "surface": "landing",
@@ -13238,7 +13256,7 @@
13238
13256
  "surface": "landing",
13239
13257
  "text": "Open source (AGPL-3.0)",
13240
13258
  "file": "public-landing/terms.html",
13241
- "line": 76,
13259
+ "line": 84,
13242
13260
  "origin": "html-text",
13243
13261
  "confidence": "certain"
13244
13262
  },
@@ -13382,7 +13400,7 @@
13382
13400
  "surface": "landing",
13383
13401
  "text": "Questions go through the",
13384
13402
  "file": "public-landing/terms.html",
13385
- "line": 72,
13403
+ "line": 80,
13386
13404
  "origin": "html-text",
13387
13405
  "confidence": "certain"
13388
13406
  },
@@ -13400,7 +13418,7 @@
13400
13418
  "surface": "landing",
13401
13419
  "text": "Read the source.",
13402
13420
  "file": "public-landing/terms.html",
13403
- "line": 71,
13421
+ "line": 79,
13404
13422
  "origin": "html-text",
13405
13423
  "confidence": "certain"
13406
13424
  },
@@ -13881,6 +13899,15 @@
13881
13899
  "origin": "html-text",
13882
13900
  "confidence": "certain"
13883
13901
  },
13902
+ {
13903
+ "id": "198f1eac5a95",
13904
+ "surface": "landing",
13905
+ "text": "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 — signing in again does not change the original date. This is version",
13906
+ "file": "public-landing/terms.html",
13907
+ "line": 70,
13908
+ "origin": "html-text",
13909
+ "confidence": "certain"
13910
+ },
13884
13911
  {
13885
13912
  "id": "20fcebaa85c4",
13886
13913
  "surface": "landing",
@@ -13944,12 +13971,21 @@
13944
13971
  "origin": "html-text",
13945
13972
  "confidence": "certain"
13946
13973
  },
13974
+ {
13975
+ "id": "02e596cc4569",
13976
+ "surface": "landing",
13977
+ "text": "These numbers describe; they do not govern.",
13978
+ "file": "public-landing/terms.html",
13979
+ "line": 67,
13980
+ "origin": "html-text",
13981
+ "confidence": "certain"
13982
+ },
13947
13983
  {
13948
13984
  "id": "ea7de26ce2f0",
13949
13985
  "surface": "landing",
13950
13986
  "text": "These terms ship with the software and change with it. The version served on this page is the one that applies; the repository keeps the history.",
13951
13987
  "file": "public-landing/terms.html",
13952
- "line": 71,
13988
+ "line": 79,
13953
13989
  "origin": "html-text",
13954
13990
  "confidence": "certain"
13955
13991
  },
@@ -14003,7 +14039,7 @@
14003
14039
  "surface": "landing",
14004
14040
  "text": "To the extent the law allows, the people running {{productName}} are not liable for loss arising from its use. It is free software, offered without warranty, as the AGPL-3.0 states.",
14005
14041
  "file": "public-landing/terms.html",
14006
- "line": 68,
14042
+ "line": 76,
14007
14043
  "origin": "html-text",
14008
14044
  "confidence": "certain"
14009
14045
  },
@@ -14106,6 +14142,15 @@
14106
14142
  "origin": "html-text",
14107
14143
  "confidence": "certain"
14108
14144
  },
14145
+ {
14146
+ "id": "9b3312899fb1",
14147
+ "surface": "landing",
14148
+ "text": "What is public.",
14149
+ "file": "public-landing/terms.html",
14150
+ "line": 68,
14151
+ "origin": "html-text",
14152
+ "confidence": "certain"
14153
+ },
14109
14154
  {
14110
14155
  "id": "b19e6b7ddc42",
14111
14156
  "surface": "landing",
@@ -14142,6 +14187,15 @@
14142
14187
  "origin": "html-text",
14143
14188
  "confidence": "certain"
14144
14189
  },
14190
+ {
14191
+ "id": "6130dcb60532",
14192
+ "surface": "landing",
14193
+ "text": "What you control.",
14194
+ "file": "public-landing/terms.html",
14195
+ "line": 69,
14196
+ "origin": "html-text",
14197
+ "confidence": "certain"
14198
+ },
14145
14199
  {
14146
14200
  "id": "b938baf0f723",
14147
14201
  "surface": "landing",
@@ -14250,6 +14304,15 @@
14250
14304
  "origin": "html-text",
14251
14305
  "confidence": "certain"
14252
14306
  },
14307
+ {
14308
+ "id": "0f06f7e72ea7",
14309
+ "surface": "landing",
14310
+ "text": "Your acceptance is recorded.",
14311
+ "file": "public-landing/terms.html",
14312
+ "line": 70,
14313
+ "origin": "html-text",
14314
+ "confidence": "certain"
14315
+ },
14253
14316
  {
14254
14317
  "id": "a4d2357d486b",
14255
14318
  "surface": "landing",
@@ -14358,6 +14421,15 @@
14358
14421
  "origin": "html-text",
14359
14422
  "confidence": "certain"
14360
14423
  },
14424
+ {
14425
+ "id": "cd379e3e0111",
14426
+ "surface": "landing",
14427
+ "text": "Your work across projects",
14428
+ "file": "public-landing/terms.html",
14429
+ "line": 65,
14430
+ "origin": "html-text",
14431
+ "confidence": "certain"
14432
+ },
14361
14433
  {
14362
14434
  "id": "bb158e10340b",
14363
14435
  "surface": "landing",
@@ -14444,7 +14516,7 @@
14444
14516
  "surface": "landing",
14445
14517
  "text": "contact page",
14446
14518
  "file": "public-landing/terms.html",
14447
- "line": 72,
14519
+ "line": 80,
14448
14520
  "origin": "html-text",
14449
14521
  "confidence": "certain"
14450
14522
  },
@@ -14606,7 +14678,7 @@
14606
14678
  "surface": "landing",
14607
14679
  "text": "privacy page",
14608
14680
  "file": "public-landing/terms.html",
14609
- "line": 72,
14681
+ "line": 80,
14610
14682
  "origin": "html-text",
14611
14683
  "confidence": "certain"
14612
14684
  },
package/docs/file-map.md CHANGED
@@ -355,6 +355,7 @@ tests/
355
355
  ├── application_echo.mjs ← (task 1002284, goal 1000045, ADR 0201 D1.3/D1.4): the hub's STATUSLESS echo of an application. Part A pins the domain functions — the read binds `APPLICATION_ECHO_WINDOW_DAYS` itself (one 30, not three), the write is an upsert on the pair, neither statement mentions a status, and a non-numeric principal is refused before any query (ADR 0171 D2). Part B runs the real `POST /my-projects/join` and `GET /my-projects` over an in-memory `platform_identity_application_echoes` table with the relay's answer scripted: only an accepted application leaves a trace — a standing membership, either door refusal, a members-only project and an unreachable project leave none — and best-effort is asserted in both directions (a failed echo write still reports the filed application; a failed echo read still lands the memberships listing). DB-free `unit` lane.
356
356
  ├── scouting_owner_access_and_reach.mjs ← (task 1002290, goal 1000045 C5, ADR 0210): the R14 proof — WHO may read the cross-project recruiting directory, and WHO appears in it. A project owner (an instance-asserted, hub-witnessed `membership_kind='owner'`) reads `GET /scouting` with no rank at all; a builder who is neither owner nor `project.curate` holder gets the unchanged 403; an un-witnessed owner claim is refused; a broken ownership read falls closed onto the permission gate rather than admitting or 500ing. And the recruiting opt-out finally bites: a public, unhidden, active account that opted out is absent from `/scouting` and the hall's `/people` (the same endpoint), returns when it opts back in, and the opt-out grants and denies no permission — an opted-out owner still reads the directory while being absent from their own copy of it. Fake pool filtered by each statement's own WHERE clause, real route, real `requirePermission` over the real government catalog, real HTTP. Mutation-checked three ways. DB-free `unit` lane.
357
357
  ├── scouting_page_gate.mjs ← (task 1002290, goal 1000045 C5, ADR 0210 D3): the OTHER half of R14 — the `/builders/people` SHELL must admit exactly the two audiences `GET /scouting` admits. A project owner holding no permission reaches it; everyone the route refuses gets the byte-identical `?denied=project.curate` redirect; and each of six ways the `alsoAdmits` door can fail (module absent, function absent, throw, reject, non-boolean, prototype-only) fails CLOSED. Also pins that the other four page gates never consult the seam at all, and that exactly ONE gate declares a second door. Its own file because the route proof's ESM imports pull `auth.js` in before a session double could be installed — this one installs it first, the `government_page_gates.mjs` pattern. DB-free `unit` lane.
358
+ ├── terms_acceptance.mjs ← (task 1002913, goal 1000045, ADR 0213): the platform T&C acceptance record. Proves the four properties that make it evidence rather than a claim — the /terms page carries the cross-project consent ADR 0141 §4 deferred to it and it is apex-only; acceptance is written ONLY by the hub's own /sso/authorize (driven over the real router with an attacker-controlled query string, so a forged timestamp/version/activate flag reaches nothing); it is written ONCE (the fake pool interprets the upsert's own column assignments, so COALESCE is executed, not string-matched, and an assignment shape it cannot read throws instead of reading as "unchanged"); and consent is two columns in the shared predicate, so an account the hub witnessed before the terms existed publishes nothing. Plus the projection rule — every read whose row a JS twin judges must select both consent columns, or the twin denies everyone silently. DB-free `unit` lane, asserts its own lane membership.
358
359
  └── public_task_resolver.mjs ← (#604): the public `/public/tasks/:id` projection — title/value_summary exposed only for shipped|confirmed; withheld for in-flight so unshipped/security task names can't leak.
359
360
  ```
360
361
 
@@ -1291,5 +1291,7 @@ is load-bearing: the script throws rather than guess if it is missing, and
1291
1291
  landed since 1.19.425 with no explicit bump. run 33270772607. (task 1002620)
1292
1292
  1.19.427 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1293
1293
  landed since 1.19.426 with no explicit bump. run 33271340372. (task 1002620)
1294
+ 1.19.428 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
1295
+ landed since 1.19.427 with no explicit bump. run 33274315388. (task 1002620)
1294
1296
  ---------------------------------------------------------------------------
1295
1297
  ```
@@ -3,9 +3,10 @@
3
3
  // query cannot drift (ADR 0202 D4; the ADR 0182 `publishableSql` precedent).
4
4
  //
5
5
  // WHAT IT ANSWERS: "may the world see that this account exists at all?" —
6
- // profile_state = 'active' (consent: a hub session has touched the account,
7
- // ADR 0171 D3) AND account_visibility = 'public' (existence: the privacy-spec
8
- // D1 axis). Both floors, ANDed, in one place.
6
+ // CONSENT (profile_state = 'active' AND terms_accepted_at IS NOT NULL: a hub
7
+ // session has touched the account AND that human accepted the platform terms,
8
+ // ADR 0171 D3 + ADR 0213) AND account_visibility = 'public' (existence: the
9
+ // privacy-spec D1 axis). Both floors, ANDed, in one place.
9
10
  //
10
11
  // WHY ITS OWN FILE. ADR 0202 D4 requires connections to consume ONE shared
11
12
  // reader rather than grow a connections-flavoured variant — "adding a second
@@ -52,11 +53,11 @@
52
53
  // (ADR 0192 §6 / ADR 0194 §2).
53
54
  'use strict';
54
55
 
55
- // The JS twin. `account` is any row carrying profile_state + account_visibility;
56
- // anything falsy (absent row, unreadable account) answers false.
56
+ // The JS twin. `account` is any row carrying the consent columns +
57
+ // account_visibility; anything falsy (absent row, unreadable account) answers
58
+ // false. COMPOSED from isAccountActive so the consent axis has one definition.
57
59
  function isAccountVisible(account) {
58
- if (!account) return false;
59
- return account.profile_state === 'active' && account.account_visibility === 'public';
60
+ return isAccountActive(account) && account.account_visibility === 'public';
60
61
  }
61
62
 
62
63
  // The SAME predicate as a SQL fragment, for the reads that filter in the
@@ -70,25 +71,43 @@ function accountVisibleSql(alias) {
70
71
  throw new Error('accountVisibleSql: alias must be a bare SQL identifier');
71
72
  }
72
73
  const q = alias ? `${alias}.` : '';
73
- return `(${q}profile_state = 'active' AND ${q}account_visibility = 'public')`;
74
+ return `(${accountActiveSql(alias)} AND ${q}account_visibility = 'public')`;
74
75
  }
75
76
 
76
- // The CONSENT floor on its own — active, without the existence axis. A private
77
+ // The CONSENT floor on its own — consented, without the existence axis. A private
77
78
  // account is still a real participant in its OWN scope: ADR 0202 D4 §2 says its
78
79
  // connections appear in its own list and in the counterparty's own list, and an
79
80
  // incoming request must name who is asking or it cannot be decided. Those
80
81
  // own-scope reads floor on this; every PUBLIC read floors on accountVisibleSql.
82
+ //
83
+ // CONSENT IS TWO COLUMNS, NOT ONE (ADR 0213, task 1002913). profile_state='active'
84
+ // says the hub witnessed a human at /sso/authorize; terms_accepted_at says that
85
+ // human accepted the platform terms — the artifact ADR 0141 §4 deferred
86
+ // cross-project-aggregation consent to, and which ADR 0171 D3 wrote its own
87
+ // definition around ("the one path where a human is demonstrably at the hub, and
88
+ // where T&C acceptance can later be captured"). Both are stamped by the SAME
89
+ // upsertAccount branch, so they cannot disagree for any account activated after
90
+ // ADR 0213 shipped; requiring both here is what makes that structural rather than
91
+ // a promise, and what keeps a legacy row that predates the terms from publishing
92
+ // on a consent premise that was never true. It re-publishes on its next hub
93
+ // sign-in — the lazy upgrade D3's own backfill rule and P03 already chose.
94
+ //
95
+ // This is the ONE place terms_accepted_at is read. tests/effective_visibility_-
96
+ // predicate.mjs fails any other file that tests it, exactly as for the columns
97
+ // above.
81
98
  function accountActiveSql(alias) {
82
99
  if (alias && !/^[A-Za-z_][A-Za-z0-9_]*$/.test(String(alias))) {
83
100
  throw new Error('accountActiveSql: alias must be a bare SQL identifier');
84
101
  }
85
- return `(${alias ? `${alias}.` : ''}profile_state = 'active')`;
102
+ const q = alias ? `${alias}.` : '';
103
+ return `(${q}profile_state = 'active' AND ${q}terms_accepted_at IS NOT NULL)`;
86
104
  }
87
105
 
88
106
  // The JS twin of accountActiveSql, for an own-scope reader that already holds
89
- // the row (routes/connections.js). Fail-closed, same as isAccountVisible.
107
+ // the row (routes/connections.js). Fail-closed, same as isAccountVisible: a row
108
+ // missing EITHER consent column is not consented.
90
109
  function isAccountActive(account) {
91
- return !!account && account.profile_state === 'active';
110
+ return !!account && account.profile_state === 'active' && account.terms_accepted_at != null;
92
111
  }
93
112
 
94
113
  // -------------------------------------------------------------------------
@@ -23,8 +23,9 @@
23
23
  // (ADR 0171 D4 / ADR 0202 D4).
24
24
  //
25
25
  // WHOSE GRAPH. Node visibility is viewer-relative by design (ADR 0202 title):
26
- // the viewer's OWN connections floor on profile_state='active' alone — a private
27
- // account that accepted you appears in your own-scope list (D4 §2) — while every
26
+ // the viewer's OWN connections floor on the CONSENT axis alone (accountActiveSql:
27
+ // profile_state + terms acceptance, ADR 0213) — a private account that accepted
28
+ // you appears in your own-scope list (D4 §2) — while every
28
29
  // DEEPER hop floors on the full public predicate in account-visibility.js. So a
29
30
  // bridge the viewer may not see yields `null`, not "3rd", and two viewers
30
31
  // legitimately seeing different labels for the same target is the model working.
@@ -128,7 +129,7 @@ async function findAccountByHandle(handle, { pool = defaultPool } = {}) {
128
129
  const norm = String(handle ?? '').trim().toLowerCase();
129
130
  if (!/^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/.test(norm)) return null;
130
131
  const { rows } = await pool.query(
131
- `SELECT github_id, handle, display_name, avatar_url, profile_state, account_visibility
132
+ `SELECT github_id, handle, display_name, avatar_url, profile_state, terms_accepted_at, account_visibility
132
133
  FROM platform_identity_accounts
133
134
  WHERE lower(handle) = $1 OR lower(previous_handle) = $1
134
135
  ORDER BY (lower(handle) = $1) DESC
@@ -0,0 +1,47 @@
1
+ -- platform_identity_019_terms_acceptance.sql — the platform T&C acceptance
2
+ -- record (ADR 0213, task 1002913; the artifact ADR 0141 §4 assumed and ADR 0171
3
+ -- D3 reserved a place for).
4
+ --
5
+ -- WHAT IT IS. ADR 0141 deferred consent for CROSS-PROJECT AGGREGATION to "the
6
+ -- platform T&C" and conditioned the scouting view on it; ADR 0171 D3 then made
7
+ -- consent a fail-closed profile_state whose activation moment is "the one path
8
+ -- where a human is demonstrably at the hub (and where T&C acceptance can later
9
+ -- be captured, task 1002913)". This is that capture. terms_accepted_at is the
10
+ -- moment the hub itself witnessed the human accept; terms_version records WHICH
11
+ -- text they accepted, so a later revision is a distinguishable event rather than
12
+ -- a silent overwrite.
13
+ --
14
+ -- WRITTEN ONLY BY THE HUB'S OWN SIGN-IN. Both columns are set exclusively by
15
+ -- upsertAccount's `activate` branch, whose sole caller is /sso/authorize, where
16
+ -- `activate: true` is a source literal and every other field comes from the
17
+ -- hub's resolved session (ADR 0205 D3). No request-body field can reach them —
18
+ -- an acceptance record a caller could assert is not evidence of anything.
19
+ --
20
+ -- IDEMPOTENT BY CONSTRUCTION. The conflict branch writes
21
+ -- COALESCE(existing, now()), so the FIRST acceptance is the one that stands and
22
+ -- signing in again never rewrites or duplicates it.
23
+ --
24
+ -- NO BACKFILL — DELIBERATE, and the same asymmetry ADR 0171 D3 chose. Existing
25
+ -- accounts get NULL: no platform T&C covering cross-project aggregation has ever
26
+ -- been shown to anyone, so no acceptance exists to record, and inventing a
27
+ -- timestamp would fabricate the very artifact this task exists to make real.
28
+ -- They re-accept on their next hub-session authorize, exactly as D3's own
29
+ -- backfill rule provides ("all existing accounts start provisional and lazily
30
+ -- upgrade on their next hub-session authorize") and P03 ratified (task 1002799:
31
+ -- no backfill activation). The consequence — currently-active accounts are not
32
+ -- publicly visible until their next hub sign-in — is the owner decision carried
33
+ -- on blocker 1000122; grandfathering is one line here if the owner prefers it.
34
+ --
35
+ -- Additive + platform_identity_-namespaced (ADR 0083 §5). Idempotent.
36
+
37
+ BEGIN;
38
+
39
+ ALTER TABLE platform_identity_accounts
40
+ ADD COLUMN IF NOT EXISTS terms_accepted_at timestamptz,
41
+ ADD COLUMN IF NOT EXISTS terms_version text;
42
+
43
+ -- No index: acceptance is read only as part of the per-account effective-
44
+ -- visibility predicate, and accounts are already looked up by github_id via the
45
+ -- UNIQUE constraint from platform_identity_001_tables.sql.
46
+
47
+ COMMIT;
@@ -18,6 +18,7 @@ const api = require('../../src/module-api');
18
18
  // D4 named). Every public read below joins it from here; nothing in this file
19
19
  // re-derives one (see account-visibility.js).
20
20
  const visibility = require('./account-visibility');
21
+ const terms = require('./terms-acceptance');
21
22
 
22
23
  const { pool: defaultPool } = api;
23
24
  const { ACCOUNT_VISIBILITY, accountVisibleSql, rollupVisibleSql, isRollupVisible, scoutingListableSql } = visibility;
@@ -139,10 +140,11 @@ async function ensureAccount({ githubId, githubLogin, displayName, avatarUrl },
139
140
  // github_id). Called at /sso/authorize for the signed-in hub user. Identity only.
140
141
  //
141
142
  // `activate` is the ONE consent upgrade path (ADR 0171 D3): an account becomes
142
- // profile_state='active' iff touched by a hub-session /sso/authorize — the path
143
- // where a human is demonstrably at the hub. Every other caller (device-poll,
144
- // membership check-in) leaves the fail-closed 'provisional' default; activation
145
- // is one-way (a later non-activating upsert never demotes an active account).
143
+ // profile_state='active' iff touched by a hub-session /sso/authorize — where a
144
+ // human is demonstrably at the hub — and the SAME branch stamps the platform T&C
145
+ // acceptance (ADR 0213, idempotent; ./terms-acceptance.js), so no account is
146
+ // active without one. Other callers (device-poll, check-in) leave the fail-closed
147
+ // 'provisional' + a NULL acceptance; activation is one-way (never demotes).
146
148
  //
147
149
  // display_name is source-guarded (the ADR 0156 mirror, P02/task 1002798): once
148
150
  // a builder sets the account name themselves (setAccountProfileFields stamps
@@ -154,18 +156,18 @@ async function ensureAccount({ githubId, githubLogin, displayName, avatarUrl },
154
156
  // rewrites identity; a federated check-in gets `ensureAccount`.
155
157
  async function upsertAccount({ githubId, githubLogin, displayName, avatarUrl, activate = false }, { pool = defaultPool } = {}) {
156
158
  const { rows } = await pool.query(
157
- `INSERT INTO platform_identity_accounts (github_id, github_login, display_name, avatar_url, profile_state)
158
- VALUES ($1, $2, $3, $4, CASE WHEN $5::boolean THEN 'active' ELSE 'provisional' END)
159
+ `INSERT INTO platform_identity_accounts (github_id, github_login, display_name, avatar_url, profile_state${terms.acceptanceInsertColumns()})
160
+ VALUES ($1, $2, $3, $4, CASE WHEN $5::boolean THEN 'active' ELSE 'provisional' END${terms.acceptanceInsertValues('$5', '$6')})
159
161
  ON CONFLICT (github_id) DO UPDATE SET
160
162
  github_login = EXCLUDED.github_login,
161
163
  display_name = CASE WHEN platform_identity_accounts.display_name_source = 'user'
162
164
  THEN platform_identity_accounts.display_name
163
165
  ELSE EXCLUDED.display_name END,
164
166
  avatar_url = EXCLUDED.avatar_url,
165
- profile_state = CASE WHEN $5::boolean THEN 'active' ELSE platform_identity_accounts.profile_state END,
167
+ profile_state = CASE WHEN $5::boolean THEN 'active' ELSE platform_identity_accounts.profile_state END, ${terms.acceptanceConflictSet('$5', '$6')}
166
168
  updated_at = now()
167
169
  RETURNING id, github_id, github_login, display_name, avatar_url, profile_state, handle`,
168
- [githubId, githubLogin, displayName ?? null, safeAvatarUrl(avatarUrl), !!activate],
170
+ [githubId, githubLogin, displayName ?? null, safeAvatarUrl(avatarUrl), !!activate, terms.TERMS_VERSION],
169
171
  );
170
172
  const row = rows[0];
171
173
  // Seed the handle on first sight (spec Decision 3); a no-op once set.
@@ -189,7 +191,7 @@ async function getAccountByLogin(githubLogin, { pool = defaultPool } = {}) {
189
191
  async function getAccount(githubId, { pool = defaultPool } = {}) {
190
192
  const { rows } = await pool.query(
191
193
  `SELECT id, github_id, github_login, display_name, avatar_url, profile_state, hide_stats,
192
- account_visibility, recruiter_discoverable,
194
+ terms_accepted_at, terms_version, account_visibility, recruiter_discoverable,
193
195
  handle, previous_handle, handle_changed_at, bio, links, display_name_source,
194
196
  created_at, updated_at
195
197
  FROM platform_identity_accounts WHERE github_id = $1`,
@@ -861,7 +863,7 @@ async function getRollupForBuilder(githubId, { pool = defaultPool } = {}) {
861
863
  async function getPublicProfileExtras(githubId, { pool = defaultPool } = {}) {
862
864
  if (!isNumericGithubId(githubId)) return null;
863
865
  const { rows } = await pool.query(
864
- `SELECT profile_state, account_visibility, hide_stats
866
+ `SELECT profile_state, terms_accepted_at, account_visibility, hide_stats
865
867
  FROM platform_identity_accounts WHERE github_id = $1`,
866
868
  [githubId],
867
869
  );
@@ -55,9 +55,10 @@ function connectionId(req) {
55
55
  }
56
56
 
57
57
  // The caller's platform account, or a { fail } describing why they cannot take
58
- // part. profile_state = 'active' is the floor for the WHOLE feature (ADR 0202
59
- // D4 §1): a provisional account cannot send, cannot receive, and never appears
60
- // in a list or a frontier. This is about the CALLER, so it may say so plainly.
58
+ // part. The CONSENT axis (isAccountActive — active AND the platform terms
59
+ // accepted, ADR 0213) is the floor for the WHOLE feature (ADR 0202 D4 §1): a
60
+ // provisional account cannot send, cannot receive, and never appears in a list
61
+ // or a frontier. This is about the CALLER, so it may say so plainly.
61
62
  async function actingAccount(builder) {
62
63
  const githubId = builder && builder.github_id;
63
64
  if (!pi.isNumericGithubId(githubId)) {
@@ -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
+ };