@bongos/core 1.19.239 → 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.239",
6
- "core_contract": "1.19.239",
7
- "source_commit": "92f85bc3527c85038cc818e47b4d503a340c8abb",
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-18T19:09:51.272Z",
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": 1324,
14
+ "functional_verbatim": 1327,
15
15
  "rules": 3,
16
16
  "gate_literals": 3,
17
17
  "gate": "passed"
18
18
  },
19
- "file_count": 1657,
20
- "tree_sha256": "ba99d83f49d20d229d51e1ca9cbb5232f744888d91ab1dbf43d5c092eade4cf9",
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": "16010eb616ec78eb915efe81ecf81ad7b20339180ec495e40eb50b71bbac8b93"
4350
+ "sha256": "8f961bb896bdf312ad73752daa2342ce9ccf7db02b21ac9d2480694db28857d8"
4346
4351
  },
4347
4352
  {
4348
4353
  "path": "package.json",
4349
4354
  "mode": "0000644",
4350
- "sha256": "8cf3937acd1926b71f0e9ee112fce2b1d23adfd5b58cb2a2e36926f90b2e714f"
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",
@@ -5332,7 +5337,7 @@
5332
5337
  {
5333
5338
  "path": "scripts/gds/run-unit-tests.js",
5334
5339
  "mode": "0000644",
5335
- "sha256": "21bcaeaa9d8b2048b9c3f9ff9b251a9203a9259c991a536636c0ec3c8745bbd5"
5340
+ "sha256": "192350d9d65934a1af724d0f285cc74a89a842e75d0fdec37037d34ded5db546"
5336
5341
  },
5337
5342
  {
5338
5343
  "path": "scripts/gds/sandbox-stage.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": "3995623cfa7376dbf8cbcf14b238c1fa1c919ad9d8da5af781b0f78dcb0bb8cc"
5970
+ "sha256": "d2dd5e30bb7f44cd8a37cbf559bbcdf76aec973043f3db5deec8988b62a59eb6"
5966
5971
  },
5967
5972
  {
5968
5973
  "path": "src/module-loader/catalog.js",
@@ -6967,7 +6972,12 @@
6967
6972
  {
6968
6973
  "path": "tests/government_live_proof.mjs",
6969
6974
  "mode": "0000644",
6970
- "sha256": "af6423ac87819ff344c8a7804c0eda903a3e8c7633d1e4182e7d4f446d30cec0"
6975
+ "sha256": "6d829af6ef71bfa744a4ff457a4766b5cce021f317661579b06ecfc14298a523"
6976
+ },
6977
+ {
6978
+ "path": "tests/government_live_proof_shape.mjs",
6979
+ "mode": "0000644",
6980
+ "sha256": "75160048aba7b8c2264478c29aa2fc19636c67f66cd63842a83851574b722469"
6971
6981
  },
6972
6982
  {
6973
6983
  "path": "tests/government_ownership_scope.mjs",
@@ -7212,7 +7222,7 @@
7212
7222
  {
7213
7223
  "path": "tests/idp_federation.mjs",
7214
7224
  "mode": "0000644",
7215
- "sha256": "09a9ae4a726cec8d0e31bc50dc08db102745c6451fb64f105c2be1b623062986"
7225
+ "sha256": "5130936408829e4e45742eda0124aac9df50865ece1812274354384f9da498fd"
7216
7226
  },
7217
7227
  {
7218
7228
  "path": "tests/infra_exec_paths.mjs",
@@ -7334,6 +7344,11 @@
7334
7344
  "mode": "0000644",
7335
7345
  "sha256": "9a1813d6036886ea07d1563e71a466e66c28554b75b22605464c795fd9fa2ebb"
7336
7346
  },
7347
+ {
7348
+ "path": "tests/membership_kind_lockstep.mjs",
7349
+ "mode": "0000644",
7350
+ "sha256": "7ebca67bf07f723f47a861eb530a0feee25967fe3c0f4a9e7d76703351dc8d84"
7351
+ },
7337
7352
  {
7338
7353
  "path": "tests/memory_isolation.mjs",
7339
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
  });