@bongos/core 1.19.237 → 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.
Files changed (33) hide show
  1. package/.bongos-core.json +53 -38
  2. package/clients/bongos-client/index.d.ts +2 -2
  3. package/docs/adr/0177-goal-membership-kind.md +96 -0
  4. package/docs/adr/README.md +1 -0
  5. package/docs/api/openapi.json +5 -5
  6. package/docs/api-reference.md +2 -2
  7. package/docs/canonical-permissions.md +3 -1
  8. package/docs/file-map.md +1 -1
  9. package/migrations/core_220_goal_members_membership_kind.sql +100 -0
  10. package/modules/discord/board-broadcast.js +118 -11
  11. package/modules/discord/db.js +44 -0
  12. package/modules/discord/migrations/discord_001_board_messages.sql +50 -0
  13. package/modules/discord/module.json +1 -0
  14. package/modules/discord/routes/discord.js +6 -0
  15. package/modules/discord/webhook.js +53 -8
  16. package/modules/government/board.js +35 -0
  17. package/modules/hall-ui/public/goals-page.js +15 -13
  18. package/modules/hall-ui/public/goals.css +2 -2
  19. package/modules/lifecycle/db.js +40 -32
  20. package/modules/lifecycle/goal-authz.js +10 -7
  21. package/modules/lifecycle/routes/done-when.js +3 -3
  22. package/modules/lifecycle/routes/goals.js +43 -36
  23. package/package-lock.json +2 -2
  24. package/package.json +1 -1
  25. package/scripts/gds/fitness.js +68 -0
  26. package/src/module-api.js +5 -1
  27. package/tests/criterion_authoring_gate.mjs +4 -4
  28. package/tests/discord_board_broadcast.mjs +168 -0
  29. package/tests/goal_conflicts.mjs +3 -3
  30. package/tests/{goal_member_roles.mjs → goal_member_kinds.mjs} +89 -80
  31. package/tests/goal_membership_requests.mjs +10 -10
  32. package/tests/goal_routes.mjs +8 -8
  33. package/tests/goal_tier.mjs +10 -10
@@ -119,3 +119,171 @@ test('an unlinked member\'s crafted display name is stripped of markdown and lin
119
119
  assert.ok(!text.includes('@everyone'), 'no lookalike mention survives (the ping was already dead; now the text is too)');
120
120
  assert.ok(text.includes('click me'), 'the words themselves remain — visible, honest, inert');
121
121
  });
