@bongos/core 1.19.238 → 1.19.239
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 +40 -30
- package/clients/bongos-client/index.d.ts +2 -2
- package/docs/adr/0177-goal-membership-kind.md +96 -0
- package/docs/adr/README.md +1 -0
- package/docs/api/openapi.json +5 -5
- package/docs/api-reference.md +2 -2
- package/docs/canonical-permissions.md +3 -1
- package/migrations/core_220_goal_members_membership_kind.sql +100 -0
- package/modules/hall-ui/public/goals-page.js +15 -13
- package/modules/hall-ui/public/goals.css +2 -2
- package/modules/lifecycle/db.js +40 -32
- package/modules/lifecycle/goal-authz.js +10 -7
- package/modules/lifecycle/routes/done-when.js +3 -3
- package/modules/lifecycle/routes/goals.js +43 -36
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/fitness.js +68 -0
- package/src/module-api.js +3 -1
- package/tests/criterion_authoring_gate.mjs +4 -4
- package/tests/goal_conflicts.mjs +3 -3
- package/tests/{goal_member_roles.mjs → goal_member_kinds.mjs} +89 -80
- package/tests/goal_membership_requests.mjs +10 -10
- package/tests/goal_routes.mjs +8 -8
- package/tests/goal_tier.mjs +10 -10
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.
|
|
6
|
-
"core_contract": "1.19.
|
|
7
|
-
"source_commit": "
|
|
5
|
+
"core_version": "1.19.239",
|
|
6
|
+
"core_contract": "1.19.239",
|
|
7
|
+
"source_commit": "92f85bc3527c85038cc818e47b4d503a340c8abb",
|
|
8
8
|
"source_ref": "HEAD",
|
|
9
|
-
"built_at": "2026-08-
|
|
9
|
+
"built_at": "2026-08-18T19:09:51.272Z",
|
|
10
10
|
"redaction": {
|
|
11
11
|
"model": "docs-redacted+functional-verbatim",
|
|
12
|
-
"docs_redacted":
|
|
12
|
+
"docs_redacted": 312,
|
|
13
13
|
"agent_docs_stubbed": 21,
|
|
14
|
-
"functional_verbatim":
|
|
14
|
+
"functional_verbatim": 1324,
|
|
15
15
|
"rules": 3,
|
|
16
16
|
"gate_literals": 3,
|
|
17
17
|
"gate": "passed"
|
|
18
18
|
},
|
|
19
|
-
"file_count":
|
|
20
|
-
"tree_sha256": "
|
|
19
|
+
"file_count": 1657,
|
|
20
|
+
"tree_sha256": "ba99d83f49d20d229d51e1ca9cbb5232f744888d91ab1dbf43d5c092eade4cf9",
|
|
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": "
|
|
370
|
+
"sha256": "2006b6d2f061c266c79390839bca228df59753db2e3919f49946db07682ee207"
|
|
371
371
|
},
|
|
372
372
|
{
|
|
373
373
|
"path": "clients/bongos-client/index.mjs",
|
|
@@ -1364,20 +1364,25 @@
|
|
|
1364
1364
|
"mode": "0000644",
|
|
1365
1365
|
"sha256": "eb560b832629f708ad458f64c6a282bf55ae86c24626431d48d258e9ca0ac6d6"
|
|
1366
1366
|
},
|
|
1367
|
+
{
|
|
1368
|
+
"path": "docs/adr/0177-goal-membership-kind.md",
|
|
1369
|
+
"mode": "0000644",
|
|
1370
|
+
"sha256": "0c81340c845250107cd195dadce2740b8324b7777a44011b02b488827193a4de"
|
|
1371
|
+
},
|
|
1367
1372
|
{
|
|
1368
1373
|
"path": "docs/adr/README.md",
|
|
1369
1374
|
"mode": "0000644",
|
|
1370
|
-
"sha256": "
|
|
1375
|
+
"sha256": "a8a408b4f15d3d4aca0d0e864bd14984a73e1801b74e36962a7cb8f4db04233d"
|
|
1371
1376
|
},
|
|
1372
1377
|
{
|
|
1373
1378
|
"path": "docs/api-reference.md",
|
|
1374
1379
|
"mode": "0000644",
|
|
1375
|
-
"sha256": "
|
|
1380
|
+
"sha256": "791e8f83d023e77d3faa2e3abf3ec9099f3ffa9236183aad5092ef0792e510cc"
|
|
1376
1381
|
},
|
|
1377
1382
|
{
|
|
1378
1383
|
"path": "docs/api/openapi.json",
|
|
1379
1384
|
"mode": "0000644",
|
|
1380
|
-
"sha256": "
|
|
1385
|
+
"sha256": "901748f25425af336637858b48ec06ad0461d5d3dc2a6beaad8b2a6af0eb6161"
|
|
1381
1386
|
},
|
|
1382
1387
|
{
|
|
1383
1388
|
"path": "docs/architecture.md",
|
|
@@ -1397,7 +1402,7 @@
|
|
|
1397
1402
|
{
|
|
1398
1403
|
"path": "docs/canonical-permissions.md",
|
|
1399
1404
|
"mode": "0000644",
|
|
1400
|
-
"sha256": "
|
|
1405
|
+
"sha256": "f566ac3244920461753cf01454b986a6b669fced8c87a440e3ce99298b0cddf5"
|
|
1401
1406
|
},
|
|
1402
1407
|
{
|
|
1403
1408
|
"path": "docs/design-contract.md",
|
|
@@ -2369,6 +2374,11 @@
|
|
|
2369
2374
|
"mode": "0000644",
|
|
2370
2375
|
"sha256": "80ff025953285762f9fdfc8de813db3471ade4d02cb6db5a65f72fa13797a0fc"
|
|
2371
2376
|
},
|
|
2377
|
+
{
|
|
2378
|
+
"path": "migrations/core_220_goal_members_membership_kind.sql",
|
|
2379
|
+
"mode": "0000644",
|
|
2380
|
+
"sha256": "e466f4222fdbfa04386003990efabe38f97f3b3c23ff174a622642daea5c096b"
|
|
2381
|
+
},
|
|
2372
2382
|
{
|
|
2373
2383
|
"path": "modules/autonomy/CLAUDE.md",
|
|
2374
2384
|
"mode": "0000644",
|
|
@@ -3257,12 +3267,12 @@
|
|
|
3257
3267
|
{
|
|
3258
3268
|
"path": "modules/hall-ui/public/goals-page.js",
|
|
3259
3269
|
"mode": "0000644",
|
|
3260
|
-
"sha256": "
|
|
3270
|
+
"sha256": "7ba31240f79d6c0b1a1db236af1b80ccbcef03ef826e48f8e07c7e55863b713a"
|
|
3261
3271
|
},
|
|
3262
3272
|
{
|
|
3263
3273
|
"path": "modules/hall-ui/public/goals.css",
|
|
3264
3274
|
"mode": "0000644",
|
|
3265
|
-
"sha256": "
|
|
3275
|
+
"sha256": "615caa61dafdf77295d8e98d7e8c826b5e8b2e352f322c4486b4da5437629f07"
|
|
3266
3276
|
},
|
|
3267
3277
|
{
|
|
3268
3278
|
"path": "modules/hall-ui/public/goals.html",
|
|
@@ -3607,7 +3617,7 @@
|
|
|
3607
3617
|
{
|
|
3608
3618
|
"path": "modules/lifecycle/db.js",
|
|
3609
3619
|
"mode": "0000644",
|
|
3610
|
-
"sha256": "
|
|
3620
|
+
"sha256": "ed948712d57d984684313afd0160b1838c8cde061f82bd62f31226e39b8df122"
|
|
3611
3621
|
},
|
|
3612
3622
|
{
|
|
3613
3623
|
"path": "modules/lifecycle/done-when.js",
|
|
@@ -3627,7 +3637,7 @@
|
|
|
3627
3637
|
{
|
|
3628
3638
|
"path": "modules/lifecycle/goal-authz.js",
|
|
3629
3639
|
"mode": "0000644",
|
|
3630
|
-
"sha256": "
|
|
3640
|
+
"sha256": "6280028c33fd0770514e753962e3a83ee2e3d47d6c9efdb67b9905b868c6db72"
|
|
3631
3641
|
},
|
|
3632
3642
|
{
|
|
3633
3643
|
"path": "modules/lifecycle/goal-conflicts.js",
|
|
@@ -3692,7 +3702,7 @@
|
|
|
3692
3702
|
{
|
|
3693
3703
|
"path": "modules/lifecycle/routes/done-when.js",
|
|
3694
3704
|
"mode": "0000644",
|
|
3695
|
-
"sha256": "
|
|
3705
|
+
"sha256": "c81e1474e237dfa2e3be5f80df56344cf7f9ca50dc7b84e45b2fec64509b900f"
|
|
3696
3706
|
},
|
|
3697
3707
|
{
|
|
3698
3708
|
"path": "modules/lifecycle/routes/gate-approvals.js",
|
|
@@ -3702,7 +3712,7 @@
|
|
|
3702
3712
|
{
|
|
3703
3713
|
"path": "modules/lifecycle/routes/goals.js",
|
|
3704
3714
|
"mode": "0000644",
|
|
3705
|
-
"sha256": "
|
|
3715
|
+
"sha256": "a986f861d919aec81e58683782ceae902e8590cb581710842113a04439984c16"
|
|
3706
3716
|
},
|
|
3707
3717
|
{
|
|
3708
3718
|
"path": "modules/lifecycle/routes/lifecycle.js",
|
|
@@ -4332,12 +4342,12 @@
|
|
|
4332
4342
|
{
|
|
4333
4343
|
"path": "package-lock.json",
|
|
4334
4344
|
"mode": "0000644",
|
|
4335
|
-
"sha256": "
|
|
4345
|
+
"sha256": "16010eb616ec78eb915efe81ecf81ad7b20339180ec495e40eb50b71bbac8b93"
|
|
4336
4346
|
},
|
|
4337
4347
|
{
|
|
4338
4348
|
"path": "package.json",
|
|
4339
4349
|
"mode": "0000644",
|
|
4340
|
-
"sha256": "
|
|
4350
|
+
"sha256": "8cf3937acd1926b71f0e9ee112fce2b1d23adfd5b58cb2a2e36926f90b2e714f"
|
|
4341
4351
|
},
|
|
4342
4352
|
{
|
|
4343
4353
|
"path": "public-docs/index.html",
|
|
@@ -4962,7 +4972,7 @@
|
|
|
4962
4972
|
{
|
|
4963
4973
|
"path": "scripts/gds/fitness.js",
|
|
4964
4974
|
"mode": "0000644",
|
|
4965
|
-
"sha256": "
|
|
4975
|
+
"sha256": "f5059d035746d8ea967e127e8421213efede43c9ae50c50403fc8772c487db76"
|
|
4966
4976
|
},
|
|
4967
4977
|
{
|
|
4968
4978
|
"path": "scripts/gds/gate-review.js",
|
|
@@ -5952,7 +5962,7 @@
|
|
|
5952
5962
|
{
|
|
5953
5963
|
"path": "src/module-api.js",
|
|
5954
5964
|
"mode": "0000644",
|
|
5955
|
-
"sha256": "
|
|
5965
|
+
"sha256": "3995623cfa7376dbf8cbcf14b238c1fa1c919ad9d8da5af781b0f78dcb0bb8cc"
|
|
5956
5966
|
},
|
|
5957
5967
|
{
|
|
5958
5968
|
"path": "src/module-loader/catalog.js",
|
|
@@ -6507,7 +6517,7 @@
|
|
|
6507
6517
|
{
|
|
6508
6518
|
"path": "tests/criterion_authoring_gate.mjs",
|
|
6509
6519
|
"mode": "0000644",
|
|
6510
|
-
"sha256": "
|
|
6520
|
+
"sha256": "970db80cf43dba855bf9bd331434add458ab16e719f06710487094a3a578ec53"
|
|
6511
6521
|
},
|
|
6512
6522
|
{
|
|
6513
6523
|
"path": "tests/criterion_progress.mjs",
|
|
@@ -6832,7 +6842,7 @@
|
|
|
6832
6842
|
{
|
|
6833
6843
|
"path": "tests/goal_conflicts.mjs",
|
|
6834
6844
|
"mode": "0000644",
|
|
6835
|
-
"sha256": "
|
|
6845
|
+
"sha256": "1dd92e13f21ed2b029c64d07dcdd6c47aa4f59ce25e288786c4fa7245ba707c5"
|
|
6836
6846
|
},
|
|
6837
6847
|
{
|
|
6838
6848
|
"path": "tests/goal_criterion_confirm_ui.mjs",
|
|
@@ -6845,19 +6855,19 @@
|
|
|
6845
6855
|
"sha256": "27e5e881185511e0562dcfe36b8d6926b3222a9443893456c5f3a04089e40db6"
|
|
6846
6856
|
},
|
|
6847
6857
|
{
|
|
6848
|
-
"path": "tests/
|
|
6858
|
+
"path": "tests/goal_member_kinds.mjs",
|
|
6849
6859
|
"mode": "0000644",
|
|
6850
|
-
"sha256": "
|
|
6860
|
+
"sha256": "3bb40fceeeeaa835db715b724ac73475be2a9ae166c740e9daa78eafc6957b03"
|
|
6851
6861
|
},
|
|
6852
6862
|
{
|
|
6853
6863
|
"path": "tests/goal_membership_requests.mjs",
|
|
6854
6864
|
"mode": "0000644",
|
|
6855
|
-
"sha256": "
|
|
6865
|
+
"sha256": "bd29e6910bc741ddf18b4592eeeaeac02b45fb1dd321527a8f44203cd78bff18"
|
|
6856
6866
|
},
|
|
6857
6867
|
{
|
|
6858
6868
|
"path": "tests/goal_routes.mjs",
|
|
6859
6869
|
"mode": "0000644",
|
|
6860
|
-
"sha256": "
|
|
6870
|
+
"sha256": "d7b40066dc12921fccb43e0176d5bf1a5d2797b2be64928719c2a598afee2773"
|
|
6861
6871
|
},
|
|
6862
6872
|
{
|
|
6863
6873
|
"path": "tests/goal_scope_check.mjs",
|
|
@@ -6867,7 +6877,7 @@
|
|
|
6867
6877
|
{
|
|
6868
6878
|
"path": "tests/goal_tier.mjs",
|
|
6869
6879
|
"mode": "0000644",
|
|
6870
|
-
"sha256": "
|
|
6880
|
+
"sha256": "00539cd1b188def53c67aab06301971132fbbe0bcd47818c19763ed760aaccda"
|
|
6871
6881
|
},
|
|
6872
6882
|
{
|
|
6873
6883
|
"path": "tests/goal_wall_proof.mjs",
|
|
@@ -122,7 +122,7 @@ export interface PatchBuildersIdBudgetResponse { builder: unknown }
|
|
|
122
122
|
export interface PatchBuildersIdRankRequest { rank: string }
|
|
123
123
|
export interface PatchBuildersIdRankResponse { builder: unknown; previous_rank: unknown; direction: unknown; credit_delta: unknown; broadcast: unknown }
|
|
124
124
|
export interface PatchBuildersIdStatusRequest { status: "active" | "inactive"; reason?: string }
|
|
125
|
-
export interface PatchGoalsIdMembersBuilderIdRequest {
|
|
125
|
+
export interface PatchGoalsIdMembersBuilderIdRequest { membership_kind: string }
|
|
126
126
|
export interface PatchGoalsIdMembersBuilderIdResponse { ok: boolean; member: unknown; changed: boolean }
|
|
127
127
|
export interface PatchGoalsIdVisibilityRequest { visibility: string }
|
|
128
128
|
export interface PatchGoalsIdVisibilityResponse { ok: boolean; goal: unknown }
|
|
@@ -213,7 +213,7 @@ export interface PostGoalsIdInvitationsRequest { note?: string; builder_id?: Str
|
|
|
213
213
|
export interface PostGoalsIdInvitationsResponse { ok: boolean; request: unknown }
|
|
214
214
|
export interface PostGoalsIdJoinResponse { ok: boolean; member: unknown }
|
|
215
215
|
export interface PostGoalsIdLeaveResponse { ok: boolean }
|
|
216
|
-
export interface PostGoalsIdMembersRequest {
|
|
216
|
+
export interface PostGoalsIdMembersRequest { membership_kind?: string; builder_id?: StringifiableId }
|
|
217
217
|
export interface PostGoalsIdMembersResponse { ok: boolean; member: unknown }
|
|
218
218
|
export interface PostGoalsIdReopenResponse { ok: boolean; goal: unknown }
|
|
219
219
|
export interface PostGoalsIdRequestsReqIdRespondRequest { action: string }
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# 0177 — A goal membership is a `membership_kind`, and the rename ships expand-then-contract
|
|
2
|
+
|
|
3
|
+
- **Status:** Accepted
|
|
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)
|
|
6
|
+
- **Extends:** [ADR 0174](<redacted>.md) — the reserved-word rule, applied to the fourth use it did not cover
|
|
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
|
+
|
|
9
|
+
## Context
|
|
10
|
+
|
|
11
|
+
ADR 0174 reserved the word **role** for the three crafts — Artist, Ideator, Builder — and renamed the
|
|
12
|
+
government's use of it to **rank**. Its fitness rule (goal 1000068 R07) enforces the reservation on the
|
|
13
|
+
**government surface only**, and that scoping is deliberate: `builders.system_role` is a core
|
|
14
|
+
machine-identity column, and Discord's API owns its own "role" noun. A repo-wide ban would be wrong.
|
|
15
|
+
|
|
16
|
+
Sweeping the docs for R08 turned up a **fourth, independent use** that nobody had catalogued:
|
|
17
|
+
**goal membership**. `goal_members.role` holds `'lead' | 'member'` — created by `160_goal_tier.sql`,
|
|
18
|
+
read as `actorMembership.role === 'lead'` in `modules/lifecycle/goal-authz.js` and in the PATCH
|
|
19
|
+
`/goals/:id/members/:builderId` gate, rendered by the hall's Goals page as "Manager"/"Member". It sits
|
|
20
|
+
outside R07's surface, so the rule is silent on it — the rule working as designed, and also the reason
|
|
21
|
+
this use would have sat there indefinitely.
|
|
22
|
+
|
|
23
|
+
Four meanings, for the record: (1) government ranks — renamed by ADR 0174; (2) the three crafts — the
|
|
24
|
+
reserved meaning; (3) Discord server roles — out of scope, Discord's word; (4) goal membership — this.
|
|
25
|
+
A **fifth** turned up while doing the work and is deliberately NOT in scope here:
|
|
26
|
+
`project_memberships.role` (`'owner' | 'builder' | 'visitor' | 'pending'` — whose `builder` value
|
|
27
|
+
collides with a craft name as well). It is filed as its own task; this ADR is the worked example for it.
|
|
28
|
+
|
|
29
|
+
Two things make this small change need a decision record. The value is on the **authorization** path
|
|
30
|
+
(the MANAGER predicate), and the column is **live**.
|
|
31
|
+
|
|
32
|
+
## Decision
|
|
33
|
+
|
|
34
|
+
**1. The noun is `membership_kind`; the values are unchanged.** `goal_members.membership_kind` holds
|
|
35
|
+
`'lead' | 'member'`. The values were never the problem — the noun was. `rank` would be wrong (a
|
|
36
|
+
membership is not a position on the ladder), and `position` would be worse: ADR 0174 uses "position"
|
|
37
|
+
as the *prose* word for what a rank is, so borrowing it here recreates exactly the qualifier collision
|
|
38
|
+
("rank-role") that ADR 0174 diagnosed.
|
|
39
|
+
|
|
40
|
+
**A boolean `is_lead` was seriously considered and rejected.** It removes the word rather than trading
|
|
41
|
+
it, and there are exactly two values with one default — the task itself argued for it. Two things
|
|
42
|
+
decided against: a text rename preserves the copied `CHECK` and stays a *reversible* schema change on a
|
|
43
|
+
live authz column, whereas `ADD boolean; UPDATE; DROP text` is data-destructive; and the values are how
|
|
44
|
+
every consumer already reads (`'lead'` renders "Manager"), so the boolean would have changed the shape
|
|
45
|
+
of every call site instead of one identifier. The extensibility argument is secondary but real: a third
|
|
46
|
+
membership (reviewer, observer) is a row-value under this shape and a migration under a boolean.
|
|
47
|
+
|
|
48
|
+
**2. The migration is EXPAND-THEN-CONTRACT, not `RENAME COLUMN`.** `bongos upgrade` auto-rollback
|
|
49
|
+
reverts the **code pin** and not the DB. A bare rename plus a rolled-back deploy would leave the
|
|
50
|
+
previous release querying a column that no longer exists — every goal-membership check erroring, on the
|
|
51
|
+
authz path. `tests/migrations_forward_safe.mjs` exists to catch precisely this, and it would have caught
|
|
52
|
+
it here. So:
|
|
53
|
+
|
|
54
|
+
- **Step 1 (`<redacted>.sql`, this task)** — ADD `membership_kind`, backfill
|
|
55
|
+
it from `role`, copy the `CHECK`, and keep the two columns in lockstep with a **BEFORE INSERT OR
|
|
56
|
+
UPDATE trigger** whose direction is decided by which side changed. Both names therefore work for one
|
|
57
|
+
release: new code writes `membership_kind`; the historical `lifecycle_001` seed and any older release
|
|
58
|
+
(including the one a rollback restores) write `role` and the new column follows. Forward-safe, no
|
|
59
|
+
pragma, nothing to remember in application code.
|
|
60
|
+
- **No `SET NOT NULL` in step 1.** The trigger fills the column on every write (a BEFORE ROW trigger
|
|
61
|
+
runs ahead of constraint checks), but a static NOT NULL on a column the previous release has never
|
|
62
|
+
heard of is the shape the forward-safety gate flags, and it would flag it rightly. NOT NULL arrives
|
|
63
|
+
with step 2.
|
|
64
|
+
- **Step 2 (its own task)** — DROP `role` and the trigger, once the release carrying step 1 is behind
|
|
65
|
+
the live pin. That step IS forward-unsafe, ships with the `-- forward-unsafe:` pragma, a fresh
|
|
66
|
+
backup, and its own deploy window — the `government_001` precedent.
|
|
67
|
+
|
|
68
|
+
**3. The API field renames with the column, with NO alias.** `POST /goals/:id/members` and `PATCH
|
|
69
|
+
/goals/:id/members/:builderId` take `membership_kind`; the refusals become `bad_membership_kind`,
|
|
70
|
+
`owner_membership_fixed`, `membership_change_conflict`, `membership_change_failed`. ADR 0174's own
|
|
71
|
+
Lineage records the lesson that a time-boxed shim outlives its release, so there is no `role` alias to
|
|
72
|
+
outlive: the rename is a contract change, and `tests/goal_member_kinds.mjs` pins that the old field name
|
|
73
|
+
buys nothing. Every known consumer (the hall's Goals page, the generated typed client) ships in the same
|
|
74
|
+
release; the generated API docs regenerate with it.
|
|
75
|
+
|
|
76
|
+
**4. The reservation gets a SECOND narrow wall, not a widened first one.**
|
|
77
|
+
`<redacted>` (`scripts/gds/fitness.js`) scans the goal-membership surface — the
|
|
78
|
+
routes and pure gate that decide it, the hall page that renders it — plus, line-scoped, the lines of
|
|
79
|
+
`modules/lifecycle/db.js` that name `goal_members`. The line scoping is the point: that file is 5k+
|
|
80
|
+
lines and legitimately says "role" about ranks and `builders.system_role`, so scanning it whole would
|
|
81
|
+
need an allowlist longer than the rule, while scanning the lines that name the table catches the only
|
|
82
|
+
regression available — a membership query reaching for the old column — with no false positives. The
|
|
83
|
+
only exemption is a line that CITES the rule (ADR 0174 / ADR 0177 / task 1003013); naming a ban has to
|
|
84
|
+
be legal or the code cannot explain itself. R07 stays scoped to the government surface, unchanged.
|
|
85
|
+
|
|
86
|
+
## Consequences
|
|
87
|
+
|
|
88
|
+
- The word `role` no longer names a goal membership in the API, the hall, or the docs, and in the DB it
|
|
89
|
+
survives only as the step-1 compatibility column, on a trigger, for one release.
|
|
90
|
+
- **The two halves must ship in one release** — they do: the column, the trigger, and every reader land
|
|
91
|
+
in one commit, so no deploy sees new code against an old schema.
|
|
92
|
+
- A rollback of that deploy is safe by construction, which is the whole reason for the trigger. It is
|
|
93
|
+
the only new moving part on the write path, and it is the thing step 2 removes.
|
|
94
|
+
- `docs/canonical-permissions.md` (the goal-management wall) now names `membership_kind`; the
|
|
95
|
+
`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.
|
package/docs/adr/README.md
CHANGED
|
@@ -250,5 +250,6 @@ This keeps the decision history honest and traceable.
|
|
|
250
250
|
| 0174 | [**Governance is renamed government, its positions are ranks, and "role" is reserved for the three crafts** ([task 1003002](https://cloudbongos.com/builders#/task/1003002), R01 of goal 1000068). Owner directive, 2026-08-16. The word **role** named two unrelated things: the permission system's ladder tiers (`governance_roles`, the "rank-roles") and the three crafts (`tasks.discipline` = engineer/artist/ideator) — and goal 1000066 was actively claiming it for the crafts (`three-roles-only`, `users-hold-roles`), putting the two on a collision course. Resolved at full depth rather than as a label pass, because a labels-only fix leaves the UI and the code disagreeing, which is the condition that produced the collision. `modules/governance/` → `modules/government/`; the kernel module key, the config files, `/api/bongos/governance/*` → `/government/*`, and the permission keys `governance.manage` → `government.manage` (~50 sites) + `page.view.governance` → `page.view.government`. **The entity IS the rank — no second noun**: `governance_roles` → `government_ranks`, `role_key` → `rank_key`, `rank-roles.js` → `ranks.js`; "position" stays prose-only, since introducing it as a code term would recreate the qualifier problem "rank-role" already demonstrates. The cross-module kernel port `governance.roleSync` → `government.rankSync` must land atomically across its provider and both consumers (`modules/lifecycle/db.js`, `src/bongos/db.js`) because it is a transaction participant on the rank-write path. Hand-composed bundles stay in the one table as **custom ranks** — the owner's call over splitting them into a separate "appointments" concept, with the objection recorded (a custom bundle is not a ladder position, and ADR 0172 §4 already treats the two kinds as different). Enforced by a `fitness.js` rule, allowlist shipped with reasons. **Discord's server roles keep the word** — that noun belongs to Discord's API. Amends ADR 0151 + ADR 0172 (the editable-rank-roles one) in **vocabulary only**; ladder slugs, `builders.rank`, `requireRank`, and every floor/grant/assignment semantic are untouched. Rejected: renaming the ladder slugs (turns a bounded rename into a trust-boundary migration), a permanent compat shim (both vocabularies stay correct forever).](<redacted>.md) | government / permissions |
|
|
251
251
|
| 0175 | [**The Board Room: Full Idea ratification is the first upward-pointing gate** ([task 1003014](https://cloudbongos.com/builders#/task/1003014), R01 of goal 1000069). Every existing gate points DOWN the rank ladder; the owner asked for one that could slow THEM down — *"otherwise I can just ship and build as I want, finding loopholes."* The board's subjects are exactly **two**, both rare by construction: a **Full Idea** and a **change to the constitution itself** (`subject_type` from day one). Because the vote gates no build path, the **ADR 0162 tension dissolves rather than being compromised** — nothing self-executes because there is nothing downstream to execute; ratification confers legitimacy and does NOT auto-create the goal. A vote is a **timed sitting, not a quorum**: absent members are *nulled* (dropped from the denominator, never counted as a no), so the clock is the deadlock exit and no shotgun/fork-with-treasury clause is needed at this scale — default 90 minutes, closing early on full turnout, and yes, a short window means whoever is awake governs (the owner's explicit intent). **Consent, not majority**: silence is consent, one reasoned objection returns the idea, and a zero-vote close PASSES. A rejection must carry free text **plus one of the five Full Idea sections** — which is what **forces the hall to be the voting surface**, since a Discord reaction could only ever be feedback-free; Discord is therefore an outbound notification mirror that casts nothing, and its own mobile app is the mobile push (no mobile surface is built). The completeness grade is the **floor** (a window opens automatically on pass), the vote is the **merit**; concurrent windows are normal. Who governs is **configuration** — day one is monarchy expressed as config on the real machinery, never a separate code path — and **amendments are decided under whatever rule is currently in force**, so flipping out of monarchy is a legal monarchy act and no bootstrap exception is needed. Open ballot; an author may never vote on their own item. Franchise derives from **rank, not role**, and that is forced: `PATCH /me/disciplines` is deliberately ungated, so a craft-scoped chamber would be one-request privilege escalation. Unblocks goal 1000067 / IC5, which consumes the pass event and *"invents no parallel vote"*. Rejected: quadratic vote weighting and the attribution-economy knobs (γ, τ, the 30% stack cap — v1's franchise is not credit-weighted at all), eternity clauses, and any legal-entity integration.](<redacted>.md) | government / ideas / governance |
|
|
252
252
|
| 0176 | [**Private-repo standup: a per-repo read-only deploy key, minted with the owner's token** ([task 1002735](https://cloudbongos.com/builders#/task/1002735), goal 1000032; walk finding N4, seen live on `mercury` 2026-08-17). A standalone instance's repo is cloned/pushed once with the owner's `repo`-scoped token (ADR 0155), but the durable `origin` rides the shared SSH deploy alias — which GitHub answers "Repository not found" for a private repo it isn't registered on, so every private standup failed at the recurring `git pull --ff-only origin main`, **token or not**. Fix: when the target repo answers *private* to the owner's stored token, provisioning mints the instance its OWN ed25519 deploy key beside the checkout (`.git/cloudbongos-deploy-key` — untracked, survives `git reset --hard`, dies with the checkout), registers it on the repo **read-only** as `cloudbongos-deploy-<slug>` (idempotent by title; a stale same-title key is replaced, never duplicated), then wires the CHECKOUT: `origin` re-pointed at plain `git@github.com` and `core.sshCommand` pinned to the key with `-F /dev/null` so no `~/.ssh/config` identity can shadow it. Public repos take the unchanged shared-alias path; pushes keep preferring the owner-token HTTPS URL (task 1002317), which is why read-only suffices; teardown best-effort revokes by title. Rejected: **GitHub App installation tokens** (hourly expiry means every unattended pull needs a minting sidecar or a hub round-trip) and **registering the shared platform key on the repo** (one shared secret granting the whole platform durable access to private code; per-repo keys keep the blast radius to one repo and are visible to the owner in their own repo settings). Follow-ups: the manage page's flip-to-private stays refused until already-provisioned instances get the same wiring, and builder dev boxes of a private-repo project need their own leg.](<redacted>.md) | provisioning / security |
|
|
253
|
+
| 0177 | [**A goal membership is a `membership_kind`, and the rename ships expand-then-contract** ([task 1003013](https://cloudbongos.com/builders#/task/1003013), goal 1000068). ADR 0174 reserved `role` for the three crafts and enforced it on the GOVERNMENT surface only — by design, since `builders.system_role` and Discord's own noun are legitimate. That left a **fourth** use uncatalogued: `goal_members.role` (`'lead' | 'member'`), the MANAGER predicate behind goal authorization. Renamed to **`membership_kind`** — values unchanged, because the noun was the problem; `rank` is wrong (not a ladder position) and `position` is worse (ADR 0174 uses it as the prose word for a rank, recreating the "rank-role" qualifier collision). A boolean `is_lead` was seriously considered and rejected: the text rename keeps the copied CHECK and stays a reversible change on a live authz column, where `ADD boolean; UPDATE; DROP text` is data-destructive. The migration is **expand-then-contract, never `RENAME COLUMN`** — auto-rollback reverts the code pin and NOT the DB, so `core_220` ADDs the column and keeps the pair in lockstep with a BEFORE trigger (direction decided by which side changed), and a later task DROPs the old column with the `-- forward-unsafe` pragma, a backup, and its own window; no `SET NOT NULL` in step 1, because a static NOT NULL on a column the previous release never heard of is what the forward-safety gate rightly flags. The API field renames with it and carries **no `role` alias** (ADR 0174's Lineage lesson: a time-boxed shim outlives its release), pinned by the test of its absence. The reservation gains a **second narrow wall** rather than a widened first one — line-scoped inside the 5k-line `db.js` to the lines that name `goal_members`, so no allowlist is needed. A **fifth** use (`project_memberships.role`) is catalogued and filed, with this as its pattern.](<redacted>.md) | naming / lifecycle |
|
|
253
254
|
|
|
254
255
|
> ⚠️ **Numbering collision (known):** two ADRs share `0008`. The numbers were assigned in parallel sessions before the lock could be enforced. The files are kept under their existing filenames to preserve git history, but the table disambiguates them as `0008-a` / `0008-b`. New ADRs should resume at `0040` to avoid further collisions. (`0024` is also a real on-disk duplicate: the cloneable-repo/local-first-memory ADR and the multi-agent-system-architecture ADR were both numbered `0024` in parallel; both keep their filenames, disambiguated as `0024-a` / `0024-b` like `0008`. Likewise `0025` ended up with two on-disk files — the offsite-backup ADR and the structured-criterion↔task-link ADR — both kept under their existing filenames and disambiguated as `0025-a` / `0025-b`; separately, the BFG ADR had also claimed `0025` on 2026-05-30 and yielded to `0026`. A third happened 2026-06-06: `0036` was claimed in parallel by the builders-subdomain ADR ([#726](https://example.com/builders#/task/726)) and this Discord-channels ADR ([#727](https://example.com/builders#/task/727)); the Discord-channels ADR yielded to `0037`. A fourth landed 2026-06-10: `0044` was claimed in parallel by the palette-replacement ADR and the per-box game-preview ADR ([#897](https://example.com/builders#/task/897)); both keep their filenames, disambiguated as `0044-a` / `0044-b` like `0008`. A fifth was caught pre-land 2026-06-13: `0050` was claimed in parallel by the device-flow-admission ADR ([#1000](https://example.com/builders#/task/1000)) and the sandbox-for-everyone ADR ([#1001](https://example.com/builders#/task/1001)); the latter yielded to `0052` before merging, so there is no `0050` duplicate. A sixth was caught pre-land 2026-06-21: the tool-agnostic-design-layer ADR ([#1375](https://example.com/builders#/task/1375)) first collided at `0079` with the value-gated-autonomous-engagement ADR, then at `0080` with the llm-cache-api-transport ADR (both part of the 0075–0080 LLM-cost-reduction series, landed on `main` in parallel); it yielded to `0081` before merging, so there is no `0079`/`0080` design-layer duplicate. A seventh landed 2026-06-23: `0084` was claimed in parallel by the auto-derived-per-task-rank-floor ADR and the remove-default-spend-cap ADR (task 1499); the rank-floor ADR landed on `main` first, so the spend-cap ADR yielded to `0085`, leaving no `0084` duplicate. An eighth landed 2026-06-27: `0095` was claimed in parallel by the borrowed-memory-and-retrieval-concepts ADR (PR #459, landed first) and the cross-agent-context-management ADR ([#1651](https://example.com/builders#/task/1651), PR #460); both keep their filenames, disambiguated as `0095-a` / `0095-b` like `0008`. These two are companions — `0095-a` adopts the memory/retrieval concepts and explicitly defers the grep-vs-semantic question to `0095-b`.)
|
package/docs/api/openapi.json
CHANGED
|
@@ -4515,7 +4515,7 @@
|
|
|
4515
4515
|
"goals"
|
|
4516
4516
|
],
|
|
4517
4517
|
"summary": "PATCH /goals/:id/members/:builderId",
|
|
4518
|
-
"description": "PATCH /goals/:id/members/:builderId —
|
|
4518
|
+
"description": "PATCH /goals/:id/members/:builderId — membership management by the goal's own authority (task 1735): the owner, a manager, or an Archon promotes member→lead(manager); ONLY the owner (or an Archon) demotes; the owner's row is never demotable. The body field is `membership_kind` (task 1003013 — ADR 0174 reserves `role` for the three crafts). The decision is the pure ordered gate above (authorizeMembershipKindChange); the DB write re-asserts the owner-demotion guard atomically (setGoalMembershipKind), so a race cannot demote an owner. rank: any-builder (requireBuilder) + the in-handler (owner | manager | Archon) gate — like the authoring keystone, the wall is in-handler, NOT a rank middleware. Pinned in route-rank-check.MODULE_EXPECTED_RANKS['lifecycle'].\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
|
|
4519
4519
|
"x-rank": "any-builder",
|
|
4520
4520
|
"x-source": "modules/lifecycle/routes/goals.js",
|
|
4521
4521
|
"parameters": [
|
|
@@ -12819,7 +12819,7 @@
|
|
|
12819
12819
|
"versions"
|
|
12820
12820
|
],
|
|
12821
12821
|
"summary": "POST /versions/:id/done-when",
|
|
12822
|
-
"description": "Auth (Metic+, then the goal's own authority): create a done-when criterion at RUNTIME. ADR 0105 moved scope-truth seeds (versions/goals/criteria) out of the normal migration path, so a brand-new criterion on a RUNNING instance can no longer be added by a plain migration (migrations/instance/ applies only under the owner opt-in GDS_APPLY_INSTANCE_SEEDS=1, a DR step). This is the vector a planning session uses to add a criterion to a live version WITHOUT a droplet migrate. ADR 0154 — authority over a GOAL's criteria follows GOAL OWNERSHIP, not global rank. A Metic who may create the goal (POST /goals) and author the tasks under it (POST /tasks, ADR 0090) may now write the criteria those tasks gate — but only inside a goal they own or manage. Deliberately NOT a flat re-rank: - An unsatisfied criterion permanently blocks version close (version-close.js counts satisfied === total per VERSION, regardless of goal) and goal auto-achieve (db.achieveGoalIfComplete). There is NO delete route, so it cannot be undone via the API — the same backward ratchet as unsatisfy, which stays Archon. - So a non-Archon must name a goal_id (version-level criteria stay Archon), must be that goal's OWNER or MANAGER (isGoalOwnerOrManager — the same predicate behind visibility/invite/
|
|
12822
|
+
"description": "Auth (Metic+, then the goal's own authority): create a done-when criterion at RUNTIME. ADR 0105 moved scope-truth seeds (versions/goals/criteria) out of the normal migration path, so a brand-new criterion on a RUNNING instance can no longer be added by a plain migration (migrations/instance/ applies only under the owner opt-in GDS_APPLY_INSTANCE_SEEDS=1, a DR step). This is the vector a planning session uses to add a criterion to a live version WITHOUT a droplet migrate. ADR 0154 — authority over a GOAL's criteria follows GOAL OWNERSHIP, not global rank. A Metic who may create the goal (POST /goals) and author the tasks under it (POST /tasks, ADR 0090) may now write the criteria those tasks gate — but only inside a goal they own or manage. Deliberately NOT a flat re-rank: - An unsatisfied criterion permanently blocks version close (version-close.js counts satisfied === total per VERSION, regardless of goal) and goal auto-achieve (db.achieveGoalIfComplete). There is NO delete route, so it cannot be undone via the API — the same backward ratchet as unsatisfy, which stays Archon. - So a non-Archon must name a goal_id (version-level criteria stay Archon), must be that goal's OWNER or MANAGER (isGoalOwnerOrManager — the same predicate behind visibility/invite/membership management), and the goal must be open on a planning/building version. That bounds the blast radius to a workspace they already hold. An unauthorized caller gets an honest 403 goal_not_yours (task 1002614 — goal reads are open to any builder, so there is no existence oracle for a mask to protect). Pinned in route-rank-check.MODULE_EXPECTED_RANKS. rank: metic+archon — criterion create, walled by goal ownership (ADR 0154).\n\n**Rank:** `metic+archon` — Metic or Archon rank (review/triage powers).\n\n**Permissions:** `criterion.create` (all required).",
|
|
12823
12823
|
"x-rank": "metic+archon",
|
|
12824
12824
|
"x-source": "modules/lifecycle/routes/done-when.js",
|
|
12825
12825
|
"x-permissions": [
|
|
@@ -14447,12 +14447,12 @@
|
|
|
14447
14447
|
"PatchGoalsIdMembersBuilderIdRequest": {
|
|
14448
14448
|
"type": "object",
|
|
14449
14449
|
"properties": {
|
|
14450
|
-
"
|
|
14450
|
+
"membership_kind": {
|
|
14451
14451
|
"type": "string"
|
|
14452
14452
|
}
|
|
14453
14453
|
},
|
|
14454
14454
|
"required": [
|
|
14455
|
-
"
|
|
14455
|
+
"membership_kind"
|
|
14456
14456
|
],
|
|
14457
14457
|
"additionalProperties": false
|
|
14458
14458
|
},
|
|
@@ -15866,7 +15866,7 @@
|
|
|
15866
15866
|
"PostGoalsIdMembersRequest": {
|
|
15867
15867
|
"type": "object",
|
|
15868
15868
|
"properties": {
|
|
15869
|
-
"
|
|
15869
|
+
"membership_kind": {
|
|
15870
15870
|
"type": "string"
|
|
15871
15871
|
},
|
|
15872
15872
|
"builder_id": {
|
package/docs/api-reference.md
CHANGED
|
@@ -201,8 +201,8 @@ Base path: `/api/bongos`. Ranks (enforced server-side, [ADR 0016](adr/<redacted>
|
|
|
201
201
|
| POST | `/api/bongos/goals/:id/invitations/:reqId/respond` | `any-builder` | `action` | POST /goals/:id/invitations/:reqId/respond { action: accept\|decline } — ONLY the invited builder resolves their OWN pending invite (task … |
|
|
202
202
|
| POST | `/api/bongos/goals/:id/join` | `any-builder` | — | POST /goals/:id/join — join an open goal. |
|
|
203
203
|
| POST | `/api/bongos/goals/:id/leave` | `any-builder` | — | POST /goals/:id/leave — leave a goal you're in. |
|
|
204
|
-
| POST | `/api/bongos/goals/:id/members` | `any-builder` | `
|
|
205
|
-
| PATCH | `/api/bongos/goals/:id/members/:builderId` | `any-builder` | `
|
|
204
|
+
| POST | `/api/bongos/goals/:id/members` | `any-builder` | `membership_kind`, `builder_id` | POST /goals/:id/members — the goal's own authority (owner \| manager \| Archon) invites a builder to the goal (task 1762: widened from Arch… |
|
|
205
|
+
| PATCH | `/api/bongos/goals/:id/members/:builderId` | `any-builder` | `membership_kind` | PATCH /goals/:id/members/:builderId — membership management by the goal's own authority (task 1735): the owner, a manager, or an Archon p… |
|
|
206
206
|
| POST | `/api/bongos/goals/:id/reopen` | `metic+archon` | — | POST /goals/:id/reopen — bring an archived goal back to open (ADR 0086 §6 / BV1.R64). |
|
|
207
207
|
| GET | `/api/bongos/goals/:id/requests` | `any-builder` | — | GET /goals/:id/requests — the goal's own pending invite/join-request queue (task 1953): every pending row on THIS goal, both kinds — outs… |
|
|
208
208
|
| POST | `/api/bongos/goals/:id/requests` | `any-builder` | `note` | POST /goals/:id/requests — a builder asks to join a (private or protected) goal (task 1952 / ADR 0112). |
|
|
@@ -185,7 +185,9 @@ or missing ranks fail closed.
|
|
|
185
185
|
[ADR 0154](adr/<redacted>.md), also `POST /api/gds/versions/:id/done-when`
|
|
186
186
|
(criterion **create**) — but walled by goal ownership, not by rank alone: a non-Archon must name a
|
|
187
187
|
`goal_id`, on an open (`planning`/`building`) version, in an `open` goal they **own or manage** (the same
|
|
188
|
-
`authorizeGoalManagement` wall as goal visibility/invites/
|
|
188
|
+
`authorizeGoalManagement` wall as goal visibility/invites/memberships — the membership column is
|
|
189
|
+
`goal_members.membership_kind` ('lead' | 'member') since task 1003013, never `role`, which
|
|
190
|
+
[ADR 0174](adr/<redacted>.md) reserves for the three crafts). Version-level criteria stay Archon;
|
|
189
191
|
`done-when/:id/unsatisfy` moved here in [ADR 0157](adr/<redacted>.md). The floor here is
|
|
190
192
|
`metic`, so **`thetes` does NOT pass these** — it sits below `metic` on the ladder and inherits none
|
|
191
193
|
of its grants (ADR 0034); `archon` and the divine tiers above it do.
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
-- core_220_goal_members_membership_kind.sql — the FOURTH "role" leaves the schema
|
|
2
|
+
-- (task 1003013; ADR 0174 reserves `role` for the three crafts, ADR 0177 records
|
|
3
|
+
-- this two-step).
|
|
4
|
+
--
|
|
5
|
+
-- `goal_members.role` holds 'lead' | 'member' — a per-goal position, unrelated to
|
|
6
|
+
-- the rank ladder and unrelated to the three crafts. ADR 0174 reserves the word
|
|
7
|
+
-- `role` for Artist / Ideator / Builder, so this column is renamed to
|
|
8
|
+
-- `membership_kind` ('lead' | 'member' unchanged: the values were never the
|
|
9
|
+
-- problem, the noun was).
|
|
10
|
+
--
|
|
11
|
+
-- ── WHY EXPAND-THEN-CONTRACT AND NOT `RENAME COLUMN` ────────────────────────
|
|
12
|
+
-- This column gates goal-membership AUTHORIZATION (goal-authz.js's MANAGER
|
|
13
|
+
-- predicate). `bongos upgrade` auto-rollback reverts the CODE PIN BUT NOT THE DB,
|
|
14
|
+
-- so a bare RENAME plus a rolled-back deploy would leave the previous release
|
|
15
|
+
-- querying a column that no longer exists — every membership check erroring on a
|
|
16
|
+
-- live authz path. That is exactly what tests/migrations_forward_safe.mjs exists
|
|
17
|
+
-- to prevent, and it is why this migration is forward-SAFE with no pragma:
|
|
18
|
+
--
|
|
19
|
+
-- STEP 1 (here) ADD the new column, backfill it, and keep the two in lockstep
|
|
20
|
+
-- with a trigger. BOTH names work: new code writes
|
|
21
|
+
-- membership_kind, any older code still writing `role` stays
|
|
22
|
+
-- correct, and a rollback lands on a schema it fully understands.
|
|
23
|
+
-- STEP 2 (later) DROP `role` + the trigger, once every released version that
|
|
24
|
+
-- reads it is behind the live pin. That step IS forward-unsafe
|
|
25
|
+
-- and ships with the pragma, a backup, and its own deploy window.
|
|
26
|
+
--
|
|
27
|
+
-- Idempotent: safe to re-run.
|
|
28
|
+
|
|
29
|
+
BEGIN;
|
|
30
|
+
|
|
31
|
+
-- 1. The new noun.
|
|
32
|
+
--
|
|
33
|
+
-- NO `SET NOT NULL` HERE, DELIBERATELY. The trigger below fills the column on
|
|
34
|
+
-- every write (a BEFORE ROW trigger runs ahead of constraint checks, so even an
|
|
35
|
+
-- older release inserting only `role` lands a complete row) — but a static
|
|
36
|
+
-- NOT NULL on a column the previous release has never heard of is exactly the
|
|
37
|
+
-- shape tests/migrations_forward_safe.mjs flags, and it would be flagged
|
|
38
|
+
-- RIGHTLY: the guarantee would then rest on the trigger surviving. The trigger
|
|
39
|
+
-- IS the enforcement for one release; NOT NULL arrives in step 2, when `role`
|
|
40
|
+
-- is dropped and nothing writes the old name any more.
|
|
41
|
+
ALTER TABLE goal_members
|
|
42
|
+
ADD COLUMN IF NOT EXISTS membership_kind text;
|
|
43
|
+
|
|
44
|
+
UPDATE goal_members SET membership_kind = role WHERE membership_kind IS NULL;
|
|
45
|
+
|
|
46
|
+
ALTER TABLE goal_members
|
|
47
|
+
ALTER COLUMN membership_kind SET DEFAULT 'member';
|
|
48
|
+
|
|
49
|
+
DO $$
|
|
50
|
+
BEGIN
|
|
51
|
+
-- The same two values, the same CHECK — a copied constraint, not a widened one.
|
|
52
|
+
IF NOT EXISTS (
|
|
53
|
+
SELECT 1 FROM pg_constraint
|
|
54
|
+
WHERE conrelid = 'goal_members'::regclass AND conname = 'goal_members_membership_kind_check'
|
|
55
|
+
) THEN
|
|
56
|
+
ALTER TABLE goal_members
|
|
57
|
+
ADD CONSTRAINT goal_members_membership_kind_check
|
|
58
|
+
CHECK (membership_kind IN ('lead', 'member'));
|
|
59
|
+
END IF;
|
|
60
|
+
END $$;
|
|
61
|
+
|
|
62
|
+
-- 2. The lockstep. A BEFORE trigger, so both columns are written by the same
|
|
63
|
+
-- statement that wrote either one — no second UPDATE, no window where the two
|
|
64
|
+
-- disagree, and nothing for application code to remember.
|
|
65
|
+
--
|
|
66
|
+
-- Direction is decided by WHICH SIDE CHANGED, not by a preferred column: new
|
|
67
|
+
-- code sets membership_kind and `role` follows; the historical
|
|
68
|
+
-- lifecycle_001_goal_first_leads.sql seed (and any pre-upgrade code, including
|
|
69
|
+
-- the release a rollback would restore) sets `role` and membership_kind
|
|
70
|
+
-- follows. On INSERT the new column wins when it was supplied, because `role`
|
|
71
|
+
-- carries a DEFAULT that makes "omitted" and "explicitly 'member'"
|
|
72
|
+
-- indistinguishable.
|
|
73
|
+
CREATE OR REPLACE FUNCTION goal_members_membership_kind_lockstep() RETURNS trigger AS $$
|
|
74
|
+
BEGIN
|
|
75
|
+
IF TG_OP = 'INSERT' THEN
|
|
76
|
+
IF NEW.membership_kind IS NOT NULL THEN
|
|
77
|
+
NEW.role := NEW.membership_kind;
|
|
78
|
+
ELSE
|
|
79
|
+
NEW.membership_kind := COALESCE(NEW.role, 'member');
|
|
80
|
+
END IF;
|
|
81
|
+
ELSE
|
|
82
|
+
IF NEW.membership_kind IS DISTINCT FROM OLD.membership_kind THEN
|
|
83
|
+
NEW.role := NEW.membership_kind;
|
|
84
|
+
ELSIF NEW.role IS DISTINCT FROM OLD.role THEN
|
|
85
|
+
NEW.membership_kind := NEW.role;
|
|
86
|
+
END IF;
|
|
87
|
+
END IF;
|
|
88
|
+
RETURN NEW;
|
|
89
|
+
END;
|
|
90
|
+
$$ LANGUAGE plpgsql;
|
|
91
|
+
|
|
92
|
+
DROP TRIGGER IF EXISTS goal_members_membership_kind_lockstep ON goal_members;
|
|
93
|
+
CREATE TRIGGER goal_members_membership_kind_lockstep
|
|
94
|
+
BEFORE INSERT OR UPDATE ON goal_members
|
|
95
|
+
FOR EACH ROW EXECUTE FUNCTION goal_members_membership_kind_lockstep();
|
|
96
|
+
|
|
97
|
+
INSERT INTO schema_migrations (version) VALUES ('core_220_goal_members_membership_kind')
|
|
98
|
+
ON CONFLICT DO NOTHING;
|
|
99
|
+
|
|
100
|
+
COMMIT;
|