@bongos/core 1.19.240 → 1.19.241

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/.bongos-core.json CHANGED
@@ -2,22 +2,22 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.240",
6
- "core_contract": "1.19.240",
7
- "source_commit": "fd0201e6b50f0e354cea9a97c215730c16357ee3",
5
+ "core_version": "1.19.241",
6
+ "core_contract": "1.19.241",
7
+ "source_commit": "3be9d3e72426f99e05a9842dcb5cc9c85cfc75a0",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-18T20:26:47.249Z",
9
+ "built_at": "2026-08-18T20:51:17.253Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 312,
13
13
  "agent_docs_stubbed": 21,
14
- "functional_verbatim": 1325,
14
+ "functional_verbatim": 1327,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1658,
20
- "tree_sha256": "a6ef1676edb20a115c4942ab09c2689af96309917e65340533f47d7cd18c6e7c",
19
+ "file_count": 1660,
20
+ "tree_sha256": "d8e9cb0898ebdea8fca5a8bc8e94fd925e4725473943002abf4cbbc68e4ae963",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -367,7 +367,7 @@
367
367
  {
368
368
  "path": "clients/bongos-client/index.d.ts",
369
369
  "mode": "0000644",
370
- "sha256": "2006b6d2f061c266c79390839bca228df59753db2e3919f49946db07682ee207"
370
+ "sha256": "dee98bb9b25e4cf7b2f4644298caa77b1bb9f7ef29560228333131b107be01dd"
371
371
  },