122
+
123
+ // ── R22 (task 1003035): the state mirror — closed, and the outcome ───────────
124
+ //
125
+ // The invariants R22 adds:
126
+ // 6. THE SAME MESSAGE CARRIES THE OUTCOME — the summons is EDITED, never
127
+ // appended to, so the channel can never show a stale open window beside a
128
+ // closed one. The id comes from the POST (`wait=true`) and is remembered.
129
+ // 7. BOTH OUTCOMES MIRROR — a return is mirrored exactly like a pass; only the
130
+ // pass CONTRACT event stays pass-only (the credit lane's payload).
131
+ // 8. STILL BEST-EFFORT, STILL OUTBOUND-ONLY — every failure is a named skip,
132
+ // an unannounced item posts fresh rather than going silent, and an edit
133
+ // never re-rings the board.
134
+ const { postBoardOutcome, composeOutcomeLine } = require(path.join(ROOT, 'modules', 'discord', 'board-broadcast.js'));
135
+ const closedPayload = (over = {}) => ({
136
+ item_id: '11', subject_type: 'full_idea', subject_id: '7',
137
+ outcome: 'passed', cause: 'vote', closed_at: '2026-08-18T12:00:00.000Z',
138
+ tally: { yes: 1, no: 0, total: 1 }, ...over,
139
+ });
140
+
141
+ test('the announcement is remembered at post time, so the close has something to edit', async () => {
142
+ let recorded = null;
143
+ const out = await postBoardAnnouncement(payload(), {
144
+ resolveMembers: async () => [member(1, 'a')],
145
+ getLinks: async () => new Map([['1', { discord_user_id: '9' }]]),
146
+ post: async () => ({ ok: true, status: 200, message_id: '555000111' }),
147
+ recordMessage: async (row) => { recorded = row; },
148
+ log: () => {},
149
+ });
150
+ assert.equal(out.posted, true);
151
+ assert.deepEqual(recorded, { itemId: '11', channel: 'board_room', messageId: '555000111' });
152
+ assert.equal(out.recorded, '555000111');
153
+ });
154
+
155
+ test('a poster that returns no id, or a failed remember, still counts as announced', async () => {
156
+ const quiet = {
157
+ resolveMembers: async () => [member(1, 'a')],
158
+ getLinks: async () => new Map([['1', { discord_user_id: '9' }]]),
159
+ log: () => {},
160
+ };
161
+ // The pre-R22 shape (no return value at all) must not become a failure.
162
+ assert.equal((await postBoardAnnouncement(payload(), { ...quiet, post: async () => {} })).posted, true);
163
+ const out = await postBoardAnnouncement(payload(), {
164
+ ...quiet,
165
+ post: async () => ({ message: { id: '7' } }),
166
+ recordMessage: async () => { throw new Error('db down'); },
167
+ });
168
+ assert.equal(out.posted, true, 'the nudge is already in the channel — a lost id is not a failed announcement');
169
+ assert.equal(out.recorded, undefined);
170
+ });
171
+
172
+ test('the close is EDITED onto the same message, and the edit is stamped', async () => {
173
+ let edited = null; let stamped = null;
174
+ const out = await postBoardOutcome(closedPayload({ tally: { yes: 2, no: 0, total: 2 } }), {
175
+ getMessage: async (itemId) => { assert.equal(itemId, '11'); return { item_id: '11', channel: 'board_room', message_id: '555000111' }; },
176
+ edit: async (channel, messageId, text) => { edited = { channel, messageId, text }; },
177
+ markEdited: async (itemId) => { stamped = itemId; },
178
+ log: () => {},
179
+ });
180
+ assert.deepEqual({ edited: out.edited, posted: out.posted, skipped: out.skipped }, { edited: true, posted: false, skipped: null });
181
+ assert.equal(edited.channel, 'board_room');
182
+ assert.equal(edited.messageId, '555000111');
183
+ assert.ok(edited.text.includes('RATIFIED'), 'the summons now reads as the outcome');
184
+ assert.ok(!edited.text.includes('your vote is wanted'), 'nothing stale survives the edit');
185
+ assert.ok(edited.text.includes('/government?item=11#board-room'), 'the record is still one click away');
186
+ assert.equal(stamped, '11', 'edited_at answers "is this window still shown as open?" without asking Discord');
187
+ });
188
+
189
+ test('a RETURN mirrors exactly like a pass — and names neither voter nor reason', async () => {
190
+ let text = null;
191
+ const out = await postBoardOutcome(closedPayload({ outcome: 'returned', cause: 'expiry', tally: { yes: 0, no: 1, total: 1 } }), {
192
+ getMessage: async () => ({ channel: 'board_room', message_id: '9' }),
193
+ edit: async (_c, _m, t) => { text = t; },
194
+ markEdited: async () => {},
195
+ log: () => {},
196
+ });
197
+ assert.equal(out.edited, true);
198
+ assert.ok(text.includes('RETURNED to its author'));
199
+ assert.ok(text.includes('1 objection'), 'singular reads right at one');
200
+ assert.ok(text.includes('the window ran out'), 'an expiry close says so');
201
+ assert.ok(text.includes('in the hall'), 'the reason + flagged section live where the idea is');
202
+ });
203
+
204
+ test('a zero-vote pass states what happened instead of reading like a bug', () => {
205
+ const line = composeOutcomeLine(closedPayload({ cause: 'expiry', tally: { yes: 0, no: 0, total: 0 } }));
206
+ assert.ok(line.includes('nobody objected before the window closed'), 'an absence is nulled, never a block (ADR 0175 §3)');
207
+ assert.ok(line.includes('RATIFIED'));
208
+ const amendment = composeOutcomeLine(closedPayload({ subject_type: 'constitutional_amendment', tally: { yes: 3, no: 0 } }));
209
+ assert.ok(amendment.includes('constitutional amendment'), 'the second subject is named honestly here too');
210
+ assert.ok(amendment.includes('3 in favour, 0 objections'));
211
+ });
212
+
213
+ test('an unannounced item posts its outcome fresh rather than going silent', async () => {
214
+ let posted = null;
215
+ const out = await postBoardOutcome(closedPayload(), {
216
+ getMessage: async () => null,
217
+ post: async (channel, text) => { posted = { channel, text }; },
218
+ log: () => {},
219
+ });
220
+ assert.deepEqual({ edited: out.edited, posted: out.posted }, { edited: false, posted: true });
221
+ assert.equal(posted.channel, 'board_room');
222
+ assert.ok(posted.text.includes('RATIFIED'));
223
+ });
224
+
225
+ test('every mirror failure is a NAMED skip — a close can never be failed by Discord', async () => {
226
+ assert.equal((await postBoardOutcome(null, { log: () => {} })).skipped, 'no_item');
227
+ assert.equal((await postBoardOutcome(closedPayload({ outcome: null }), { log: () => {} })).skipped, 'no_outcome');
228
+ const failed = await postBoardOutcome(closedPayload(), {
229
+ getMessage: async () => ({ channel: 'board_room', message_id: '9' }),
230
+ edit: async () => { throw new Error('discord down'); },
231
+ log: () => {},
232
+ });
233
+ assert.deepEqual({ edited: failed.edited, posted: failed.posted, skipped: failed.skipped },
234
+ { edited: false, posted: false, skipped: 'mirror_failed' },
235
+ 'a failed EDIT never falls back to a second message — the room must not get two notices');
236
+ // An unreadable bookkeeping read degrades to "unannounced", not to a throw.
237
+ const unread = await postBoardOutcome(closedPayload(), {
238
+ getMessage: async () => { throw new Error('db down'); },
239
+ post: async () => {},
240
+ log: () => {},
241
+ });
242
+ assert.equal(unread.posted, true);
243
+ // A stamp that fails leaves the mirrored close reported as mirrored.
244
+ const unstamped = await postBoardOutcome(closedPayload(), {
245
+ getMessage: async () => ({ channel: 'board_room', message_id: '9' }),
246
+ edit: async () => {},
247
+ markEdited: async () => { throw new Error('db down'); },
248
+ log: () => {},
249
+ });
250
+ assert.equal(unstamped.edited, true);
251
+ });
252
+
253
+ test('government emits the close for BOTH outcomes, after commit, and keeps the pass contract pass-only (source pins)', () => {
254
+ const board = fs.readFileSync(path.join(ROOT, 'modules', 'government', 'board.js'), 'utf8');
255
+ const close = board.slice(board.indexOf('async function closeItem'));
256
+ assert.ok(close.indexOf('closeBoardItem') < close.indexOf('emitItemClosed(closed'),
257
+ 'the mirror event fires AFTER the close commits — the row is the durable fact');
258
+ const passEmit = close.indexOf("emitAsync('board.item.passed'");
259
+ assert.ok(passEmit < close.indexOf('emitItemClosed(closed'),
260
+ "the credit lane's contract event still goes first");
261
+ const closedEmit = board.slice(board.indexOf('async function emitItemClosed'));
262
+ assert.match(closedEmit, /emitAsync\('board\.item\.closed'/, 'the event exists');
263
+ assert.match(closedEmit, /outcome: closed\.outcome/, 'it carries the outcome — a return mirrors too');
264
+ assert.match(closedEmit, /tally: \{/, 'it carries the tally so no consumer re-reads votes');
265
+ const passBlock = board.slice(board.indexOf('function passPayload'), board.indexOf('// Close an item'));
266
+ assert.ok(!/tally/.test(passBlock), 'the pass CONTRACT payload stays the seven fields — no tally (ADR 0175 / the pass-event spec)');
267
+ const routes = fs.readFileSync(path.join(ROOT, 'modules', 'discord', 'routes', 'discord.js'), 'utf8');
268
+ assert.match(routes, /api\.on\('board\.item\.closed', \(payload\) => boardBroadcast\.postBoardOutcome\(payload\)\)/,
269
+ 'the discord module subscribes to the close as well');
270
+ });
271
+
272
+ test('the poster asks for the message back, the edit rings NOBODY, and the mirror table is discord-owned', () => {
273
+ const src = fs.readFileSync(path.join(ROOT, 'modules', 'discord', 'webhook.js'), 'utf8');
274
+ const core = src.slice(src.indexOf('async function postCore'), src.indexOf('// SECURITY (#871)'));
275
+ assert.match(core, /wait=true/, 'the POST asks Discord for the created message — that id is what R22 edits');
276
+ assert.match(core, /message_id: message && message\.id/, 'the id is surfaced to the caller');
277
+ const edit = src.slice(src.indexOf('async function editWebhook'));
278
+ assert.match(edit, /method: 'PATCH'/);
279
+ assert.match(edit, /\/messages\/\$\{encodeURIComponent\(String\(messageId\)\)\}/, 'a webhook may only edit its OWN message');
280
+ assert.match(edit, /allowed_mentions: \{ parse: \[\] \}/, 'an edit is an outcome notice, not a summons — it must never re-ring the board');
281
+ assert.ok(!/users/.test(edit), 'the mention exception stays scoped to the one post that means "your vote is wanted"');
282
+ const sql = fs.readFileSync(path.join(ROOT, 'modules', 'discord', 'migrations', 'discord_001_board_messages.sql'), 'utf8');
283
+ assert.match(sql, /CREATE TABLE IF NOT EXISTS discord_board_messages/);
284
+ assert.ok(!/REFERENCES government_board_items/.test(sql),
285
+ 'no FK across the module boundary — the mirror never constrains government\'s schema');
286
+ assert.match(sql, /item_id\s+bigint\s+PRIMARY KEY/, 'one announcement per sitting, edited in place');
287
+ const manifest = JSON.parse(fs.readFileSync(path.join(ROOT, 'modules', 'discord', 'module.json'), 'utf8'));
288
+ assert.equal(manifest.contributes.migrations, true, 'the module now owns a migration and says so');
289
+ });
@@ -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');