@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.
@@ -1741,6 +1741,71 @@ function vocabFilesToScan() {
1741
1741
  return out;
1742
1742
  }
1743
1743
 
1744
+ // ── Check 19b — the FOURTH "role": a goal membership is not a craft either ──
1745
+ //
1746
+ // task 1003013 / ADR 0177. ADR 0174 reserved `role` for the three crafts and R07
1747
+ // (checkVocabularyWall above) enforces it on the GOVERNMENT surface — deliberately
1748
+ // only there, because `builders.system_role` is a core machine-identity column and
1749
+ // Discord's API owns its own "role" noun. Goal membership was a fourth,
1750
+ // independent use of the word (`goal_members.role` = 'lead' | 'member'), which is
1751
+ // exactly why it survived the rename: the rule was silent on it, as designed.
1752
+ //
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`.
1758
+ //
1759
+ // Naming the rule stays legal — a line that cites ADR 0174 / ADR 0177 / task
1760
+ // 1003013 is meta-commentary about the ban, the same principle as
1761
+ // VOCAB_HISTORICAL_REF above. `role="…"` is stripped first: that is HTML's ARIA
1762
+ // attribute, a different word spelled the same.
1763
+ const GOAL_VOCAB_SURFACE = [
1764
+ 'modules/lifecycle/routes/goals.js',
1765
+ 'modules/lifecycle/goal-authz.js',
1766
+ 'modules/lifecycle/routes/done-when.js',
1767
+ 'modules/hall-ui/public/goals-page.js',
1768
+ 'modules/hall-ui/public/goals.html',
1769
+ 'modules/hall-ui/public/goals.css',
1770
+ ];
1771
+ // modules/lifecycle/db.js is 5k+ lines and legitimately says "role" about ranks
1772
+ // (the rank-sync port, builders.system_role). Scanning it whole would need an
1773
+ // allowlist longer than the rule; scanning the LINES THAT NAME THE TABLE catches
1774
+ // the only thing that can regress here — a membership query reaching for the old
1775
+ // 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/;
1778
+
1779
+ function checkGoalMembershipVocabulary() {
1780
+ const violations = [];
1781
+ let scanned = 0;
1782
+ const scanFile = (rel, lineFilter = null) => {
1783
+ const abs = path.join(CORE_ROOT, rel);
1784
+ let src;
1785
+ try { src = fs.readFileSync(abs, 'utf8'); } catch { return; }
1786
+ scanned += 1;
1787
+ src.split('\n').forEach((line, i) => {
1788
+ if (lineFilter && !lineFilter.test(line)) return;
1789
+ const scanLine = line.replace(/\srole\s*=\s*("[^"]*"|'[^']*')/g, ' ');
1790
+ if (!/\brole/i.test(scanLine)) return;
1791
+ 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)}`);
1793
+ });
1794
+ };
1795
+ for (const rel of GOAL_VOCAB_SURFACE) scanFile(rel);
1796
+ for (const { file, mustMention } of GOAL_VOCAB_LINE_SCOPED) scanFile(file, mustMention);
1797
+ return {
1798
+ name: 'a goal membership is a "membership_kind", never a role (ADR 0177)',
1799
+ ok: violations.length === 0,
1800
+ hardFail: violations.length > 0,
1801
+ violations,
1802
+ 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 — `
1805
+ + 'a line that CITES the rule is the only exemption.',
1806
+ };
1807
+ }
1808
+
1744
1809
  function checkVocabularyWall() {
1745
1810
  const violations = [];
1746
1811
  let scanned = 0;
@@ -1799,6 +1864,7 @@ const CHECKS = [
1799
1864
  checkCoreRequiresExcludedModule, // Check 16b — task 2135: core→excluded-module require guard (R89 split)
1800
1865
  checkErrorEnvelope, // Check 17 — BV1 R03 / task 1990: no route hand-rolls a flat error (ADR 0116)
1801
1866
  checkVocabularyWall, // Check 19 — ADR 0174 / goal 1000068 R07: "role" is a craft, never a rank
1867
+ checkGoalMembershipVocabulary, // Check 19b — ADR 0177 / task 1003013: the FOURTH "role" (goal membership)
1802
1868
  checkWriteRoutesValidated, // Check 18 — BV1 R12 / task 1999: every body-reading write route validates (ADR 0118)
1803
1869
  ];
1804
1870
 
@@ -1830,6 +1896,8 @@ if (require.main === module) main();
1830
1896
  module.exports = {
1831
1897
  checkVocabularyWall,
1832
1898
  VOCAB_ALLOW,
1899
+ checkGoalMembershipVocabulary,
1900
+ GOAL_VOCAB_SURFACE,
1833
1901
  runAll,
1834
1902
  resolvesToHostGame,
1835
1903
  checkCoreHostBoundary,
package/src/module-api.js CHANGED
@@ -903,8 +903,10 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
903
903
  // landed since 1.19.236 with no explicit bump. run 32164526454. (task 1002620)
904
904
  // 1.19.238 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
905
905
  // landed since 1.19.237 with no explicit bump. run 32172843539. (task 1002620)
906
+ // 1.19.239 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
907
+ // landed since 1.19.238 with no explicit bump. run 32174983930. (task 1002620)
906
908
  // ---------------------------------------------------------------------------
907
- const CORE_VERSION = '1.19.238'; // CI auto-patch carrier (ADR 0161); see changelog above
909
+ const CORE_VERSION = '1.19.239'; // CI auto-patch carrier (ADR 0161); see changelog above
908
910
 
909
911
  // A namespaced logger so a module's log lines are attributable + consistent.
910
912
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -50,8 +50,8 @@ const G_ACHIEVED = { id: 43, version_id: 'BONGOS-V1', status: 'achieved', create
50
50
  const G_ARCHIVED = { id: 44, version_id: 'BONGOS-V1', status: 'archived', created_by: OWNER };
51
51
  const G_SYSTEM = { id: 45, version_id: 'BONGOS-V1', status: 'open', created_by: null };
52
52
 
53
- const LEAD = { role: 'lead' };
54
- const PLAIN = { role: 'member' };
53
+ const LEAD = { membership_kind: 'lead' };
54
+ const PLAIN = { membership_kind: 'member' };
55
55
 
56
56
  const ask = (o) => authorizeCriterionCreate({ version: V_BUILDING, actorRank: 'metic', ...o });
57
57
 
@@ -62,12 +62,12 @@ await test('the goal OWNER may author their goal\'s criterion', () => {
62
62
  assert.equal(d.ok, true);
63
63
  });
64
64
 
65
- await test('owner authority is created_by, not the role row — an owner with no membership still passes', () => {
65
+ await test('owner authority is created_by, not the membership row — an owner with no membership still passes', () => {
66
66
  const d = ask({ goal: G_OPEN, actorId: OWNER, actorMembership: null });
67
67
  assert.equal(d.ok, true, 'created_by is the fixed authority (goal-authz isGoalOwner)');
68
68
  });
69
69
 
70
- await test('a MANAGER (goal_members.role=lead) may author', () => {
70
+ await test('a MANAGER (goal_members.membership_kind=lead) may author', () => {
71
71
  const d = ask({ goal: G_OPEN, actorId: MANAGER, actorMembership: LEAD });
72
72
  assert.equal(d.ok, true);
73
73
  });
@@ -162,8 +162,8 @@ await test('detector: no inputs → no conflicts (and never throws)', () => {
162
162
  // =========================================================================
163
163
 
164
164
  const OPEN_GOAL = { id: 7, status: 'open', created_by: '1' };
165
- const manager = (builderId) => ({ goal_id: 7, builder_id: builderId, role: 'lead' });
166
- const member = (builderId) => ({ goal_id: 7, builder_id: builderId, role: 'member' });
165
+ const manager = (builderId) => ({ goal_id: 7, builder_id: builderId, membership_kind: 'lead' });
166
+ const member = (builderId) => ({ goal_id: 7, builder_id: builderId, membership_kind: 'member' });
167
167
 
168
168
  await test('gate: owner / manager / archon pass; ordering goal_not_found → goal_not_open → not_owner_or_manager', () => {
169
169
  for (const actor of [
@@ -182,7 +182,7 @@ await test('gate: owner / manager / archon pass; ordering goal_not_found → goa
182
182
  assert.equal(denied.body.error, 'not_owner_or_manager', 'a plain member cannot manage');
183
183
  });
184
184
 
185
- await test('gate: owner authority is created_by, not the role row', () => {
185
+ await test('gate: owner authority is created_by, not the membership row', () => {
186
186
  const d = authorizeGoalManagement({ goal: OPEN_GOAL, actorId: 1, actorRank: 'metic', actorMembership: member(1) });
187
187
  assert.deepEqual(d, { ok: true });
188
188
  });
@@ -1,18 +1,18 @@
1
- // tests/goal_member_roles.mjs — the goal owner/manager authority model (task 1735).
1
+ // tests/goal_member_kinds.mjs — the goal owner/manager authority model (task 1735).
2
2
  //
3
3
  // The model: the OWNER is goals.created_by — the fixed authority, set at create
4
- // and moved only via POST /goals/:id/transfer. MANAGER is the goal_members.role
4
+ // and moved only via POST /goals/:id/transfer. MANAGER is the goal_members.membership_kind
5
5
  // 'lead' value (the hall renders it "manager"), owner-equivalent EXCEPT that a
6
6
  // manager cannot demote a fellow manager (owner-only) and cannot transfer
7
7
  // ownership. The owner's own row is never demotable. This file pins, in the
8
8
  // goal_routes pattern:
9
9
  //
10
- // 1. the PURE ordered gates (authorizeMemberRoleChange +
10
+ // 1. the PURE ordered gates (authorizeMembershipKindChange +
11
11
  // authorizeOwnershipTransfer) — every rejection, DB-free, ordering
12
12
  // included (the authorizeGoalTaskCreate precedent);
13
13
  // 2. the request-validation schema of the PATCH route;
14
14
  // 3. the db helpers' SQL contracts via the SQL-aware fake pool —
15
- // getGoalMember / setGoalMemberRole (the atomic owner-demotion guard) /
15
+ // getGoalMember / setGoalMembershipKind (the atomic owner-demotion guard) /
16
16
  // transferGoalOwnership (atomic created_by move + lead upsert);
17
17
  // createGoal's creator-becomes-owner CTE lives in tests/goal_tier.mjs;
18
18
  // 4. the route-rank PINS: both routes stay 'any-builder' (the wall is
@@ -26,14 +26,14 @@
26
26
  // B manager promotes a member → manager → ALLOW (owner-equivalent)
27
27
  // C owner demotes a manager → member → ALLOW
28
28
  // D manager demotes a fellow manager → 403 owner_only_demotion
29
- // E owner/Archon demotes the OWNER's row → 409 owner_role_fixed
30
- // F plain member tries any role change → 403 not_owner_or_manager
31
- // G non-member Metic tries any role change → 403 not_owner_or_manager
29
+ // E owner/Archon demotes the OWNER's row → 409 owner_membership_fixed
30
+ // F plain member tries any membership change → 403 not_owner_or_manager
31
+ // G non-member Metic tries any membership change → 403 not_owner_or_manager
32
32
  // H Archon (not a member) promotes and demotes → ALLOW (the Archon arm)
33
33
  // I target isn't a member → 404 not_a_member
34
34
  // J goal doesn't exist → 404 goal_not_found
35
35
  // K goal not open (achieved/archived) → 409 goal_not_open
36
- // L role already as requested → no-op (ok, changed:false)
36
+ // L membership already as requested → no-op (ok, changed:false)
37
37
  // O1 ordering: unauthorized actor + non-member target → not_owner_or_manager (no membership leak)
38
38
  // O2 ordering: closed goal short-circuits everything → goal_not_open
39
39
  //
@@ -47,7 +47,7 @@
47
47
  // T6 transfer to the current owner → no-op
48
48
  // T7 closed goal / unknown goal → 409 / 404
49
49
  //
50
- // Run: node tests/goal_member_roles.mjs
50
+ // Run: node tests/goal_member_kinds.mjs
51
51
 
52
52
  import { strict as assert } from 'node:assert';
53
53
  import { createRequire } from 'node:module';
@@ -57,8 +57,8 @@ process.env.NODE_ENV = 'test';
57
57
 
58
58
  const require = createRequire(import.meta.url);
59
59
  const { validate } = require('../src/bongos/routes/_helpers.js');
60
- const { authorizeMemberRoleChange, authorizeOwnershipTransfer } = require('../modules/lifecycle/routes/goals.js');
61
- const { getGoalMember, setGoalMemberRole, transferGoalOwnership } = require('../modules/lifecycle/db.js');
60
+ const { authorizeMembershipKindChange, authorizeOwnershipTransfer } = require('../modules/lifecycle/routes/goals.js');
61
+ const { getGoalMember, setGoalMembershipKind, transferGoalOwnership } = require('../modules/lifecycle/db.js');
62
62
  const { MODULE_EXPECTED_RANKS } = require('../src/bongos/route-rank-check.js');
63
63
 
64
64
  const { test, summary } = makeRunner();
@@ -74,49 +74,49 @@ const find = (queries, re) => queries.find((q) => re.test(String(q.sql).replace(
74
74
  // must compare loosely (String-normalized), exactly as the route receives it.
75
75
  const OPEN_GOAL = { id: 7, status: 'open', created_by: '1' };
76
76
  const ORPHAN_GOAL = { id: 8, status: 'open', created_by: null }; // migration-160 default
77
- const manager = (builderId) => ({ goal_id: 7, builder_id: builderId, role: 'lead', joined_at: 'T0' });
78
- const member = (builderId) => ({ goal_id: 7, builder_id: builderId, role: 'member', joined_at: 'T1' });
77
+ const manager = (builderId) => ({ goal_id: 7, builder_id: builderId, membership_kind: 'lead', joined_at: 'T0' });
78
+ const member = (builderId) => ({ goal_id: 7, builder_id: builderId, membership_kind: 'member', joined_at: 'T1' });
79
79
 
80
80
  // =========================================================================
81
- // 1a. authorizeMemberRoleChange — the role-change matrix
81
+ // 1a. authorizeMembershipKindChange — the membership-change matrix
82
82
  // =========================================================================
83
83
 
84
84
  await test('A: the owner promotes a member → manager — ALLOW', () => {
85
- const d = authorizeMemberRoleChange({
85
+ const d = authorizeMembershipKindChange({
86
86
  goal: OPEN_GOAL, actorId: 1, actorRank: 'metic', actorMembership: manager(1),
87
- targetMembership: member(2), targetId: 2, role: 'lead',
87
+ targetMembership: member(2), targetId: 2, membershipKind: 'lead',
88
88
  });
89
89
  assert.deepEqual(d, { ok: true });
90
90
  });
91
91
 
92
- await test('A2: owner authority is created_by, not the role row — owner with a plain member row still promotes', () => {
93
- const d = authorizeMemberRoleChange({
92
+ await test('A2: owner authority is created_by, not the membership row — owner with a plain member row still promotes', () => {
93
+ const d = authorizeMembershipKindChange({
94
94
  goal: OPEN_GOAL, actorId: 1, actorRank: 'metic', actorMembership: member(1),
95
- targetMembership: member(2), targetId: 2, role: 'lead',
95
+ targetMembership: member(2), targetId: 2, membershipKind: 'lead',
96
96
  });
97
97
  assert.deepEqual(d, { ok: true });
98
98
  });
99
99
 
100
100
  await test('B: a manager promotes a member → manager — ALLOW (owner-equivalent)', () => {
101
- const d = authorizeMemberRoleChange({
101
+ const d = authorizeMembershipKindChange({
102
102
  goal: OPEN_GOAL, actorId: 3, actorRank: 'metic', actorMembership: manager(3),
103
- targetMembership: member(2), targetId: 2, role: 'lead',
103
+ targetMembership: member(2), targetId: 2, membershipKind: 'lead',
104
104
  });
105
105
  assert.deepEqual(d, { ok: true });
106
106
  });
107
107
 
108
108
  await test('C: the owner demotes a manager → member — ALLOW', () => {
109
- const d = authorizeMemberRoleChange({
109
+ const d = authorizeMembershipKindChange({
110
110
  goal: OPEN_GOAL, actorId: 1, actorRank: 'metic', actorMembership: manager(1),
111
- targetMembership: manager(3), targetId: 3, role: 'member',
111
+ targetMembership: manager(3), targetId: 3, membershipKind: 'member',
112
112
  });
113
113
  assert.deepEqual(d, { ok: true });
114
114
  });
115
115
 
116
116
  await test('D: a manager cannot demote a fellow manager — 403 owner_only_demotion', () => {
117
- const d = authorizeMemberRoleChange({
117
+ const d = authorizeMembershipKindChange({
118
118
  goal: OPEN_GOAL, actorId: 3, actorRank: 'metic', actorMembership: manager(3),
119
- targetMembership: manager(4), targetId: 4, role: 'member',
119
+ targetMembership: manager(4), targetId: 4, membershipKind: 'member',
120
120
  });
121
121
  assert.equal(d.ok, false);
122
122
  assert.equal(d.status, 403);
@@ -124,32 +124,32 @@ await test('D: a manager cannot demote a fellow manager — 403 owner_only_demot
124
124
  });
125
125
 
126
126
  await test('D2: a manager cannot demote themselves either — same owner-only rule', () => {
127
- const d = authorizeMemberRoleChange({
127
+ const d = authorizeMembershipKindChange({
128
128
  goal: OPEN_GOAL, actorId: 3, actorRank: 'metic', actorMembership: manager(3),
129
- targetMembership: manager(3), targetId: 3, role: 'member',
129
+ targetMembership: manager(3), targetId: 3, membershipKind: 'member',
130
130
  });
131
131
  assert.equal(d.body.error, 'owner_only_demotion');
132
132
  });
133
133
 
134
- await test('E: the OWNER row is never demotable — 409 owner_role_fixed (even self / Archon)', () => {
134
+ await test('E: the OWNER row is never demotable — 409 owner_membership_fixed (even self / Archon)', () => {
135
135
  for (const actor of [
136
136
  { actorId: 1, actorRank: 'metic', actorMembership: manager(1) }, // owner themselves
137
137
  { actorId: 99, actorRank: 'archon', actorMembership: null }, // an Archon
138
138
  ]) {
139
- const d = authorizeMemberRoleChange({
139
+ const d = authorizeMembershipKindChange({
140
140
  goal: OPEN_GOAL, ...actor,
141
- targetMembership: manager(1), targetId: 1, role: 'member',
141
+ targetMembership: manager(1), targetId: 1, membershipKind: 'member',
142
142
  });
143
143
  assert.equal(d.ok, false, `${actor.actorRank} demoting the owner must refuse`);
144
144
  assert.equal(d.status, 409);
145
- assert.equal(d.body.error, 'owner_role_fixed');
145
+ assert.equal(d.body.error, 'owner_membership_fixed');
146
146
  }
147
147
  });
148
148
 
149
- await test('F: a plain member cannot change roles — 403 not_owner_or_manager', () => {
150
- const d = authorizeMemberRoleChange({
149
+ await test('F: a plain member cannot change memberships — 403 not_owner_or_manager', () => {
150
+ const d = authorizeMembershipKindChange({
151
151
  goal: OPEN_GOAL, actorId: 2, actorRank: 'metic', actorMembership: member(2),
152
- targetMembership: member(5), targetId: 5, role: 'lead',
152
+ targetMembership: member(5), targetId: 5, membershipKind: 'lead',
153
153
  });
154
154
  assert.equal(d.ok, false);
155
155
  assert.equal(d.status, 403);
@@ -157,38 +157,38 @@ await test('F: a plain member cannot change roles — 403 not_owner_or_manager',
157
157
  });
158
158
 
159
159
  await test('G: a non-member (whatever their rank below archon) — 403 not_owner_or_manager', () => {
160
- const d = authorizeMemberRoleChange({
160
+ const d = authorizeMembershipKindChange({
161
161
  goal: OPEN_GOAL, actorId: 9, actorRank: 'metic', actorMembership: null,
162
- targetMembership: member(2), targetId: 2, role: 'lead',
162
+ targetMembership: member(2), targetId: 2, membershipKind: 'lead',
163
163
  });
164
164
  assert.equal(d.body.error, 'not_owner_or_manager');
165
165
  });
166
166
 
167
167
  await test('H: an Archon who is not a member may promote AND demote — the Archon arm', () => {
168
- const promote = authorizeMemberRoleChange({
168
+ const promote = authorizeMembershipKindChange({
169
169
  goal: OPEN_GOAL, actorId: 99, actorRank: 'archon', actorMembership: null,
170
- targetMembership: member(2), targetId: 2, role: 'lead',
170
+ targetMembership: member(2), targetId: 2, membershipKind: 'lead',
171
171
  });
172
172
  assert.deepEqual(promote, { ok: true });
173
- const demote = authorizeMemberRoleChange({
173
+ const demote = authorizeMembershipKindChange({
174
174
  goal: OPEN_GOAL, actorId: 99, actorRank: 'archon', actorMembership: null,
175
- targetMembership: manager(3), targetId: 3, role: 'member',
175
+ targetMembership: manager(3), targetId: 3, membershipKind: 'member',
176
176
  });
177
177
  assert.deepEqual(demote, { ok: true });
178
178
  });
179
179
 
180
180
  await test('H2: an ownerless goal (created_by NULL) is Archon-run — a manager there still cannot demote', () => {
181
- const d = authorizeMemberRoleChange({
181
+ const d = authorizeMembershipKindChange({
182
182
  goal: ORPHAN_GOAL, actorId: 3, actorRank: 'metic', actorMembership: manager(3),
183
- targetMembership: manager(4), targetId: 4, role: 'member',
183
+ targetMembership: manager(4), targetId: 4, membershipKind: 'member',
184
184
  });
185
185
  assert.equal(d.body.error, 'owner_only_demotion', 'no owner exists, so only an Archon demotes');
186
186
  });
187
187
 
188
188
  await test('I: target is not a member — 404 not_a_member', () => {
189
- const d = authorizeMemberRoleChange({
189
+ const d = authorizeMembershipKindChange({
190
190
  goal: OPEN_GOAL, actorId: 1, actorRank: 'metic', actorMembership: manager(1),
191
- targetMembership: null, targetId: 5, role: 'lead',
191
+ targetMembership: null, targetId: 5, membershipKind: 'lead',
192
192
  });
193
193
  assert.equal(d.ok, false);
194
194
  assert.equal(d.status, 404);
@@ -196,19 +196,19 @@ await test('I: target is not a member — 404 not_a_member', () => {
196
196
  });
197
197
 
198
198
  await test('J: unknown goal — 404 goal_not_found (fail-closed on missing input)', () => {
199
- const d = authorizeMemberRoleChange({
199
+ const d = authorizeMembershipKindChange({
200
200
  goal: null, actorId: 99, actorRank: 'archon', actorMembership: null,
201
- targetMembership: null, targetId: 2, role: 'lead',
201
+ targetMembership: null, targetId: 2, membershipKind: 'lead',
202
202
  });
203
203
  assert.equal(d.status, 404);
204
204
  assert.equal(d.body.error, 'goal_not_found');
205
205
  });
206
206
 
207
- await test('K: a non-open goal refuses role changes — 409 goal_not_open', () => {
207
+ await test('K: a non-open goal refuses membership changes — 409 goal_not_open', () => {
208
208
  for (const status of ['achieved', 'archived']) {
209
- const d = authorizeMemberRoleChange({
209
+ const d = authorizeMembershipKindChange({
210
210
  goal: { id: 7, status, created_by: '1' }, actorId: 1, actorRank: 'metic',
211
- actorMembership: manager(1), targetMembership: member(2), targetId: 2, role: 'lead',
211
+ actorMembership: manager(1), targetMembership: member(2), targetId: 2, membershipKind: 'lead',
212
212
  });
213
213
  assert.equal(d.ok, false, `status=${status} must refuse`);
214
214
  assert.equal(d.status, 409);
@@ -216,34 +216,34 @@ await test('K: a non-open goal refuses role changes — 409 goal_not_open', () =
216
216
  }
217
217
  });
218
218
 
219
- await test('L: role already as requested — no-op, not an error (idempotent PATCH)', () => {
220
- const d = authorizeMemberRoleChange({
219
+ await test('L: membership already as requested — no-op, not an error (idempotent PATCH)', () => {
220
+ const d = authorizeMembershipKindChange({
221
221
  goal: OPEN_GOAL, actorId: 1, actorRank: 'metic', actorMembership: manager(1),
222
- targetMembership: manager(3), targetId: 3, role: 'lead',
222
+ targetMembership: manager(3), targetId: 3, membershipKind: 'lead',
223
223
  });
224
224
  assert.deepEqual(d, { ok: true, noop: true });
225
225
  });
226
226
 
227
227
  await test('L2: the no-op wins over the demotion rules — re-demoting an already-member target', () => {
228
- const d = authorizeMemberRoleChange({
228
+ const d = authorizeMembershipKindChange({
229
229
  goal: OPEN_GOAL, actorId: 3, actorRank: 'metic', actorMembership: manager(3),
230
- targetMembership: member(2), targetId: 2, role: 'member',
230
+ targetMembership: member(2), targetId: 2, membershipKind: 'member',
231
231
  });
232
232
  assert.deepEqual(d, { ok: true, noop: true }, 'a manager "demoting" a member to member is a no-op, not owner_only_demotion');
233
233
  });
234
234
 
235
235
  await test('O1: ordering — an unauthorized actor learns nothing about the target', () => {
236
- const d = authorizeMemberRoleChange({
236
+ const d = authorizeMembershipKindChange({
237
237
  goal: OPEN_GOAL, actorId: 2, actorRank: 'metic', actorMembership: member(2),
238
- targetMembership: null, targetId: 5, role: 'lead',
238
+ targetMembership: null, targetId: 5, membershipKind: 'lead',
239
239
  });
240
240
  assert.equal(d.body.error, 'not_owner_or_manager', 'authority is checked BEFORE the target lookup');
241
241
  });
242
242
 
243
243
  await test('O2: ordering — a closed goal short-circuits before authority', () => {
244
- const d = authorizeMemberRoleChange({
244
+ const d = authorizeMembershipKindChange({
245
245
  goal: { id: 7, status: 'archived', created_by: '1' }, actorId: 2, actorRank: 'metic',
246
- actorMembership: member(2), targetMembership: null, targetId: 5, role: 'lead',
246
+ actorMembership: member(2), targetMembership: null, targetId: 5, membershipKind: 'lead',
247
247
  });
248
248
  assert.equal(d.body.error, 'goal_not_open');
249
249
  });
@@ -316,27 +316,36 @@ await test('T7: unknown goal → 404; closed goal → 409 (fail-closed ordering)
316
316
  // =========================================================================
317
317
  // 2. request-validation schema — the EXACT schema declared in goals.js's
318
318
  // PATCH /goals/:id/members/:builderId; keep in sync if the route changes.
319
- // (role's lead|member vocabulary is an in-handler check → 400 bad_role, and
319
+ // (the lead|member vocabulary is an in-handler check → 400 bad_membership_kind, and
320
320
  // the transfer route coerces builder_id in-handler — the /members precedent.)
321
321
  // =========================================================================
322
322
 
323
- const PATCH_MEMBER_ROLE_SCHEMA = {
324
- role: { required: true, type: 'string' },
323
+ const PATCH_MEMBER_SCHEMA = {
324
+ membership_kind: { required: true, type: 'string' },
325
325
  };
326
326
 
327
- await test('PATCH schema: role required', () => {
328
- const r = validate({}, PATCH_MEMBER_ROLE_SCHEMA);
329
- assert.ok(r && r.details.some((d) => d.field === 'role' && d.reason === 'missing'));
327
+ await test('PATCH schema: membership_kind required', () => {
328
+ const r = validate({}, PATCH_MEMBER_SCHEMA);
329
+ assert.ok(r && r.details.some((d) => d.field === 'membership_kind' && d.reason === 'missing'));
330
330
  });
331
331
 
332
- await test('PATCH schema: non-string role → wrong_type', () => {
333
- const r = validate({ role: 42 }, PATCH_MEMBER_ROLE_SCHEMA);
334
- assert.ok(r && r.details.some((d) => d.field === 'role' && d.reason === 'wrong_type'));
332
+ await test('PATCH schema: non-string membership_kind → wrong_type', () => {
333
+ const r = validate({ membership_kind: 42 }, PATCH_MEMBER_SCHEMA);
334
+ assert.ok(r && r.details.some((d) => d.field === 'membership_kind' && d.reason === 'wrong_type'));
335
335
  });
336
336
 
337
- await test('PATCH schema: role=lead / role=member pass', () => {
338
- assert.equal(validate({ role: 'lead' }, PATCH_MEMBER_ROLE_SCHEMA), null);
339
- assert.equal(validate({ role: 'member' }, PATCH_MEMBER_ROLE_SCHEMA), null);
337
+ await test('PATCH schema: membership_kind=lead / =member pass', () => {
338
+ assert.equal(validate({ membership_kind: 'lead' }, PATCH_MEMBER_SCHEMA), null);
339
+ assert.equal(validate({ membership_kind: 'member' }, PATCH_MEMBER_SCHEMA), null);
340
+ });
341
+
342
+ await test('the RESERVED WORD is gone from the goal-membership surface (task 1003013, ADR 0174)', () => {
343
+ // A body that still says `role` no longer satisfies the schema — the rename is
344
+ // a contract change, not an alias, so this pins the ABSENCE of the old field
345
+ // (the ADR 0174 lesson: a shim that outlives its release is a lie).
346
+ const r = validate({ role: 'lead' }, PATCH_MEMBER_SCHEMA);
347
+ assert.ok(r && r.details.some((d) => d.field === 'membership_kind' && d.reason === 'missing'),
348
+ 'the old field name buys nothing — membership_kind is required');
340
349
  });
341
350
 
342
351
  await test('transfer builder_id coercion contract: int + numeric string pass, junk rejected', () => {
@@ -350,7 +359,7 @@ await test('transfer builder_id coercion contract: int + numeric string pass, ju
350
359
  // 3. db helpers — SQL contracts via the SQL-aware fake pool
351
360
  // =========================================================================
352
361
 
353
- const MEMBER_ROW = { goal_id: 7, builder_id: 42, role: 'lead', joined_at: 'T0' };
362
+ const MEMBER_ROW = { goal_id: 7, builder_id: 42, membership_kind: 'lead', joined_at: 'T0' };
354
363
  const GOAL_ROW = {
355
364
  id: 7, version_id: 'BONGOS-V1', title: 'g', description: null, scope_modules: [],
356
365
  status: 'open', created_by: 5, succeeded_by_goal_id: null, created_at: 'T0', updated_at: 'T1',
@@ -360,16 +369,16 @@ await test('getGoalMember: SELECT by (goal_id, builder_id), returns the row or n
360
369
  const pool = fakePool((s) => (/FROM goal_members WHERE goal_id = \$1 AND builder_id = \$2/.test(s) ? { rows: [MEMBER_ROW] } : null));
361
370
  const out = await getGoalMember({ goalId: 7, builderId: 42 }, { pool });
362
371
  assert.deepEqual(out, MEMBER_ROW);
363
- const sel = find(pool.queries, /SELECT goal_id, builder_id, role, joined_at FROM goal_members/);
372
+ const sel = find(pool.queries, /SELECT goal_id, builder_id, membership_kind, joined_at FROM goal_members/);
364
373
  assert.deepEqual(sel.params, [7, 42]);
365
374
 
366
375
  const empty = fakePool(() => ({ rows: [] }));
367
376
  assert.equal(await getGoalMember({ goalId: 7, builderId: 99 }, { pool: empty }), null);
368
377
  });
369
378
 
370
- await test('setGoalMemberRole: the UPDATE carries the atomic owner-demotion guard', async () => {
379
+ await test('setGoalMembershipKind: the UPDATE carries the atomic owner-demotion guard', async () => {
371
380
  const pool = fakePool((s) => (/UPDATE goal_members/.test(s) ? { rows: [MEMBER_ROW] } : null));
372
- const out = await setGoalMemberRole({ goalId: 7, builderId: 42, role: 'lead' }, { pool });
381
+ const out = await setGoalMembershipKind({ goalId: 7, builderId: 42, membershipKind: 'lead' }, { pool });
373
382
  assert.deepEqual(out, MEMBER_ROW);
374
383
  const upd = find(pool.queries, /UPDATE goal_members/);
375
384
  assert.deepEqual(upd.params, [7, 42, 'lead']);
@@ -378,12 +387,12 @@ await test('setGoalMemberRole: the UPDATE carries the atomic owner-demotion guar
378
387
  const sql = String(upd.sql).replace(/\s+/g, ' ');
379
388
  assert.match(sql, /\$3 = 'lead' OR gm\.builder_id IS DISTINCT FROM \(SELECT g\.created_by FROM goals g WHERE g\.id = \$1\)/,
380
389
  'the owner-demotion guard is IN the statement, not only in the route gate');
381
- assert.match(sql, /RETURNING goal_id, builder_id, role, joined_at/);
390
+ assert.match(sql, /RETURNING goal_id, builder_id, membership_kind, joined_at/);
382
391
  });
383
392
 
384
- await test('setGoalMemberRole: guard blocked / no such member → null (route answers 409)', async () => {
393
+ await test('setGoalMembershipKind: guard blocked / no such member → null (route answers 409)', async () => {
385
394
  const pool = fakePool(() => ({ rows: [] }));
386
- assert.equal(await setGoalMemberRole({ goalId: 7, builderId: 42, role: 'member' }, { pool }), null);
395
+ assert.equal(await setGoalMembershipKind({ goalId: 7, builderId: 42, membershipKind: 'member' }, { pool }), null);
387
396
  });
388
397
 
389
398
  await test('transferGoalOwnership: created_by moves and the new owner upserts to lead, one statement', async () => {
@@ -393,9 +402,9 @@ await test('transferGoalOwnership: created_by moves and the new owner upserts to
393
402
  const upd = find(pool.queries, /UPDATE goals SET created_by = \$2/);
394
403
  assert.deepEqual(upd.params, [7, 5]);
395
404
  const sql = String(upd.sql).replace(/\s+/g, ' ');
396
- assert.match(sql, /INSERT INTO goal_members \(goal_id, builder_id, role\)/,
405
+ assert.match(sql, /INSERT INTO goal_members \(goal_id, builder_id, membership_kind\)/,
397
406
  'the new owner\'s membership rides in the same statement');
398
- assert.match(sql, /ON CONFLICT \(goal_id, builder_id\) DO UPDATE SET role = 'lead'/,
407
+ assert.match(sql, /ON CONFLICT \(goal_id, builder_id\) DO UPDATE SET membership_kind = 'lead'/,
399
408
  'an existing membership upgrades to lead rather than erroring');
400
409
  assert.match(sql, /updated_at = now\(\)/);
401
410
  });
@@ -409,7 +418,7 @@ await test('transferGoalOwnership: unknown goal → null', async () => {
409
418
  // 4. the route-rank pins — the wall is in-handler, the pins keep it that way
410
419
  // =========================================================================
411
420
 
412
- await test('route-rank pins: role change + transfer stay any-builder (in-handler walls)', () => {
421
+ await test('route-rank pins: membership change + transfer stay any-builder (in-handler walls)', () => {
413
422
  const pins = MODULE_EXPECTED_RANKS.lifecycle;
414
423
  assert.equal(pins['PATCH /goals/:id/members/:builderId'], 'any-builder',
415
424
  'the owner/manager wall is in-handler — a requireRank re-gate must be a red build');
@@ -1,6 +1,6 @@
1
1
  // tests/goal_membership_requests.mjs — private goals: the visibility flag + the
2
2
  // invite / join-request queue db layer (task 1951 / ADR 0112). DB-free: the
3
- // SQL-aware fake pool (the goal_member_roles.mjs precedent) pins each helper's
3
+ // SQL-aware fake pool (the goal_member_kinds.mjs precedent) pins each helper's
4
4
  // SQL contract + return shaping. The route authorization gates are task 1952's
5
5
  // tests; this file pins the query layer under them.
6
6
  //
@@ -139,7 +139,7 @@ await test('resolveMembershipRequest: a lost race (nothing pending) → null', a
139
139
  // without a membership.
140
140
  await test('acceptMembershipRequestAndAddMember: one atomic CTE; returns { request, member }', async () => {
141
141
  const REQ = { ...REQUEST_ROW, status: 'accepted' };
142
- const MEMBER = { goal_id: 1, builder_id: 2, role: 'member', joined_at: 'T2' };
142
+ const MEMBER = { goal_id: 1, builder_id: 2, membership_kind: 'member', joined_at: 'T2' };
143
143
  const pool = fakePool((s) => (/WITH resolved AS/.test(s) ? { rows: [{ request: REQ, member: MEMBER }] } : null));
144
144
  const out = await acceptMembershipRequestAndAddMember({ id: 10, resolvedBy: 5 }, { pool });
145
145
  assert.deepEqual(out, { request: REQ, member: MEMBER });
@@ -179,7 +179,7 @@ await test('listInboxFor: shapes {invitations, joinRequests, myPendingRequests}'
179
179
  await test('listInboxFor: joinRequests gates on owner (created_by) OR manager (lead)', async () => {
180
180
  const pool = fakePool(() => ({ rows: [] }));
181
181
  await listInboxFor(95, { pool });
182
- const jq = find(pool.queries, /r\.kind = 'request'.*g\.created_by = \$1.*role = 'lead'/);
182
+ const jq = find(pool.queries, /r\.kind = 'request'.*g\.created_by = \$1.*membership_kind = 'lead'/);
183
183
  assert.ok(jq, 'a join-request only reaches an owner or a manager of the goal');
184
184
  // and it must not leak: the same $1 builder id feeds every bucket.
185
185
  for (const q of pool.queries) assert.deepEqual(q.params, [95]);
@@ -207,30 +207,30 @@ await test('listPendingRequestsForGoal: pending both-kinds for one goal', async
207
207
  // stamped 'rejected' on someone who is in fact a member.
208
208
  // =========================================================================
209
209
 
210
- const ADD_MEMBER_ROW = { goal_id: 7, builder_id: 42, role: 'member', joined_at: 'T0' };
210
+ const ADD_MEMBER_ROW = { goal_id: 7, builder_id: 42, membership_kind: 'member', joined_at: 'T0' };
211
211
  const addMemberPool = () => fakePool((s) => (/^WITH added AS/.test(s) ? { rows: [ADD_MEMBER_ROW] } : null));
212
212
 
213
213
  // join-cancels-request: the joiner arrives under their own steam.
214
214
  await test('addGoalMember: a join cancels the joiner\'s pending ask in ONE statement', async () => {
215
215
  const pool = addMemberPool();
216
- const out = await addGoalMember({ goalId: 7, builderId: 42, role: 'member', resolvedBy: 42 }, { pool });
216
+ const out = await addGoalMember({ goalId: 7, builderId: 42, membershipKind: 'member', resolvedBy: 42 }, { pool });
217
217
  assert.deepEqual(out, ADD_MEMBER_ROW, 'still returns the member row — the route response shape is unchanged');
218
218
  assert.equal(pool.queries.length, 1, 'ONE statement: the cancel commits with the membership write or not at all');
219
219
  const q = find(pool.queries, /^WITH added AS/);
220
- assert.ok(/INSERT INTO goal_members[\s\S]*ON CONFLICT \(goal_id, builder_id\) DO UPDATE SET role = goal_members\.role/.test(norm(q.sql)),
221
- 'the membership upsert is the first arm, role still preserved (task 1002689)');
220
+ assert.ok(/INSERT INTO goal_members[\s\S]*ON CONFLICT \(goal_id, builder_id\) DO UPDATE SET membership_kind = goal_members\.membership_kind/.test(norm(q.sql)),
221
+ 'the membership upsert is the first arm, membership_kind still preserved (task 1002689)');
222
222
  assert.ok(/UPDATE goal_membership_requests[\s\S]*SET status = 'cancelled', resolved_by = \$4/.test(norm(q.sql)),
223
223
  'the second arm closes the ask as cancelled, attributed to the actor');
224
224
  assert.ok(/WHERE goal_id = \$1 AND builder_id = \$2 AND status = 'pending'/.test(norm(q.sql)),
225
225
  'scoped to THIS goal+builder, and only a pending row');
226
- assert.deepEqual(q.params, [7, 42, 'member', 42], 'goal, builder, role, resolvedBy — in that order');
226
+ assert.deepEqual(q.params, [7, 42, 'member', 42], 'goal, builder, membership_kind, resolvedBy — in that order');
227
227
  });
228
228
 
229
229
  // add-cancels-request: an owner/manager/Archon admits them directly. Same
230
230
  // statement; resolvedBy is the ADDER, so the audit trail names who admitted them.
231
231
  await test('addGoalMember: a direct member-add cancels the target\'s pending ask, attributed to the adder', async () => {
232
232
  const pool = addMemberPool();
233
- await addGoalMember({ goalId: 7, builderId: 42, role: 'lead', resolvedBy: 99 }, { pool });
233
+ await addGoalMember({ goalId: 7, builderId: 42, membershipKind: 'lead', resolvedBy: 99 }, { pool });
234
234
  const q = find(pool.queries, /^WITH added AS/);
235
235
  assert.deepEqual(q.params, [7, 42, 'lead', 99],
236
236
  'resolved_by is the ADDER (99), not the target (42) — who admitted them is the audit fact');
@@ -425,7 +425,7 @@ await test('routes: invitation-respond declines before the goal-state gate', ()
425
425
  // authorizeJoinRequestResponse — owner|manager|Archon resolves a join-request.
426
426
  const OWNER_GOAL = { id: 5, status: 'open', created_by: '1', visibility: 'private' };
427
427
  const REQUEST = { id: 8, goal_id: '5', builder_id: '2', kind: 'request', status: 'pending' };
428
- const mgr = (bid) => ({ goal_id: 5, builder_id: bid, role: 'lead' });
428
+ const mgr = (bid) => ({ goal_id: 5, builder_id: bid, membership_kind: 'lead' });
429
429
  await test('authorizeJoinRequestResponse: non-authority actor → 403 (before any request lookup — no leak)', () => {
430
430
  const d = authorizeJoinRequestResponse({ goal: OWNER_GOAL, actorId: 7, actorRank: 'metic', actorMembership: null, request: REQUEST, goalId: 5 });
431
431
  assert.equal(d.status, 403); assert.equal(d.body.error, 'not_owner_or_manager');