372
372
  {
373
373
  "path": "clients/bongos-client/index.mjs",
@@ -1367,7 +1367,7 @@
1367
1367
  {
1368
1368
  "path": "docs/adr/0177-goal-membership-kind.md",
1369
1369
  "mode": "0000644",
1370
- "sha256": "0c81340c845250107cd195dadce2740b8324b7777a44011b02b488827193a4de"
1370
+ "sha256": "1bc4a0df32f8267b5fb3b274974fbcd15d680a161de4c502334dff62da494b17"
1371
1371
  },
1372
1372
  {
1373
1373
  "path": "docs/adr/README.md",
@@ -1377,12 +1377,12 @@
1377
1377
  {
1378
1378
  "path": "docs/api-reference.md",
1379
1379
  "mode": "0000644",
1380
- "sha256": "791e8f83d023e77d3faa2e3abf3ec9099f3ffa9236183aad5092ef0792e510cc"
1380
+ "sha256": "c0f20beba35a91ba5f06a1accf3b5df15832d719f14af729824295da60d33e30"
1381
1381
  },
1382
1382
  {
1383
1383
  "path": "docs/api/openapi.json",
1384
1384
  "mode": "0000644",
1385
- "sha256": "901748f25425af336637858b48ec06ad0461d5d3dc2a6beaad8b2a6af0eb6161"
1385
+ "sha256": "50e9b99155cd83e625f1e0289a4e8ab6a2817b2701dc3d62cfb3d902c3acf3bd"
1386
1386
  },
1387
1387
  {
1388
1388
  "path": "docs/architecture.md",
@@ -3362,7 +3362,7 @@
3362
3362
  {
3363
3363
  "path": "modules/hall-ui/public/my-projects.js",
3364
3364
  "mode": "0000644",
3365
- "sha256": "30d22956e21c9ac371d989d8b295631e7bd5df89647457e81a71e0dfffe07cce"
3365
+ "sha256": "9b5604fd1d071bcb61c1ce77a8356fbce8060db09440ba5621646aa18b6f5b5f"
3366
3366
  },
3367
3367
  {
3368
3368
  "path": "modules/hall-ui/public/not-ready.html",
@@ -3904,6 +3904,11 @@
3904
3904
  "mode": "0000644",
3905
3905
  "sha256": "00e2e9feccc56bdb30a5e4f0c6abbe610382fe425222419963a04ae022d3c58a"
3906
3906
  },
3907
+ {
3908
+ "path": "modules/platform-identity/migrations/platform_identity_010_membership_kind.sql",
3909
+ "mode": "0000644",
3910
+ "sha256": "8f03dc84d8fe3864aa5f933512df71259c266e8a16214a926cf89eaf01cc5668"
3911
+ },
3907
3912
  {
3908
3913
  "path": "modules/platform-identity/module.json",
3909
3914
  "mode": "0000644",
@@ -3912,7 +3917,7 @@
3912
3917
  {
3913
3918
  "path": "modules/platform-identity/platform-identity.js",
3914
3919
  "mode": "0000644",
3915
- "sha256": "423afc175f0e1024d39fc20b561e24b423e2d9b6e5c16ddcd8e4a3329bcfa97b"
3920
+ "sha256": "a563fb701e62d665d10baf25761f9341678264fa2c6c73f1efeb9eb20ba6a439"
3916
3921
  },
3917
3922
  {
3918
3923
  "path": "modules/platform-identity/pollers/code-sweep.js",
@@ -3927,7 +3932,7 @@
3927
3932
  {
3928
3933
  "path": "modules/platform-identity/routes/my-projects.js",
3929
3934
  "mode": "0000644",
3930
- "sha256": "7fdb5a107e1ad84214c0d07507a7fd14b9234c9907224fddc0f6a70c850f460a"
3935
+ "sha256": "de5aa85aedf31834009ec0e1a3b4a7ceea2ff60bbb6586a3f231b59eb9549aaf"
3931
3936
  },
3932
3937
  {
3933
3938
  "path": "modules/platform-identity/routes/profile.js",
@@ -3952,12 +3957,12 @@
3952
3957
  {
3953
3958
  "path": "modules/platform-identity/routes/sso.js",
3954
3959
  "mode": "0000644",
3955
- "sha256": "72796558274b45faec2b1a63e332100061a6ab0c690dadc42a54f1f39cb0c68e"
3960
+ "sha256": "e4cd3084c44b6ff3e3fdad141e530b098b330a25d609fd08f5ae2290fd251db0"
3956
3961
  },
3957
3962
  {
3958
3963
  "path": "modules/platform-identity/tests/platform-identity.mjs",
3959
3964
  "mode": "0000644",
3960
- "sha256": "514adcaae952190d418888ffb778d541a632e3040664ce53f7a55ba9a297292b"
3965
+ "sha256": "6f442cd4ae9ebb770e3537ae82da30e555f7e6ce3a293a60fbd154eff6c1eaf1"
3961
3966
  },
3962
3967
  {
3963
3968
  "path": "modules/platform-identity/ui/.gitkeep",
@@ -4342,12 +4347,12 @@
4342
4347
  {
4343
4348
  "path": "package-lock.json",
4344
4349
  "mode": "0000644",
4345
- "sha256": "5016dfd9f9b3ee1b5141dc1d0a24a478637b449e39b8e82fa42dc8f5084356f5"
4350
+ "sha256": "8f961bb896bdf312ad73752daa2342ce9ccf7db02b21ac9d2480694db28857d8"
4346
4351
  },
4347
4352
  {
4348
4353
  "path": "package.json",
4349
4354
  "mode": "0000644",
4350
- "sha256": "9e996de99c2422683685f66dc08b2b8a8663d4e5da4e71f00059c332acfa9b37"
4355
+ "sha256": "ac7bd7958b32f4eee03a29fab16260eb489d49c0e40907691bdd9c3e447d6a5d"
4351
4356
  },
4352
4357
  {
4353
4358
  "path": "public-docs/index.html",
@@ -4972,7 +4977,7 @@
4972
4977
  {
4973
4978
  "path": "scripts/gds/fitness.js",
4974
4979
  "mode": "0000644",
4975
- "sha256": "f5059d035746d8ea967e127e8421213efede43c9ae50c50403fc8772c487db76"
4980
+ "sha256": "add1ee5439ef3ddff171e7eb9a695c63a41b8323c5e8cc6c415b620b812bdfa8"
4976
4981
  },
4977
4982
  {
4978
4983
  "path": "scripts/gds/gate-review.js",
@@ -5722,7 +5727,7 @@
5722
5727
  {
5723
5728
  "path": "src/bongos/auth.js",
5724
5729
  "mode": "0000644",
5725
- "sha256": "8fd6f6ce798ebcde76cd2ac0ccf7129a60555506ce3d2fed818deb1eb331d42b"
5730
+ "sha256": "803d4cc68847dda227c8581be493c2b4e799d1bf81acd40794efbf7b2b625a24"
5726
5731
  },
5727
5732
  {
5728
5733
  "path": "src/bongos/cascade-dispatch.js",
@@ -5962,7 +5967,7 @@
5962
5967
  {
5963
5968
  "path": "src/module-api.js",
5964
5969
  "mode": "0000644",
5965
- "sha256": "4c133ea04355ccf4ac6b4f1530d612421ca5e5a99393a4ee102cee4949377611"
5970
+ "sha256": "d2dd5e30bb7f44cd8a37cbf559bbcdf76aec973043f3db5deec8988b62a59eb6"
5966
5971
  },
5967
5972
  {
5968
5973
  "path": "src/module-loader/catalog.js",
@@ -7217,7 +7222,7 @@
7217
7222
  {
7218
7223
  "path": "tests/idp_federation.mjs",
7219
7224
  "mode": "0000644",
7220
- "sha256": "09a9ae4a726cec8d0e31bc50dc08db102745c6451fb64f105c2be1b623062986"
7225
+ "sha256": "5130936408829e4e45742eda0124aac9df50865ece1812274354384f9da498fd"
7221
7226
  },
7222
7227
  {
7223
7228
  "path": "tests/infra_exec_paths.mjs",
@@ -7339,6 +7344,11 @@
7339
7344
  "mode": "0000644",
7340
7345
  "sha256": "9a1813d6036886ea07d1563e71a466e66c28554b75b22605464c795fd9fa2ebb"
7341
7346
  },
7347
+ {
7348
+ "path": "tests/membership_kind_lockstep.mjs",
7349
+ "mode": "0000644",
7350
+ "sha256": "7ebca67bf07f723f47a861eb530a0feee25967fe3c0f4a9e7d76703351dc8d84"
7351
+ },
7342
7352
  {
7343
7353
  "path": "tests/memory_isolation.mjs",
7344
7354
  "mode": "0000644",
@@ -294,7 +294,7 @@ export interface PostSsoActivityRollupRequest { client_id: string; client_secret
294
294
  export interface PostSsoActivityRollupResponse { ok: boolean }
295
295
  export interface PostSsoDevicePollRequest { device_code: string }
296
296
  export interface PostSsoDeviceStartRequest { origin: string }
297
- export interface PostSsoMembershipCheckInRequest { client_id: string; client_secret: string; github_id: StringifiableId; github_login: string; display_name?: string; avatar_url?: string; role?: string }
297
+ export interface PostSsoMembershipCheckInRequest { client_id: string; client_secret: string; github_id: StringifiableId; github_login: string; display_name?: string; avatar_url?: string; membership_kind?: string; role?: string }
298
298
  export interface PostSsoMembershipCheckInResponse { ok: boolean }
299
299
  export interface PostSsoTokenRequest { client_id: string; client_secret: string; code: string; redirect_uri: string }
300
300
  export interface PostSsoTokenResponse { github_id: unknown; github_login: unknown; display_name: unknown; avatar_url: unknown }
@@ -2,7 +2,7 @@
2
2
 
3
3
  - **Status:** Accepted
4
4
  - **Date:** 2026-08-18
5
- - **Task:** [#1003013](https://cloudbongos.com/builders#/task/1003013) (goal 1000068 — government terminology; unlinked from the goal's criteria on 2026-08-18 because it is a live-column migration, not a naming pass)
5
+ - **Tasks:** [#1003013](https://cloudbongos.com/builders#/task/1003013) (goal 1000068 — government terminology; unlinked from the goal's criteria on 2026-08-18 because it is a live-column migration, not a naming pass)
6
6
  - **Extends:** [ADR 0174](<redacted>.md) — the reserved-word rule, applied to the fourth use it did not cover
7
7
  - **Related:** ADR 0016 (authority resolved server-side, per request, uncached), 0086 (goals + scope walls), 0154 (goal owner/manager authors criteria), 0137/0161 (land → release → deploy are three gates)
8
8
 
@@ -83,6 +83,62 @@ regression available — a membership query reaching for the old column — with
83
83
  only exemption is a line that CITES the rule (ADR 0174 / ADR 0177 / task 1003013); naming a ban has to
84
84
  be legal or the code cannot explain itself. R07 stays scoped to the government surface, unchanged.
85
85
 
86
+ ## Amendment, same day — the FIFTH use: project membership (task [#1003090](https://cloudbongos.com/builders#/task/1003090))
87
+
88
+ The sweep for the fourth use turned up a fifth, and it is worse: the hub's
89
+ `<redacted>.role` (`'owner' | 'builder' | 'visitor' |
90
+ 'pending'`, ADR 0141 §3) collides with the reserved word **twice** — the column is named
91
+ `role`, and one of its VALUES is `'builder'`, a craft name pressed into meaning "a
92
+ non-owner member of this project". The migration's own comment ("a COARSE role ONLY …
93
+ the role is owner|builder|visitor, NEVER a rank") is the same qualifier tell ADR 0174
94
+ diagnosed in "rank-role".
95
+
96
+ Decisions 1, 2 and 4 above carry over unchanged: the column becomes `membership_kind`,
97
+ the value becomes `'member'` (`owner` / `visitor` / `pending` untouched), the migration
98
+ is expand-then-contract with a lockstep trigger (`<redacted>`), and the
99
+ fitness wall's file list grows rather than the rule widening. **Decision 3 is the one
100
+ that does NOT carry over.**
101
+
102
+ **Decision 3′ — this rename keeps a compatibility window, and the sender writes BOTH
103
+ spellings.** This is a deliberate, named divergence from decision 3 above *and* from the
104
+ sequence task #1003090 itself prescribed ("the API field renamed with no alias"); the
105
+ task wrote that sequence before anyone had looked at who calls the endpoint. Decision 3
106
+ holds wherever every caller ships in one release, which is why it stays the default — and
107
+ it does not hold here. The goal-membership rename could ship without an alias because every
108
+ caller lived in the same release. This table's writer is `POST
109
+ /sso/membership/check-in` — a **server-to-server call from other instances**, each on
110
+ its own upgrade schedule (ADR 0137/0161: land, release and deploy are three separate
111
+ gates *per instance*). So:
112
+
113
+ - the **hub accepts both** fields, `membership_kind` winning when both arrive, and maps
114
+ a legacy `'builder'` to `'member'` on the way in;
115
+ - the **sender sends both** (`src/bongos/auth.js`). This is the part worth writing down:
116
+ sending only the new field would make an un-upgraded hub silently default every
117
+ membership to non-owner — *dropping the owner flag* — while sending only the legacy
118
+ field would make an upgraded hub translate forever. Sending both is what makes the two
119
+ ends independent;
120
+ - the trigger therefore maps **values as well as columns**, in both directions, which
121
+ `core_220`'s did not have to.
122
+
123
+ **And a trap both triggers share, found by the grader on #1003090.** The INSERT branch has
124
+ to tell "the caller wrote the new column" from "the column's DEFAULT filled it" — and it
125
+ cannot, if the new column has a DEFAULT: **Postgres materializes a column default into
126
+ `NEW` before any BEFORE ROW trigger fires.** With a default in place, an old-release
127
+ `INSERT … (client_id, github_id, role) VALUES (…, 'pending')` — `inviteToProject`'s exact
128
+ statement — took the "new code wrote it" branch and overwrote `role`, turning a pending
129
+ INVITE into an active membership. So the new column stays **DEFAULT-less** while the legacy
130
+ one is still written (NULL then means exactly "not written"), and `DEFAULT` + `NOT NULL`
131
+ arrive together in step 2. `core_220` shipped with the defective form and is corrected
132
+ under task [#1003093](https://cloudbongos.com/builders#/task/1003093) — where the same
133
+ statement demotes a goal's first lead to `member` on a fresh install; the invariant is
134
+ pinned statically by `tests/membership_kind_lockstep.mjs`, since the trigger only
135
+ misbehaves against a live Postgres and CI has none.
136
+
137
+ The window's removal is a filed task, and its condition is explicit: every federated
138
+ instance past the window, not merely "one release later". A time-boxed shim still ships
139
+ with the test of its absence (ADR 0174's Lineage lesson) — that test lands with the
140
+ removal, because until then the legacy field is load-bearing, not vestigial.
141
+
86
142
  ## Consequences
87
143
 
88
144
  - The word `role` no longer names a goal membership in the API, the hall, or the docs, and in the DB it
@@ -93,4 +149,5 @@ be legal or the code cannot explain itself. R07 stays scoped to the government s
93
149
  the only new moving part on the write path, and it is the thing step 2 removes.
94
150
  - `docs/canonical-permissions.md` (the goal-management wall) now names `membership_kind`; the
95
151
  `authorizeGoalManagement` behaviour is untouched — this ADR changes no authority, floor, or grant.
96
- - The fifth use (`project_memberships.role`) is now catalogued and claimable, with this as its pattern.
152
+ - The fifth use (`project_memberships.role`) shipped the same day under the amendment above — same shape, plus a cross-version window because its writer is another instance.
153
+ - **No sixth use is known.** The remaining `role` spellings in the repo are the three legitimate ones ADR 0174 named: the crafts, `builders.system_role` (machine identity), and Discord's own noun — plus HTML's ARIA attribute, which every wall strips before matching.
@@ -11117,7 +11117,7 @@
11117
11117
  "sso"
11118
11118
  ],
11119
11119
  "summary": "POST /sso/membership/check-in",
11120
- "description": "POST /sso/membership/check-in — a federated instance reports, on every sign-in, that a builder builds on its project (ADR 0141 §3). Server-to-server, authed by client_id + client_secret (same constant-time check as /sso/token). Upserts the account (so a CLI-only builder still appears in My Projects) + the membership with a COARSE role. The client_id is taken from the AUTHENTICATED client — the caller cannot report membership for a project it doesn't hold the secret for. rank: public — authenticated by client_id + client_secret, not a builder rank.\n\n**Rank:** `public` — No authentication — any caller.",
11120
+ "description": "POST /sso/membership/check-in — a federated instance reports, on every sign-in, that a builder builds on its project (ADR 0141 §3). Server-to-server, authed by client_id + client_secret (same constant-time check as /sso/token). Upserts the account (so a CLI-only builder still appears in My Projects) + the membership with a COARSE membership_kind. The client_id is taken from the AUTHENTICATED client — the caller cannot report membership for a project it doesn't hold the secret for. THE FIELD IS `membership_kind`; `role` IS ACCEPTED FOR THE WINDOW (task 1003090, ADR 0177 §\"the fifth use\"). ADR 0174 reserves `role` for the three crafts, and the old value 'builder' was a craft name meaning \"non-owner member\" — so both the field and that value are renamed. Unlike the goal-membership rename, this is a CROSS-VERSION wire contract: the callers are other instances on their own upgrade schedule, so an un-upgraded instance still sends `role: 'builder'` and the hub must keep understanding it. `membership_kind` WINS when both arrive (a sender in the window sends both deliberately). rank: public — authenticated by client_id + client_secret, not a builder rank.\n\n**Rank:** `public` — No authentication — any caller.",
11121
11121
  "x-rank": "public",
11122
11122
  "x-source": "modules/platform-identity/routes/sso.js",
11123
11123
  "requestBody": {
@@ -17333,6 +17333,10 @@
17333
17333
  "type": "string",
17334
17334
  "maxLength": 2048
17335
17335
  },
17336
+ "membership_kind": {
17337
+ "type": "string",
17338
+ "maxLength": 16
17339
+ },
17336
17340
  "role": {
17337
17341
  "type": "string",
17338
17342
  "maxLength": 16
@@ -474,7 +474,7 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
474
474
  | GET | `/api/bongos/sso/authorize` | `public` | — | GET /sso/authorize?client_id=&redirect_uri=&state= — the browser authorize leg. |
475
475
  | POST | `/api/bongos/sso/device/poll` | `public` | `device_code` | POST /sso/device/poll — poll the hub device flow. |
476
476
  | POST | `/api/bongos/sso/device/start` | `public` | `origin` | POST /sso/device/start — begin CLI federation. |
477
- | POST | `/api/bongos/sso/membership/check-in` | `public` | `client_id`, `client_secret`, `github_id`, `github_login`, `display_name`, `avatar_url`, `role` | POST /sso/membership/check-in — a federated instance reports, on every sign-in, that a builder builds on its project (ADR 0141 §3). |
477
+ | POST | `/api/bongos/sso/membership/check-in` | `public` | `client_id`, `client_secret`, `github_id`, `github_login`, `display_name`, `avatar_url`, `membership_kind`, `role` | POST /sso/membership/check-in — a federated instance reports, on every sign-in, that a builder builds on its project (ADR 0141 §3). |
478
478
  | GET | `/api/bongos/sso/pubkey` | `public` | — | GET /sso/pubkey — the hub's Ed25519 assertion-verification key (public). |
479
479
  | POST | `/api/bongos/sso/token` | `public` | `client_id`, `client_secret`, `code`, `redirect_uri` | POST /sso/token — server-to-server code redemption. |
480
480
 
@@ -40,7 +40,7 @@
40
40
  meta.appendChild(o);
41
41
  }
42
42
  row.appendChild(meta);
43
- if (m.role === 'owner') {
43
+ if (m.membership_kind === 'owner') {
44
44
  var pill = el('span', 'owner-pill');
45
45
  pill.textContent = 'owner';
46
46
  row.appendChild(pill);
@@ -0,0 +1,106 @@
1
+ -- platform_identity_010_membership_kind.sql — the FIFTH "role" leaves the schema
2
+ -- (task 1003090; ADR 0174 reserves the word for the three crafts, ADR 0177 §"the
3
+ -- fifth use" records this two-step).
4
+ --
5
+ -- `platform_identity_project_memberships.role` holds the hub's COARSE project
6
+ -- membership — and it collided with the reserved word TWICE over:
7
+ -- • the COLUMN is named `role`, which ADR 0174 reserves for Artist / Ideator /
8
+ -- Builder;
9
+ -- • one of its VALUES is literally 'builder', used to mean "a non-owner member
10
+ -- of this project" — a craft name doing membership work.
11
+ -- The column becomes `membership_kind` and that value becomes 'member'. 'owner',
12
+ -- 'visitor' and 'pending' are unchanged.
13
+ --
14
+ -- ── EXPAND-THEN-CONTRACT, AND WHY THE WINDOW IS LONGER HERE ─────────────────
15
+ -- Same reasoning as core_220 (ADR 0177): `bongos upgrade` auto-rollback reverts the
16
+ -- code pin and NOT the DB, so a bare RENAME COLUMN plus a rolled-back deploy
17
+ -- strands the previous release on a missing column. But this table has a second,
18
+ -- stronger reason: it is written by POST /sso/membership/check-in, a
19
+ -- SERVER-TO-SERVER call from OTHER INSTANCES (ADR 0141 §3) that upgrade on their
20
+ -- own schedule. The legacy `role` column and the legacy request field therefore
21
+ -- outlive one release by design — until every federated instance is past the
22
+ -- window. The removal task carries that condition.
23
+ --
24
+ -- Idempotent, additive, platform_identity_-namespaced (ADR 0083 §5). Forward-safe:
25
+ -- old code writing `role` still lands a complete row (the trigger below), and no
26
+ -- NOT NULL is added to the new column while the old one is still written.
27
+
28
+ BEGIN;
29
+
30
+ ALTER TABLE platform_identity_project_memberships
31
+ ADD COLUMN IF NOT EXISTS membership_kind text;
32
+
33
+ -- Backfill WITH the value rename: 'builder' → 'member', everything else verbatim.
34
+ UPDATE platform_identity_project_memberships
35
+ SET membership_kind = CASE WHEN role = 'builder' THEN 'member' ELSE role END
36
+ WHERE membership_kind IS NULL;
37
+
38
+ -- NO DEFAULT ON THE NEW COLUMN WHILE THE LEGACY ONE IS STILL WRITTEN. This is
39
+ -- load-bearing, and it is subtle: Postgres materializes a column DEFAULT into NEW
40
+ -- *before* any BEFORE ROW trigger fires, so a default here would make
41
+ -- `NEW.membership_kind IS NULL`never true and the trigger could no longer tell
42
+ -- "the caller wrote the new column" from "the default filled it". An old-release
43
+ -- INSERT naming only `role` (inviteToProject's `role = 'pending'`, or an owner
44
+ -- check-in) would then be silently rewritten to the default's mapping — turning a
45
+ -- pending INVITE into an active membership. Leaving the column DEFAULT-less makes
46
+ -- NULL mean exactly "not written", which is what the trigger branches on. The
47
+ -- default (and NOT NULL) arrive in step 2, when `role` is dropped and nothing
48
+ -- writes the old name any more.
49
+
50
+ DO $$
51
+ BEGIN
52
+ IF NOT EXISTS (
53
+ SELECT 1 FROM pg_constraint
54
+ WHERE conrelid = 'platform_identity_project_memberships'::regclass
55
+ AND conname = 'platform_identity_project_memberships_membership_kind_check'
56
+ ) THEN
57
+ ALTER TABLE platform_identity_project_memberships
58
+ ADD CONSTRAINT platform_identity_project_memberships_membership_kind_check
59
+ CHECK (membership_kind IN ('owner', 'member', 'visitor', 'pending'));
60
+ END IF;
61
+ END $$;
62
+
63
+ -- The lockstep, with the value MAPPING in both directions — the difference from
64
+ -- core_220's trigger, which only had to copy a value. Direction is decided by
65
+ -- which side changed, so a check-in from an un-upgraded instance (writing `role`
66
+ -- = 'builder') lands membership_kind = 'member', and new code writing
67
+ -- membership_kind = 'member' keeps `role` = 'builder' readable by an old release.
68
+ CREATE OR REPLACE FUNCTION platform_identity_membership_kind_lockstep() RETURNS trigger AS $$
69
+ DECLARE
70
+ kind_from_role text;
71
+ role_from_kind text;
72
+ BEGIN
73
+ kind_from_role := CASE WHEN NEW.role = 'builder' THEN 'member' ELSE NEW.role END;
74
+ role_from_kind := CASE WHEN NEW.membership_kind = 'member' THEN 'builder' ELSE NEW.membership_kind END;
75
+ IF TG_OP = 'INSERT' THEN
76
+ -- NULL means the caller did not write the new column (see the no-DEFAULT note
77
+ -- above): that is an old-release INSERT, so `role` is the source of truth and
78
+ -- the new column follows it. Otherwise the caller is new code and `role`
79
+ -- follows, mapped, so an older release can still read the row.
80
+ IF NEW.membership_kind IS NULL THEN
81
+ NEW.membership_kind := COALESCE(kind_from_role, 'member');
82
+ NEW.role := COALESCE(NEW.role, 'builder');
83
+ ELSE
84
+ NEW.role := role_from_kind;
85
+ END IF;
86
+ ELSE
87
+ IF NEW.membership_kind IS DISTINCT FROM OLD.membership_kind THEN
88
+ NEW.role := role_from_kind;
89
+ ELSIF NEW.role IS DISTINCT FROM OLD.role THEN
90
+ NEW.membership_kind := kind_from_role;
91
+ END IF;
92
+ END IF;
93
+ RETURN NEW;
94
+ END;
95
+ $$ LANGUAGE plpgsql;
96
+
97
+ DROP TRIGGER IF EXISTS platform_identity_membership_kind_lockstep
98
+ ON platform_identity_project_memberships;
99
+ CREATE TRIGGER platform_identity_membership_kind_lockstep
100
+ BEFORE INSERT OR UPDATE ON platform_identity_project_memberships
101
+ FOR EACH ROW EXECUTE FUNCTION platform_identity_membership_kind_lockstep();
102
+
103
+ INSERT INTO schema_migrations (version) VALUES ('platform_identity_010_membership_kind')
104
+ ON CONFLICT DO NOTHING;
105
+
106
+ COMMIT;
@@ -307,31 +307,53 @@ async function setProjectFeatured(id, featured, { pool = defaultPool } = {}) {
307
307
  // Project memberships (the cross-project directory — ADR 0141 §3)
308
308
  // -------------------------------------------------------------------------
309
309
 
310
- const MEMBERSHIP_ROLES = ['owner', 'builder', 'visitor', 'pending'];
310
+ // THE COARSE PROJECT MEMBERSHIP — 'membership_kind', never 'role' (task 1003090,
311
+ // ADR 0177 §"the fifth use"; ADR 0174 reserves that word for the three crafts). The
312
+ // old value 'builder' is now 'member': it meant "a non-owner member of this
313
+ // project", which is membership work done by a craft name.
314
+ //
315
+ // THE LEGACY COLUMN AND FIELD SURVIVE THIS RELEASE ON PURPOSE. This table is
316
+ // written by POST /sso/membership/check-in — a server-to-server call from OTHER
317
+ // INSTANCES (ADR 0141 §3) that upgrade on their own schedule — so both spellings
318
+ // are read on the way in and platform_identity_010's trigger keeps both columns
319
+ // (and both value vocabularies) in lockstep. Write `membership_kind` here; the
320
+ // removal task drops the pair once every federated instance is past the window.
321
+ const MEMBERSHIP_KINDS = ['owner', 'member', 'visitor', 'pending'];
322
+
323
+ // Map the legacy wire/column vocabulary onto the current one and back: the craft
324
+ // name 'builder' ⇄ 'member'; every other value was already right. Exported so the
325
+ // route, the sender and the migration's trigger all agree on one mapping. (task
326
+ // 1003090 — the legacy spelling is named here by necessity: this IS the translator.)
327
+ function membershipKindFromLegacy(legacyValue) {
328
+ return legacyValue === 'builder' ? 'member' : legacyValue;
329
+ }
330
+ function legacyRoleFromKind(kind) {
331
+ return kind === 'member' ? 'builder' : kind;
332
+ }
311
333
 
312
334
  // Upsert a builder's membership in a project (keyed by the reporting client + the
313
- // builder's github_id). COARSE role only — never a rank. Refreshes last_active on
314
- // every check-in. Returns the row.
315
- async function upsertMembership({ clientId, githubId, role = 'builder' }, { pool = defaultPool } = {}) {
316
- const r = MEMBERSHIP_ROLES.includes(role) ? role : 'builder';
335
+ // builder's github_id). COARSE membership only — never a rank. Refreshes
336
+ // last_active on every check-in. Returns the row.
337
+ async function upsertMembership({ clientId, githubId, membershipKind = 'member' }, { pool = defaultPool } = {}) {
338
+ const k = MEMBERSHIP_KINDS.includes(membershipKind) ? membershipKind : 'member';
317
339
  const { rows } = await pool.query(
318
- `INSERT INTO platform_identity_project_memberships (client_id, github_id, role)
340
+ `INSERT INTO platform_identity_project_memberships (client_id, github_id, membership_kind)
319
341
  VALUES ($1, $2, $3)
320
342
  ON CONFLICT (client_id, github_id) DO UPDATE SET
321
- role = EXCLUDED.role,
343
+ membership_kind = EXCLUDED.membership_kind,
322
344
  last_active = now()
323
- RETURNING id, client_id, github_id, role, joined_at, last_active`,
324
- [clientId, githubId, r],
345
+ RETURNING id, client_id, github_id, membership_kind, joined_at, last_active`,
346
+ [clientId, githubId, k],
325
347
  );
326
348
  return rows[0];
327
349
  }
328
350
 
329
- // One membership row for (project, builder), any role — or null. Backs the
351
+ // One membership row for (project, builder), any membership_kind — or null. Backs the
330
352
  // activity-rollup membership requirement (ADR 0171 D7): a client may only report
331
353
  // activity for a builder who actually has a membership on that project.
332
354
  async function getMembership(clientId, githubId, { pool = defaultPool } = {}) {
333
355
  const { rows } = await pool.query(
334
- `SELECT id, client_id, github_id, role, joined_at, last_active
356
+ `SELECT id, client_id, github_id, membership_kind, joined_at, last_active
335
357
  FROM platform_identity_project_memberships
336
358
  WHERE client_id = $1 AND github_id = $2`,
337
359
  [clientId, githubId],
@@ -343,11 +365,11 @@ async function getMembership(clientId, githubId, { pool = defaultPool } = {}) {
343
365
  // display fields (name/origin from sso_clients). Powers "My Projects" (task #1002112).
344
366
  async function listMembershipsForAccount(githubId, { pool = defaultPool } = {}) {
345
367
  const { rows } = await pool.query(
346
- `SELECT m.client_id, m.role, m.joined_at, m.last_active,
368
+ `SELECT m.client_id, m.membership_kind, m.joined_at, m.last_active,
347
369
  c.name AS project_name, c.origin AS project_origin, c.owner_github_id
348
370
  FROM platform_identity_project_memberships m
349
371
  LEFT JOIN platform_identity_sso_clients c ON c.client_id = m.client_id
350
- WHERE m.github_id = $1 AND m.role <> 'pending'
372
+ WHERE m.github_id = $1 AND m.membership_kind <> 'pending'
351
373
  ORDER BY m.last_active DESC`,
352
374
  [githubId],
353
375
  );
@@ -364,10 +386,10 @@ async function listMembershipsForAccount(githubId, { pool = defaultPool } = {})
364
386
  // created, or { invited:false, alreadyMember:true } when a membership already existed.
365
387
  async function inviteToProject({ clientId, githubId }, { pool = defaultPool } = {}) {
366
388
  const { rows } = await pool.query(
367
- `INSERT INTO platform_identity_project_memberships (client_id, github_id, role)
389
+ `INSERT INTO platform_identity_project_memberships (client_id, github_id, membership_kind)
368
390
  VALUES ($1, $2, 'pending')
369
391
  ON CONFLICT (client_id, github_id) DO NOTHING
370
- RETURNING id, client_id, github_id, role`,
392
+ RETURNING id, client_id, github_id, membership_kind`,
371
393
  [clientId, githubId],
372
394
  );
373
395
  if (rows[0]) return { invited: true, row: rows[0] };
@@ -381,22 +403,22 @@ async function listPendingInvitesForAccount(githubId, { pool = defaultPool } = {
381
403
  c.name AS project_name, c.origin AS project_origin
382
404
  FROM platform_identity_project_memberships m
383
405
  LEFT JOIN platform_identity_sso_clients c ON c.client_id = m.client_id
384
- WHERE m.github_id = $1 AND m.role = 'pending'
406
+ WHERE m.github_id = $1 AND m.membership_kind = 'pending'
385
407
  ORDER BY m.joined_at DESC`,
386
408
  [githubId],
387
409
  );
388
410
  return rows;
389
411
  }
390
412
 
391
- // Resolve a pending invite for (client, builder): accept → flip to 'builder' (the
413
+ // Resolve a pending invite for (client, builder): accept → flip to 'member' (the
392
414
  // membership becomes real; the builder then signs into the project, whose own gate
393
415
  // still applies + whose check-in keeps it fresh); decline → delete the pending row.
394
416
  // Only ever touches a 'pending' row, so it can't downgrade or remove a real member.
395
417
  async function resolveInvite({ clientId, githubId, accept }, { pool = defaultPool } = {}) {
396
418
  if (accept) {
397
419
  const { rows } = await pool.query(
398
- `UPDATE platform_identity_project_memberships SET role = 'builder', last_active = now()
399
- WHERE client_id = $1 AND github_id = $2 AND role = 'pending'
420
+ `UPDATE platform_identity_project_memberships SET membership_kind = 'member', last_active = now()
421
+ WHERE client_id = $1 AND github_id = $2 AND membership_kind = 'pending'
400
422
  RETURNING id`,
401
423
  [clientId, githubId],
402
424
  );
@@ -404,7 +426,7 @@ async function resolveInvite({ clientId, githubId, accept }, { pool = defaultPoo
404
426
  }
405
427
  const { rowCount } = await pool.query(
406
428
  `DELETE FROM platform_identity_project_memberships
407
- WHERE client_id = $1 AND github_id = $2 AND role = 'pending'`,
429
+ WHERE client_id = $1 AND github_id = $2 AND membership_kind = 'pending'`,
408
430
  [clientId, githubId],
409
431
  );
410
432
  return { resolved: rowCount > 0, accepted: false };
@@ -1072,7 +1094,9 @@ module.exports = {
1072
1094
  listAllProjects,
1073
1095
  setProjectFeatured,
1074
1096
  // memberships
1075
- MEMBERSHIP_ROLES,
1097
+ MEMBERSHIP_KINDS,
1098
+ membershipKindFromLegacy,
1099
+ legacyRoleFromKind,
1076
1100
  upsertMembership,
1077
1101
  getMembership,
1078
1102
  listMembershipsForAccount,
@@ -26,11 +26,11 @@ module.exports = function myProjectsRoutes() {
26
26
  const owned = [];
27
27
  const building = [];
28
28
  for (const m of rows) {
29
- (m.role === 'owner' ? owned : building).push({
29
+ (m.membership_kind === 'owner' ? owned : building).push({
30
30
  client_id: m.client_id,
31
31
  name: m.project_name || m.project_origin || m.client_id,
32
32
  origin: m.project_origin || null,
33
- role: m.role,
33
+ membership_kind: m.membership_kind,
34
34
  joined_at: m.joined_at,
35
35
  last_active: m.last_active,
36
36
  });
@@ -358,8 +358,18 @@ module.exports = function ssoRoutes() {
358
358
  // that a builder builds on its project (ADR 0141 §3). Server-to-server, authed by
359
359
  // client_id + client_secret (same constant-time check as /sso/token). Upserts the
360
360
  // account (so a CLI-only builder still appears in My Projects) + the membership
361
- // with a COARSE role. The client_id is taken from the AUTHENTICATED client — the
362
- // caller cannot report membership for a project it doesn't hold the secret for.
361
+ // with a COARSE membership_kind. The client_id is taken from the AUTHENTICATED
362
+ // client — the caller cannot report membership for a project it doesn't hold the
363
+ // secret for.
364
+ //
365
+ // THE FIELD IS `membership_kind`; `role` IS ACCEPTED FOR THE WINDOW (task 1003090,
366
+ // ADR 0177 §"the fifth use"). ADR 0174 reserves `role` for the three crafts, and
367
+ // the old value 'builder' was a craft name meaning "non-owner member" — so both
368
+ // the field and that value are renamed. Unlike the goal-membership rename, this is
369
+ // a CROSS-VERSION wire contract: the callers are other instances on their own
370
+ // upgrade schedule, so an un-upgraded instance still sends `role: 'builder'` and
371
+ // the hub must keep understanding it. `membership_kind` WINS when both arrive
372
+ // (a sender in the window sends both deliberately).
363
373
  //
364
374
  // rank: public — authenticated by client_id + client_secret, not a builder rank.
365
375
  router.post('/sso/membership/check-in', async (req, res) => {
@@ -370,9 +380,14 @@ module.exports = function ssoRoutes() {
370
380
  github_login: { required: true, type: 'string', maxLength: 200 },
371
381
  display_name: { type: 'string', maxLength: 400 },
372
382
  avatar_url: { type: 'string', maxLength: 2048 },
383
+ membership_kind: { type: 'string', maxLength: 16 },
384
+ // LEGACY, accepted for the cross-version window (task 1003090).
373
385
  role: { type: 'string', maxLength: 16 },
374
386
  })) return;
375
- const { client_id, client_secret, github_id, github_login, display_name, avatar_url, role } = req.body || {};
387
+ const { client_id, client_secret, github_id, github_login, display_name, avatar_url, membership_kind, role } = req.body || {};
388
+ const membershipKind = membership_kind
389
+ || (role ? pi.membershipKindFromLegacy(role) : null)
390
+ || 'member';
376
391
  try {
377
392
  const client = await pi.getClient(client_id, { pool });
378
393
  if (!client || client.status !== 'active' || !pi.clientSecretValid(client, client_secret)) {
@@ -381,7 +396,7 @@ module.exports = function ssoRoutes() {
381
396
  // Upsert the account first so a CLI-only builder is known to the hub, then the
382
397
  // membership — keyed to the AUTHENTICATED client_id, never a caller-named one.
383
398
  await pi.upsertAccount({ githubId: github_id, githubLogin: github_login, displayName: display_name, avatarUrl: avatar_url }, { pool });
384
- await pi.upsertMembership({ clientId: client_id, githubId: github_id, role: role || 'builder' }, { pool });
399
+ await pi.upsertMembership({ clientId: client_id, githubId: github_id, membershipKind }, { pool });
385
400
  res.json({ ok: true });
386
401
  } catch (err) {
387
402
  console.error('[platform-identity] /sso/membership/check-in', err);
@@ -424,7 +439,7 @@ module.exports = function ssoRoutes() {
424
439
  return res.fail('invalid_github_id', 400);
425
440
  }
426
441
  const membership = await pi.getMembership(b.client_id, b.github_id, { pool });
427
- if (!membership || membership.role === 'pending') {
442
+ if (!membership || membership.membership_kind === 'pending') {
428
443
  return res.fail('membership_required', 409);
429
444
  }
430
445
  await pi.upsertActivity({
@@ -262,9 +262,9 @@ await ta('registerClient auto-catalogues the project (2nd query) when an origin
262
262
 
263
263
  console.log('\nproject memberships (§3 check-in directory):');
264
264
 
265
- await ta('upsertMembership upserts on (client_id, github_id) with a coarse role', async () => {
266
- const pool = mockPool([{ rows: [{ id: '1', client_id: 'c1', github_id: '42', role: 'owner' }] }]);
267
- await P.upsertMembership({ clientId: 'c1', githubId: 42, role: 'owner' }, { pool });
265
+ await ta('upsertMembership upserts on (client_id, github_id) with a coarse membership_kind', async () => {
266
+ const pool = mockPool([{ rows: [{ id: '1', client_id: 'c1', github_id: '42', membership_kind: 'owner' }] }]);
267
+ await P.upsertMembership({ clientId: 'c1', githubId: 42, membershipKind: 'owner' }, { pool });
268
268
  const c = pool.calls[0];
269
269
  assert.match(c.text, /INSERT INTO platform_identity_project_memberships/);
270
270
  assert.match(c.text, /ON CONFLICT \(client_id, github_id\) DO UPDATE/);
@@ -272,10 +272,23 @@ await ta('upsertMembership upserts on (client_id, github_id) with a coarse role'
272
272
  assert.deepEqual(c.params, ['c1', 42, 'owner']);
273
273
  });
274
274
 
275
- await ta('upsertMembership clamps an unknown role to builder (never trusts a caller role blindly)', async () => {
275
+ await ta('upsertMembership clamps an unknown kind to member (never trusts a caller blindly)', async () => {
276
276
  const pool = mockPool([{ rows: [{}] }]);
277
- await P.upsertMembership({ clientId: 'c1', githubId: 42, role: 'archon' }, { pool });
278
- assert.equal(pool.calls[0].params[2], 'builder', 'a non-coarse role is clamped to builder');
277
+ await P.upsertMembership({ clientId: 'c1', githubId: 42, membershipKind: 'archon' }, { pool });
278
+ assert.equal(pool.calls[0].params[2], 'member', 'a non-coarse membership_kind is clamped to member');
279
+ });
280
+
281
+ await ta('the legacy wire vocabulary maps both ways (the cross-version window, task 1003090)', async () => {
282
+ // Other instances upgrade on their own schedule, so 'builder' keeps arriving on
283
+ // the wire for a while; 'member' is what the hub stores. Nothing else changes.
284
+ assert.equal(P.membershipKindFromLegacy('builder'), 'member');
285
+ assert.equal(P.legacyRoleFromKind('member'), 'builder');
286
+ for (const same of ['owner', 'visitor', 'pending']) {
287
+ assert.equal(P.membershipKindFromLegacy(same), same);
288
+ assert.equal(P.legacyRoleFromKind(same), same);
289
+ }
290
+ assert.deepEqual(P.MEMBERSHIP_KINDS, ['owner', 'member', 'visitor', 'pending'],
291
+ 'the craft name is gone from the coarse vocabulary');
279
292
  });
280
293
 
281
294
  await ta('removeMembership deletes the (client_id, github_id) row (self-service leave)', async () => {
@@ -287,20 +300,20 @@ await ta('removeMembership deletes the (client_id, github_id) row (self-service
287
300
  });
288
301
 
289
302
  await ta('listMembershipsForAccount joins projects + excludes pending', async () => {
290
- const pool = mockPool([{ rows: [{ client_id: 'c1', role: 'builder', project_name: 'Proj' }] }]);
303
+ const pool = mockPool([{ rows: [{ client_id: 'c1', membership_kind: 'member', project_name: 'Proj' }] }]);
291
304
  const out = await P.listMembershipsForAccount(42, { pool });
292
305
  assert.equal(out.length, 1);
293
306
  const c = pool.calls[0];
294
307
  assert.match(c.text, /FROM platform_identity_project_memberships m/);
295
308
  assert.match(c.text, /LEFT JOIN platform_identity_sso_clients c/);
296
- assert.match(c.text, /role <> 'pending'/);
309
+ assert.match(c.text, /membership_kind <> 'pending'/);
297
310
  assert.deepEqual(c.params, [42]);
298
311
  });
299
312
 
300
313
  console.log('\ninvitations (§5 — pre-seed pending membership):');
301
314
 
302
315
  await ta('inviteToProject pre-seeds a pending row, never clobbering an existing member', async () => {
303
- const pool = mockPool([{ rows: [{ id: '1', role: 'pending' }] }]);
316
+ const pool = mockPool([{ rows: [{ id: '1', membership_kind: 'pending' }] }]);
304
317
  const out = await P.inviteToProject({ clientId: 'c1', githubId: 42 }, { pool });
305
318
  assert.equal(out.invited, true);
306
319
  const c = pool.calls[0];
@@ -320,15 +333,15 @@ await ta('resolveInvite accept flips ONLY a pending row to builder', async () =>
320
333
  const out = await P.resolveInvite({ clientId: 'c1', githubId: 42, accept: true }, { pool });
321
334
  assert.deepEqual(out, { resolved: true, accepted: true });
322
335
  const c = pool.calls[0];
323
- assert.match(c.text, /UPDATE platform_identity_project_memberships SET role = 'builder'/);
324
- assert.match(c.text, /AND role = 'pending'/, 'only touches pending — never a real member');
336
+ assert.match(c.text, /UPDATE platform_identity_project_memberships SET membership_kind = 'member'/);
337
+ assert.match(c.text, /AND membership_kind = 'pending'/, 'only touches pending — never a real member');
325
338
  });
326
339
 
327
340
  await ta('resolveInvite decline deletes ONLY a pending row', async () => {
328
341
  const pool = mockPool([{ rowCount: 1 }]);
329
342
  const out = await P.resolveInvite({ clientId: 'c1', githubId: 42, accept: false }, { pool });
330
343
  assert.deepEqual(out, { resolved: true, accepted: false });
331
- assert.match(pool.calls[0].text, /DELETE FROM platform_identity_project_memberships[\s\S]*AND role = 'pending'/);
344
+ assert.match(pool.calls[0].text, /DELETE FROM platform_identity_project_memberships[\s\S]*AND membership_kind = 'pending'/);
332
345
  });
333
346
 
334
347
  await ta('getAccountByLogin resolves case-insensitively, newest first', async () => {
@@ -376,7 +389,7 @@ await ta('upsertActivity keeps a legitimate core rank key', async () => {
376
389
  });
377
390
 
378
391
  await ta('getMembership reads one (client, builder) row — the rollup membership requirement', async () => {
379
- const row = { id: '9', client_id: 'c1', github_id: '42', role: 'builder' };
392
+ const row = { id: '9', client_id: 'c1', github_id: '42', membership_kind: 'member' };
380
393
  const pool = mockPool([{ rows: [row] }]);
381
394
  const out = await P.getMembership('c1', 42, { pool });
382
395
  assert.deepEqual(out, row);
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.240",
3
+ "version": "1.19.241",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.240",
9
+ "version": "1.19.241",
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.240",
3
+ "version": "1.19.241",
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",
@@ -1741,7 +1741,7 @@ function vocabFilesToScan() {
1741
1741
  return out;
1742
1742
  }
1743
1743
 
1744
- // ── Check 19b — the FOURTH "role": a goal membership is not a craft either ──
1744
+ // ── Check 19b — the FOURTH and FIFTH "role": a membership is not a craft either ─
1745
1745
  //
1746
1746
  // task 1003013 / ADR 0177. ADR 0174 reserved `role` for the three crafts and R07
1747
1747
  // (checkVocabularyWall above) enforces it on the GOVERNMENT surface — deliberately
@@ -1751,10 +1751,16 @@ function vocabFilesToScan() {
1751
1751
  // exactly why it survived the rename: the rule was silent on it, as designed.
1752
1752
  //
1753
1753
  // So this is a SECOND narrow wall rather than a widened first one — same reason the
1754
- // first was scoped: the honest unit is a SURFACE, not the repo. The surface here is
1755
- // the goal-membership one: the routes and pure gate that decide it, the hall page
1756
- // that renders it, and (line-scoped) the membership SQL in the module's db.js. The
1757
- // noun on it is `membership_kind`.
1754
+ // first was scoped: the honest unit is a SURFACE, not the repo. Two surfaces carry
1755
+ // a membership, and both now say `membership_kind`:
1756
+ // • GOAL membership (`goal_members`, task 1003013) — the routes and pure gate that
1757
+ // decide it, the hall page that renders it, and (line-scoped) the membership SQL
1758
+ // in modules/lifecycle/db.js;
1759
+ // • PROJECT membership on the hub (`platform_identity_project_memberships`, task
1760
+ // 1003090) — whose old VALUE 'builder' was itself a craft name. Its check-in
1761
+ // route keeps reading a legacy `role` field for the cross-version federation
1762
+ // window (other instances upgrade on their own schedule), so the lines that
1763
+ // handle that legacy field cite the task and are exempt like any other citation.
1758
1764
  //
1759
1765
  // Naming the rule stays legal — a line that cites ADR 0174 / ADR 0177 / task
1760
1766
  // 1003013 is meta-commentary about the ban, the same principle as
@@ -1767,14 +1773,23 @@ const GOAL_VOCAB_SURFACE = [
1767
1773
  'modules/hall-ui/public/goals-page.js',
1768
1774
  'modules/hall-ui/public/goals.html',
1769
1775
  'modules/hall-ui/public/goals.css',
1776
+ // The FIFTH use (task 1003090): the hub's coarse project membership.
1777
+ 'modules/platform-identity/platform-identity.js',
1778
+ 'modules/platform-identity/routes/my-projects.js',
1779
+ 'modules/hall-ui/public/my-projects.js',
1770
1780
  ];
1771
1781
  // modules/lifecycle/db.js is 5k+ lines and legitimately says "role" about ranks
1772
1782
  // (the rank-sync port, builders.system_role). Scanning it whole would need an
1773
1783
  // allowlist longer than the rule; scanning the LINES THAT NAME THE TABLE catches
1774
1784
  // the only thing that can regress here — a membership query reaching for the old
1775
1785
  // column — with no false positives to excuse.
1776
- const GOAL_VOCAB_LINE_SCOPED = [{ file: 'modules/lifecycle/db.js', mustMention: /goal_members/ }];
1777
- const GOAL_VOCAB_CITATION = /ADR 017[47]|task 1003013/;
1786
+ const GOAL_VOCAB_LINE_SCOPED = [
1787
+ { file: 'modules/lifecycle/db.js', mustMention: /goal_members/ },
1788
+ // sso.js is the federation surface: only the lines that name the membership table
1789
+ // or its upsert are in scope, so the file's other server-to-server prose stays out.
1790
+ { file: 'modules/platform-identity/routes/sso.js', mustMention: /project_memberships|upsertMembership/ },
1791
+ ];
1792
+ const GOAL_VOCAB_CITATION = /ADR 017[47]|task 1003013|task 1003090/;
1778
1793
 
1779
1794
  function checkGoalMembershipVocabulary() {
1780
1795
  const violations = [];
@@ -1789,19 +1804,19 @@ function checkGoalMembershipVocabulary() {
1789
1804
  const scanLine = line.replace(/\srole\s*=\s*("[^"]*"|'[^']*')/g, ' ');
1790
1805
  if (!/\brole/i.test(scanLine)) return;
1791
1806
  if (GOAL_VOCAB_CITATION.test(scanLine)) return;
1792
- violations.push(`${rel}:${i + 1}: "role" — a goal membership is 'membership_kind' (ADR 0174 reserves "role" for the crafts; ADR 0177). → ${line.trim().slice(0, 90)}`);
1807
+ violations.push(`${rel}:${i + 1}: "role" — a membership is a 'membership_kind' (ADR 0174 reserves "role" for the crafts; ADR 0177). → ${line.trim().slice(0, 90)}`);
1793
1808
  });
1794
1809
  };
1795
1810
  for (const rel of GOAL_VOCAB_SURFACE) scanFile(rel);
1796
1811
  for (const { file, mustMention } of GOAL_VOCAB_LINE_SCOPED) scanFile(file, mustMention);
1797
1812
  return {
1798
- name: 'a goal membership is a "membership_kind", never a role (ADR 0177)',
1813
+ name: 'a membership is a "membership_kind", never a role (ADR 0177)',
1799
1814
  ok: violations.length === 0,
1800
1815
  hardFail: violations.length > 0,
1801
1816
  violations,
1802
1817
  warnings: [],
1803
- note: `${scanned} goal-membership-surface file(s) scanned (${GOAL_VOCAB_SURFACE.length} whole + `
1804
- + `${GOAL_VOCAB_LINE_SCOPED.length} line-scoped to the lines naming goal_members); no allowlist — `
1818
+ note: `${scanned} membership-surface file(s) scanned (${GOAL_VOCAB_SURFACE.length} whole — goal + project membership — plus `
1819
+ + `${GOAL_VOCAB_LINE_SCOPED.length} line-scoped to the lines naming the membership tables); no allowlist — `
1805
1820
  + 'a line that CITES the rule is the only exemption.',
1806
1821
  };
1807
1822
  }
@@ -173,15 +173,27 @@ function idpConfigured() {
173
173
  }
174
174
 
175
175
  // The COARSE membership role to report to the hub (ADR 0141 §3) — owner if this
176
- // builder is the instance's configured firstAdmin, else builder. NEVER a rank.
177
- function membershipRole({ githubLogin } = {}) {
176
+ // builder is the instance's configured firstAdmin, else a plain member. NEVER a rank.
177
+ //
178
+ // 'member', not 'builder' (task 1003090, ADR 0177 §"the fifth use"): ADR 0174 reserves
179
+ // `role` — and the craft name 'Builder' — for Artist / Ideator / Builder, so the coarse
180
+ // project membership says what it means. The legacy spelling still goes on the wire for
181
+ // the cross-version window; see reportMembershipCheckIn.
182
+ function membershipKind({ githubLogin } = {}) {
178
183
  const fa = firstAdminLogin();
179
184
  if (fa && String(githubLogin || '').toLowerCase() === fa.toLowerCase()) return 'owner';
180
- return 'builder';
185
+ return 'member';
181
186
  }
182
187
 
183
188
  // Best-effort server-to-server membership check-in to the hub (ADR 0141 §3): "this
184
- // github_id builds on project <client_id>, coarse role <role>". Runs on every
189
+ // github_id builds on project <client_id>, coarse membership <membership_kind>".
190
+ //
191
+ // BOTH SPELLINGS GO ON THE WIRE FOR THE WINDOW (task 1003090). The receiver is the
192
+ // HUB, a different instance on its own upgrade schedule: send only the new field and
193
+ // an un-upgraded hub silently defaults every membership to non-owner (it would drop
194
+ // the owner flag); send only the legacy field and an upgraded hub keeps translating
195
+ // forever. Sending both is what makes the two ends independent — the removal task
196
+ // drops `role` once the hub is past the window. Runs on every
185
197
  // FEDERATED sign-in (the only sign-ins a federated instance has) so a CLI-only
186
198
  // project still appears in the builder's My Projects with no hub browser session.
187
199
  // Fire-and-forget + fully swallowed: a hub outage must NEVER fail a sign-in (mirrors
@@ -189,6 +201,7 @@ function membershipRole({ githubLogin } = {}) {
189
201
  async function reportMembershipCheckIn({ githubId, githubLogin, displayName, avatarUrl }) {
190
202
  const idp = loadIdpConfig();
191
203
  if (!idp) return;
204
+ const kind = membershipKind({ githubLogin });
192
205
  try {
193
206
  await fetch(`${idp.origin}/api/gds/sso/membership/check-in`, {
194
207
  method: 'POST',
@@ -200,7 +213,9 @@ async function reportMembershipCheckIn({ githubId, githubLogin, displayName, ava
200
213
  github_login: githubLogin,
201
214
  display_name: displayName ?? null,
202
215
  avatar_url: avatarUrl ?? null,
203
- role: membershipRole({ githubLogin }),
216
+ membership_kind: kind,
217
+ // LEGACY (cross-version window): 'member' → 'builder' for an un-upgraded hub.
218
+ role: kind === 'member' ? 'builder' : kind,
204
219
  }),
205
220
  });
206
221
  } catch (err) {
@@ -1816,7 +1831,7 @@ module.exports = {
1816
1831
  verifyHubLogoutToken,
1817
1832
  logoutTokenJtiSeenAndRecord,
1818
1833
  completeDeviceExchange,
1819
- membershipRole,
1834
+ membershipKind,
1820
1835
  extractTokens,
1821
1836
  resolveSession,
1822
1837
  allowBoxScope,
package/src/module-api.js CHANGED
@@ -907,8 +907,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
907
907
  // landed since 1.19.238 with no explicit bump. run 32174983930. (task 1002620)
908
908
  // 1.19.240 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
909
909
  // landed since 1.19.239 with no explicit bump. run 32182178305. (task 1002620)
910
+ // 1.19.241 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
911
+ // landed since 1.19.240 with no explicit bump. run 32184475745. (task 1002620)
910
912
  // ---------------------------------------------------------------------------
911
- const CORE_VERSION = '1.19.240'; // CI auto-patch carrier (ADR 0161); see changelog above
913
+ const CORE_VERSION = '1.19.241'; // CI auto-patch carrier (ADR 0161); see changelog above
912
914
 
913
915
  // A namespaced logger so a module's log lines are attributable + consistent.
914
916
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -80,16 +80,18 @@ t('buildIdpAuthorizeUrl throws when the IdP is not configured (never silently mi
80
80
  });
81
81
  });
82
82
 
83
- console.log('\nmembership check-in role (§3 — coarse, never a rank):');
83
+ console.log('\nmembership check-in kind (§3 — coarse, never a rank, never a craft):');
84
84
 
85
- t('membershipRole is builder by default, owner for the configured firstAdmin', () => {
85
+ t('membershipKind is member by default, owner for the configured firstAdmin', () => {
86
86
  const prev = process.env.CLOUDBONGOS_FIRST_ADMIN;
87
87
  process.env.CLOUDBONGOS_FIRST_ADMIN = 'ownerlogin';
88
88
  try {
89
- assert.equal(auth.membershipRole({ githubLogin: 'ownerlogin' }), 'owner');
90
- assert.equal(auth.membershipRole({ githubLogin: 'OwnerLogin' }), 'owner', 'case-insensitive');
91
- assert.equal(auth.membershipRole({ githubLogin: 'someone-else' }), 'builder');
92
- assert.equal(auth.membershipRole({}), 'builder');
89
+ assert.equal(auth.membershipKind({ githubLogin: 'ownerlogin' }), 'owner');
90
+ assert.equal(auth.membershipKind({ githubLogin: 'OwnerLogin' }), 'owner', 'case-insensitive');
91
+ // 'member', not 'builder' — the craft name left the coarse membership vocabulary
92
+ // with task 1003090 (ADR 0177 §"the fifth use").
93
+ assert.equal(auth.membershipKind({ githubLogin: 'someone-else' }), 'member');
94
+ assert.equal(auth.membershipKind({}), 'member');
93
95
  } finally {
94
96
  if (prev === undefined) delete process.env.CLOUDBONGOS_FIRST_ADMIN;
95
97
  else process.env.CLOUDBONGOS_FIRST_ADMIN = prev;
@@ -0,0 +1,99 @@
1
+ // tests/membership_kind_lockstep.mjs — the expand-phase lockstep triggers, and the
2
+ // Postgres semantic that makes their shape load-bearing (task 1003090, ADR 0177).
3
+ //
4
+ // Both membership renames keep the legacy column alive for one release, kept in step
5
+ // by a BEFORE INSERT OR UPDATE trigger. The INSERT branch has to answer "did the
6
+ // caller write the NEW column, or is this an old release writing the legacy one?" —
7
+ // and there is exactly ONE way to answer it:
8
+ //
9
+ // POSTGRES MATERIALIZES A COLUMN DEFAULT INTO `NEW` BEFORE ANY BEFORE ROW TRIGGER
10
+ // FIRES. So if the new column carries a DEFAULT, `NEW.<new> IS NULL` is never true
11
+ // and the trigger cannot distinguish "explicitly written" from "default filled".
12
+ //
13
+ // That is not theoretical. With a DEFAULT in place, an old-release
14
+ // `INSERT … (client_id, github_id, role) VALUES (…, 'pending')` — inviteToProject's
15
+ // exact statement — took the "new code wrote it" branch and overwrote `role` with the
16
+ // default's mapping, turning a pending INVITE into an active membership. Caught by
17
+ // the grader on task 1003090 and reproduced against a real Postgres:
18
+ //
19
+ // A old-invite-pending | role=builder | membership_kind=member ← WRONG (before)
20
+ // A old-invite-pending | role=pending | membership_kind=pending ← after the fix
21
+ //
22
+ // The fix is the absence of a DEFAULT while the legacy column is still written, so
23
+ // NULL means exactly "not written". The DEFAULT (and NOT NULL) belong to step 2,
24
+ // when the legacy column is dropped. This file pins that invariant statically,
25
+ // because the trigger only misbehaves against a live Postgres — which CI has none of.
26
+ //
27
+ // Run: node --test tests/membership_kind_lockstep.mjs
28
+ import assert from 'node:assert/strict';
29
+ import { test } from 'node:test';
30
+ import fs from 'node:fs';
31
+ import path from 'node:path';
32
+ import { fileURLToPath } from 'node:url';
33
+
34
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
35
+ const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
36
+
37
+ // core_220 (goal membership) carries the SAME defect and is fixed under task
38
+ // 1003093 — filed, not forgotten. It is deliberately absent from this list rather
39
+ // than silently passing: a class guard that quietly excludes a known-broken member
40
+ // of the class is worse than no guard.
41
+ const EXPAND_MIGRATIONS = [
42
+ {
43
+ rel: 'modules/platform-identity/migrations/platform_identity_010_membership_kind.sql',
44
+ table: 'platform_identity_project_memberships',
45
+ legacy: 'role',
46
+ },
47
+ ];
48
+
49
+ for (const m of EXPAND_MIGRATIONS) {
50
+ test(`${path.basename(m.rel)}: the new column has NO default while ${m.legacy} is still written`, () => {
51
+ const sql = read(m.rel);
52
+ assert.ok(/ADD COLUMN IF NOT EXISTS membership_kind text/.test(sql), 'the expand step adds the column');
53
+ assert.ok(!/ALTER COLUMN membership_kind SET DEFAULT/.test(sql),
54
+ 'a DEFAULT here is materialized into NEW before the BEFORE trigger runs, so the trigger '
55
+ + 'could no longer tell an explicit write from a filled default — and an old-release INSERT '
56
+ + `naming only ${m.legacy} would be silently rewritten (task 1003090)`);
57
+ assert.ok(!/ALTER COLUMN membership_kind SET NOT NULL/.test(sql),
58
+ 'NOT NULL belongs to step 2, once nothing writes the legacy column (and the forward-safety '
59
+ + 'linter flags it here for the same reason)');
60
+ });
61
+
62
+ test(`${path.basename(m.rel)}: the trigger's INSERT branch keys off NULL, not off truthiness`, () => {
63
+ const sql = read(m.rel);
64
+ const fn = sql.slice(sql.indexOf('RETURNS trigger AS'), sql.indexOf('$$ LANGUAGE plpgsql'));
65
+ const insertBranch = fn.slice(fn.indexOf("IF TG_OP = 'INSERT'"), fn.indexOf('ELSE', fn.indexOf('END IF')));
66
+ assert.match(insertBranch, /IF NEW\.membership_kind IS NULL THEN/,
67
+ 'NULL is the only reliable signal that the caller did not write the new column');
68
+ assert.ok(!/IF NEW\.membership_kind IS NOT NULL THEN/.test(insertBranch),
69
+ 'the inverted form is the task-1003090 bug: it treats a filled default as an explicit write');
70
+ // The UPDATE branch has OLD to compare against, so it may (and does) use IS DISTINCT FROM.
71
+ assert.match(fn, /NEW\.membership_kind IS DISTINCT FROM OLD\.membership_kind/,
72
+ 'the UPDATE branch decides direction by which side actually changed');
73
+ assert.ok(/BEFORE INSERT OR UPDATE/.test(sql), 'a BEFORE trigger, so both columns are written by one statement');
74
+ });
75
+ }
76
+
77
+ test('the value mapping is one shared function, not repeated inline', () => {
78
+ // Route, sender and trigger must agree that 'builder' ⇄ 'member'; three copies of a
79
+ // ternary is how they would stop agreeing.
80
+ const pi = read('modules/platform-identity/platform-identity.js');
81
+ assert.match(pi, /function membershipKindFromLegacy\(/);
82
+ assert.match(pi, /function legacyRoleFromKind\(/);
83
+ const sso = read('modules/platform-identity/routes/sso.js');
84
+ assert.match(sso, /pi\.membershipKindFromLegacy\(role\)/,
85
+ 'the check-in translates the legacy field through the shared mapping');
86
+ const sql = read('modules/platform-identity/migrations/platform_identity_010_membership_kind.sql');
87
+ assert.match(sql, /CASE WHEN NEW\.role = 'builder' THEN 'member'/, 'the trigger maps the same pair');
88
+ assert.match(sql, /CASE WHEN NEW\.membership_kind = 'member' THEN 'builder'/);
89
+ });
90
+
91
+ test('the sender writes BOTH spellings, computing the kind once (the cross-version window)', () => {
92
+ const auth = read('src/bongos/auth.js');
93
+ const body = auth.slice(auth.indexOf('async function reportMembershipCheckIn'), auth.indexOf('async function reportMembershipCheckIn') + 2400);
94
+ assert.match(body, /membership_kind: kind/, 'the new field goes on the wire');
95
+ assert.match(body, /role: kind === 'member' \? 'builder' : kind/,
96
+ 'and the legacy field too — an un-upgraded hub would otherwise drop the owner flag');
97
+ assert.equal((body.match(/membershipKind\(\{ githubLogin \}\)/g) || []).length, 1,
98
+ 'the kind is computed ONCE per check-in, not per field');
99
+ });