@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.
@@ -1,6 +1,6 @@
1
1
  // goals-page.js — the dedicated Goals page (task 1734, owner request from the
2
2
  // task-1723 review): list every goal, then per-goal detail — completion card,
3
- // members with lead (manager) roles, work to do vs completed, and a conflicts
3
+ // members with lead (manager) standing, work to do vs completed, and a conflicts
4
4
  // panel. Hash-routed (#/goal/N) inside one page; the server gate is
5
5
  // SIGNEDIN_BUILDERS_PAGE_RE in serve-internal.js.
6
6
  //
@@ -18,7 +18,7 @@
18
18
  // to the top-100-by-credits window).
19
19
  // Writes: POST /goals/:id/join | /leave | /members (task 1762 — owner/manager
20
20
  // invite, mirrors the server's authorizeGoalManagement gate; task 1877 picks
21
- // the invitee from a dropdown instead of typing a GitHub login). Lead role
21
+ // the invitee from a dropdown instead of typing a GitHub login). Lead
22
22
  // management is task 1735.
23
23
  (function () {
24
24
  'use strict';
@@ -1190,7 +1190,7 @@
1190
1190
  return `<li class="member">
1191
1191
  <span class="member__avatar">${memberAvatar(m)}</span>
1192
1192
  <span class="member__name">${escapeHtml(memberName(m))} ${m.rank ? `<small>· ${escapeHtml(rankLabel(m.rank))}</small>` : ''}</span>
1193
- <span class="role-pill${m.role === 'lead' ? ' role-pill--lead' : ''}">${m.role === 'lead' ? 'Manager' : 'Member'}</span>
1193
+ <span class="member-pill${m.membership_kind === 'lead' ? ' member-pill--lead' : ''}">${m.membership_kind === 'lead' ? 'Manager' : 'Member'}</span>
1194
1194
  </li>`;
1195
1195
  }
1196
1196
 
@@ -1221,7 +1221,7 @@
1221
1221
  <span class="member__avatar">${memberAvatar(m)}</span>
1222
1222
  <span class="member__name">${escapeHtml(memberName(m))} ${m.rank ? `<small>· ${escapeHtml(rankLabel(m.rank))}</small>` : ''}</span>
1223
1223
  <span class="member__stats" title="Contribution to this goal">${statLine}</span>
1224
- <span class="role-pill${m.role === 'lead' ? ' role-pill--lead' : ''}">${m.role === 'lead' ? 'Manager' : 'Member'}</span>
1224
+ <span class="member-pill${m.membership_kind === 'lead' ? ' member-pill--lead' : ''}">${m.membership_kind === 'lead' ? 'Manager' : 'Member'}</span>
1225
1225
  </li>`;
1226
1226
  }
1227
1227
 
@@ -1231,7 +1231,7 @@
1231
1231
  const members = d.members || [];
1232
1232
  const lede = $('#goal-members-lede');
1233
1233
  if (lede) {
1234
- const leads = members.filter((m) => m.role === 'lead');
1234
+ const leads = members.filter((m) => m.membership_kind === 'lead');
1235
1235
  lede.textContent = leads.length
1236
1236
  ? 'Leads manage this goal’s scope and (soon) resolve its conflicts.'
1237
1237
  : 'No lead assigned yet — an Archon can appoint one.';
@@ -1287,7 +1287,7 @@
1287
1287
  const members = d.members || [];
1288
1288
  const sat = crit.filter((c) => c.satisfied).length;
1289
1289
  const joined = members.some((m) => sameId(m.builder_id, selfId()));
1290
- const iAmLead = members.some((m) => sameId(m.builder_id, selfId()) && m.role === 'lead');
1290
+ const iAmLead = members.some((m) => sameId(m.builder_id, selfId()) && m.membership_kind === 'lead');
1291
1291
 
1292
1292
  const h = $('#goal-detail-h');
1293
1293
  // task 2163 — surface the numeric goal id alongside the title (escaped title,
@@ -1367,7 +1367,7 @@
1367
1367
  parts.push('<button class="btn-ghost" id="goal-leave-btn" type="button">Leave goal</button>');
1368
1368
  } else if ((g.visibility === 'private' || g.scope_protected) && !isArchon()) {
1369
1369
  if (state.myPendingRequest) {
1370
- parts.push('<span class="role-pill" title="Awaiting the owner/manager\'s approval">Requested</span>');
1370
+ parts.push('<span class="member-pill" title="Awaiting the owner/manager\'s approval">Requested</span>');
1371
1371
  parts.push('<button class="btn-ghost" id="goal-withdraw-btn" type="button">Withdraw</button>');
1372
1372
  } else {
1373
1373
  parts.push('<button class="btn-ghost" id="goal-request-btn" type="button">🔒 Request to join</button>');
@@ -1471,7 +1471,7 @@
1471
1471
  }
1472
1472
 
1473
1473
  // task 1762/1877 — owner/manager invite control. Shows a compact form (a
1474
- // builder dropdown + role) in place of a button while open. Idempotent
1474
+ // builder dropdown + membership) in place of a button while open. Idempotent
1475
1475
  // re-render: always rebuilds the box from scratch, so state.inviteOpen is the
1476
1476
  // only thing that survives a renderDetail() call while the form is open.
1477
1477
  function renderInviteBox(goalId, canInvite, members) {
@@ -1504,7 +1504,7 @@
1504
1504
  ${invitable.length ? `<option value="" disabled selected>Choose a builder…</option>${options}`
1505
1505
  : '<option value="">Everyone is already a member</option>'}
1506
1506
  </select>
1507
- <select id="goal-invite-role">
1507
+ <select id="goal-invite-kind">
1508
1508
  <option value="member">Member</option>
1509
1509
  <option value="lead">Manager</option>
1510
1510
  </select>
@@ -1518,14 +1518,16 @@
1518
1518
  $('#goal-invite-form')?.addEventListener('submit', (ev) => {
1519
1519
  ev.preventDefault();
1520
1520
  const builderId = $('#goal-invite-builder')?.value;
1521
- const role = $('#goal-invite-role')?.value || 'member';
1522
- if (builderId) inviteMember(goalId, builderId, role);
1521
+ const membershipKind = $('#goal-invite-kind')?.value || 'member';
1522
+ if (builderId) inviteMember(goalId, builderId, membershipKind);
1523
1523
  });
1524
1524
  }
1525
1525
 
1526
- async function inviteMember(goalId, builderId, role) {
1526
+ async function inviteMember(goalId, builderId, membershipKind) {
1527
1527
  try {
1528
- await postJSON(`${API}/goals/${encodeURIComponent(goalId)}/members`, { builder_id: Number(builderId), role });
1528
+ // `membership_kind`, not `role` — ADR 0174 reserves that word for the three
1529
+ // crafts (task 1003013).
1530
+ await postJSON(`${API}/goals/${encodeURIComponent(goalId)}/members`, { builder_id: Number(builderId), membership_kind: membershipKind });
1529
1531
  toast('Invited.');
1530
1532
  state.inviteOpen = false;
1531
1533
  await loadDetail(goalId, { force: true });
@@ -173,8 +173,8 @@ a.goal-card:hover { text-decoration: none; border-color: var(--border-strong); b
173
173
  .member__avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
174
174
  .member__name { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
175
175
  .member__name small { color: var(--ink-faint); }
176
- .role-pill { display: inline-flex; align-items: center; height: 20px; padding: 0 9px; border-radius: 999px; font-size: 11px; font-weight: 600; background: var(--surface-2); color: var(--ink-soft); }
177
- .role-pill--lead { background: var(--accent-weak); color: var(--accent-text); }
176
+ .member-pill { display: inline-flex; align-items: center; height: 20px; padding: 0 9px; border-radius: 999px; font-size: 11px; font-weight: 600; background: var(--surface-2); color: var(--ink-soft); }
177
+ .member-pill--lead { background: var(--accent-weak); color: var(--accent-text); }
178
178
 
179
179
  /* task 1762/1877 — owner/manager invite control (goal member admission),
180
180
  collapsed behind "+ Invite" (disclosure, closed by default — task 2171). */
@@ -695,7 +695,7 @@ async function createGoal(
695
695
  SELECT $1, $2, $3, $4, $5, $6, next_order.n FROM next_order
696
696
  RETURNING ${GOAL_COLS}
697
697
  ), first_lead AS (
698
- INSERT INTO goal_members (goal_id, builder_id, role)
698
+ INSERT INTO goal_members (goal_id, builder_id, membership_kind)
699
699
  SELECT g.id, $6::bigint, 'lead' FROM g WHERE $6::bigint IS NOT NULL
700
700
  )
701
701
  SELECT * FROM g`,
@@ -848,12 +848,18 @@ async function achieveGoalIfComplete({ goalId }, deps = {}) {
848
848
  }
849
849
 
850
850
  // addGoalMember — join. Upserts on the (goal_id, builder_id) PK so a re-join is
851
- // not a duplicate-key error, and the conflict arm PRESERVES the stored role
852
- // (task 1002689): overwriting it let POST /goals/:id/join (always role 'member')
853
- // and a POST /goals/:id/members re-add silently demote a lead — including the
854
- // owner's row — bypassing authorizeMemberRoleChange's owner-only-demotion
855
- // guards. Role changes go through PATCH /goals/:id/members/:builderId only
856
- // (the acceptMembershipRequestAndAddMember precedent). role defaults to 'member'.
851
+ // not a duplicate-key error, and the conflict arm PRESERVES the stored
852
+ // membership_kind (task 1002689): overwriting it let POST /goals/:id/join (always
853
+ // 'member') and a POST /goals/:id/members re-add silently demote a lead —
854
+ // including the owner's row — bypassing authorizeMembershipKindChange's
855
+ // owner-only-demotion guards. Changes go through PATCH
856
+ // /goals/:id/members/:builderId only (the acceptMembershipRequestAndAddMember
857
+ // precedent). membershipKind defaults to 'member'.
858
+
859
+ // THE COLUMN IS `membership_kind` (task 1003013, ADR 0177 — ADR 0174 reserves the
860
+ // other word for the three crafts). The legacy column still EXISTS for one
861
+ // release, kept in lockstep by core_220's trigger so a rolled-back deploy still
862
+ // reads a schema it understands: write the new name only, step 2 drops the old.
857
863
  //
858
864
  // ARRIVING AS A MEMBER CLOSES ANY PENDING ASK (task 1002888 / idea 1000688).
859
865
  // The membership write used to be a bare upsert that never looked at
@@ -880,22 +886,22 @@ async function achieveGoalIfComplete({ goalId }, deps = {}) {
880
886
  // (Postgres runs a data-modifying CTE to completion whether or not the primary
881
887
  // query reads it), and both see the same snapshot.
882
888
 
883
- async function addGoalMember({ goalId, builderId, role = 'member', resolvedBy = null }, deps = {}) {
889
+ async function addGoalMember({ goalId, builderId, membershipKind = 'member', resolvedBy = null }, deps = {}) {
884
890
  const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
885
891
  const { rows } = await activePool.query(
886
892
  `WITH added AS (
887
- INSERT INTO goal_members (goal_id, builder_id, role)
893
+ INSERT INTO goal_members (goal_id, builder_id, membership_kind)
888
894
  VALUES ($1, $2, $3)
889
- ON CONFLICT (goal_id, builder_id) DO UPDATE SET role = goal_members.role
890
- RETURNING goal_id, builder_id, role, joined_at
895
+ ON CONFLICT (goal_id, builder_id) DO UPDATE SET membership_kind = goal_members.membership_kind
896
+ RETURNING goal_id, builder_id, membership_kind, joined_at
891
897
  ), cancelled AS (
892
898
  UPDATE goal_membership_requests
893
899
  SET status = 'cancelled', resolved_by = $4, resolved_at = now(), updated_at = now()
894
900
  WHERE goal_id = $1 AND builder_id = $2 AND status = 'pending'
895
901
  RETURNING id
896
902
  )
897
- SELECT goal_id, builder_id, role, joined_at FROM added`,
898
- [goalId, builderId, role, resolvedBy]
903
+ SELECT goal_id, builder_id, membership_kind, joined_at FROM added`,
904
+ [goalId, builderId, membershipKind, resolvedBy]
899
905
  );
900
906
  return rows[0];
901
907
  }
@@ -917,9 +923,9 @@ async function listGoalMembers(goalId, deps = {}) {
917
923
  const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
918
924
  // Builder identity rides along (task 1734: the hall Goals page renders member
919
925
  // names + lead badges) — additive columns, so every existing consumer that
920
- // reads only {goal_id, builder_id, role} is untouched.
926
+ // reads only {goal_id, builder_id, membership_kind} is untouched.
921
927
  const { rows } = await activePool.query(
922
- `SELECT gm.goal_id, gm.builder_id, gm.role, gm.joined_at,
928
+ `SELECT gm.goal_id, gm.builder_id, gm.membership_kind, gm.joined_at,
923
929
  b.github_login, b.display_name, b.avatar_url, b.rank
924
930
  FROM goal_members gm
925
931
  LEFT JOIN builders b ON b.id = gm.builder_id
@@ -993,14 +999,15 @@ async function isGoalMember({ goalId, builderId }, deps = {}) {
993
999
  return rows.length > 0;
994
1000
  }
995
1001
 
996
- // getGoalMember — one membership row incl. role, or null. The lead-authority
997
- // read behind PATCH /goals/:id/members/:builderId (task 1735): the route needs
998
- // the actor's and the target's role, not just the isGoalMember boolean.
1002
+ // getGoalMember — one membership row incl. membership_kind, or null. The
1003
+ // lead-authority read behind PATCH /goals/:id/members/:builderId (task 1735): the
1004
+ // route needs the actor's and the target's membership_kind, not just the
1005
+ // isGoalMember boolean.
999
1006
 
1000
1007
  async function getGoalMember({ goalId, builderId }, deps = {}) {
1001
1008
  const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
1002
1009
  const { rows } = await activePool.query(
1003
- `SELECT goal_id, builder_id, role, joined_at
1010
+ `SELECT goal_id, builder_id, membership_kind, joined_at
1004
1011
  FROM goal_members
1005
1012
  WHERE goal_id = $1 AND builder_id = $2`,
1006
1013
  [goalId, builderId]
@@ -1008,24 +1015,24 @@ async function getGoalMember({ goalId, builderId }, deps = {}) {
1008
1015
  return rows[0] ?? null;
1009
1016
  }
1010
1017
 
1011
- // setGoalMemberRole — apply a role change with the owner guard IN the statement
1018
+ // setGoalMembershipKind — apply a membership change with the owner guard IN the statement
1012
1019
  // (task 1735): the goal OWNER (goals.created_by) is the fixed authority, so a
1013
1020
  // demotion never applies to the owner's row even if two requests race past the
1014
1021
  // route's pure gate (the attachParent "re-check atomically" posture). Returns
1015
1022
  // the updated row, or null when nothing changed — no such member, or the guard
1016
1023
  // blocked an owner demotion — which the route answers as a conflict.
1017
1024
 
1018
- async function setGoalMemberRole({ goalId, builderId, role }, deps = {}) {
1025
+ async function setGoalMembershipKind({ goalId, builderId, membershipKind }, deps = {}) {
1019
1026
  const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
1020
1027
  const { rows } = await activePool.query(
1021
1028
  `UPDATE goal_members gm
1022
- SET role = $3
1029
+ SET membership_kind = $3
1023
1030
  WHERE gm.goal_id = $1 AND gm.builder_id = $2
1024
1031
  AND ($3 = 'lead'
1025
1032
  OR gm.builder_id IS DISTINCT FROM
1026
1033
  (SELECT g.created_by FROM goals g WHERE g.id = $1))
1027
- RETURNING goal_id, builder_id, role, joined_at`,
1028
- [goalId, builderId, role]
1034
+ RETURNING goal_id, builder_id, membership_kind, joined_at`,
1035
+ [goalId, builderId, membershipKind]
1029
1036
  );
1030
1037
  return rows[0] ?? null;
1031
1038
  }
@@ -1047,9 +1054,9 @@ async function transferGoalOwnership({ goalId, newOwnerId }, deps = {}) {
1047
1054
  WHERE id = $1
1048
1055
  RETURNING ${GOAL_COLS}
1049
1056
  ), new_owner_member AS (
1050
- INSERT INTO goal_members (goal_id, builder_id, role)
1057
+ INSERT INTO goal_members (goal_id, builder_id, membership_kind)
1051
1058
  SELECT g.id, $2, 'lead' FROM g
1052
- ON CONFLICT (goal_id, builder_id) DO UPDATE SET role = 'lead'
1059
+ ON CONFLICT (goal_id, builder_id) DO UPDATE SET membership_kind = 'lead'
1053
1060
  )
1054
1061
  SELECT * FROM g`,
1055
1062
  [goalId, newOwnerId]
@@ -1259,7 +1266,8 @@ async function resolveMembershipRequest({ id, status, resolvedBy = null }, deps
1259
1266
  // WHERE status='pending' keeps it idempotent — a lost race (already resolved /
1260
1267
  // gone) resolves nothing, adds nobody, and returns null. On the (near-impossible,
1261
1268
  // since the create routes reject an existing member) membership conflict the
1262
- // existing role is PRESERVED, so an approve never silently demotes a manager.
1269
+ // existing membership_kind is PRESERVED, so an approve never silently demotes a
1270
+ // manager.
1263
1271
  // Returns { request, member } or null.
1264
1272
  async function acceptMembershipRequestAndAddMember({ id, resolvedBy = null }, deps = {}) {
1265
1273
  const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
@@ -1270,10 +1278,10 @@ async function acceptMembershipRequestAndAddMember({ id, resolvedBy = null }, de
1270
1278
  WHERE id = $1 AND status = 'pending'
1271
1279
  RETURNING ${MEMBERSHIP_REQUEST_COLS}
1272
1280
  ), added AS (
1273
- INSERT INTO goal_members (goal_id, builder_id, role)
1281
+ INSERT INTO goal_members (goal_id, builder_id, membership_kind)
1274
1282
  SELECT goal_id, builder_id, 'member' FROM resolved
1275
- ON CONFLICT (goal_id, builder_id) DO UPDATE SET role = goal_members.role
1276
- RETURNING goal_id, builder_id, role, joined_at
1283
+ ON CONFLICT (goal_id, builder_id) DO UPDATE SET membership_kind = goal_members.membership_kind
1284
+ RETURNING goal_id, builder_id, membership_kind, joined_at
1277
1285
  )
1278
1286
  SELECT (SELECT to_jsonb(r) FROM resolved r) AS request,
1279
1287
  (SELECT to_jsonb(a) FROM added a) AS member`,
@@ -1320,7 +1328,7 @@ async function listInboxFor(builderId, deps = {}) {
1320
1328
  WHERE r.kind = 'request' AND r.status = 'pending'
1321
1329
  AND (g.created_by = $1
1322
1330
  OR EXISTS (SELECT 1 FROM goal_members gm
1323
- WHERE gm.goal_id = g.id AND gm.builder_id = $1 AND gm.role = 'lead'))
1331
+ WHERE gm.goal_id = g.id AND gm.builder_id = $1 AND gm.membership_kind = 'lead'))
1324
1332
  ORDER BY r.created_at, r.id`,
1325
1333
  [builderId]
1326
1334
  ),
@@ -5590,7 +5598,7 @@ module.exports = {
5590
5598
  listGoalMembers,
5591
5599
  isGoalMember,
5592
5600
  getGoalMember,
5593
- setGoalMemberRole,
5601
+ setGoalMembershipKind,
5594
5602
  transferGoalOwnership,
5595
5603
  listActiveClaimsForTasks,
5596
5604
  requestClaimRelease,
@@ -5,18 +5,21 @@
5
5
  // that goal's criteria, and authorization logic must have exactly one definition.
6
6
  // routes/goals.js re-exports authorizeGoalManagement (isGoalOwner it imports for
7
7
  // internal use only), so its existing DB-free unit tests (goal_conflicts,
8
- // goal_member_roles, goal_membership_requests) are unchanged.
8
+ // goal_member_kinds, goal_membership_requests) are unchanged.
9
9
  //
10
10
  // A module sibling, like ../goal-conflicts — routes consume pure files here; no
11
11
  // route requires another route.
12
12
 
13
13
  // The goal AUTHORITY MODEL (task 1735). Two layers, deliberately different kinds:
14
- // - OWNER — goals.created_by, the fixed authority. Not a role row: it is set at
14
+ // - OWNER — goals.created_by, the fixed authority. Not a membership row: set at
15
15
  // create time (the creator) and moves only via the explicit transfer route.
16
16
  // The owner holds every goal power.
17
- // - MANAGER — the goal_members.role 'lead' value (the hall renders it
18
- // "manager"). Owner-equivalent EXCEPT: a manager cannot DEMOTE a fellow
19
- // manager (owner-only), and cannot transfer ownership.
17
+ // - MANAGER — the goal_members.membership_kind 'lead' value (the hall renders
18
+ // it "manager"). Owner-equivalent EXCEPT: a manager cannot DEMOTE a fellow
19
+ // manager (owner-only), and cannot transfer ownership. The column is
20
+ // `membership_kind`, never `role` — ADR 0174 reserves that word for the three
21
+ // crafts, and a goal membership is neither a craft nor a rank (task 1003013,
22
+ // ADR 0177).
20
23
  // A goal with created_by NULL (the migration-160 system defaults) has no owner;
21
24
  // Archons act as one — the archon arm of both gates below.
22
25
  function isGoalOwner(goal, builderId) {
@@ -32,12 +35,12 @@ function isGoalOwner(goal, builderId) {
32
35
  function isGoalOwnerOrManager({ goal, actorId, actorRank, actorMembership }) {
33
36
  if (actorRank === 'archon') return true;
34
37
  if (!goal) return false;
35
- return isGoalOwner(goal, actorId) || (!!actorMembership && actorMembership.role === 'lead');
38
+ return isGoalOwner(goal, actorId) || (!!actorMembership && actorMembership.membership_kind === 'lead');
36
39
  }
37
40
 
38
41
  // authorizeGoalManagement — the shared ORDERED, FAIL-CLOSED prefix for actions
39
42
  // a goal's own authority performs (task 1736: conflict resolution; the same
40
- // three steps open authorizeMemberRoleChange): goal exists → goal open →
43
+ // three steps open authorizeMembershipKindChange): goal exists → goal open →
41
44
  // actor is owner|manager|Archon. Returns { ok:true } or { ok:false, status,
42
45
  // body }. PURE + exported, same precedent as its siblings.
43
46
  function authorizeGoalManagement({ goal, actorId, actorRank, actorMembership }) {
@@ -16,7 +16,7 @@ const auth = api;
16
16
  const doneWhen = require('../done-when');
17
17
  const db = require('../db');
18
18
  // The goal authority PREDICATE (ADR 0154) — the same owner|manager test routes/goals.js
19
- // uses for visibility / invites / roles, so "who may manage this goal" has
19
+ // uses for visibility / invites / memberships, so "who may manage this goal" has
20
20
  // one definition. A module sibling, like ../db.
21
21
  const { isGoalOwnerOrManager } = require('../goal-authz');
22
22
  const { validateOrRespond, parseId, asyncHandler, LIMITS } = api;
@@ -57,7 +57,7 @@ function authorizeCriterionCreate({ goal, version, actorId, actorRank, actorMemb
57
57
  if (!isGoalOwnerOrManager({ goal, actorId, actorRank, actorMembership })) {
58
58
  return { ok: false, status: 403, body: {
59
59
  error: 'goal_not_yours',
60
- message: `Criteria on goal ${goal.id} can be authored only by the goal's owner or a manager (ADR 0154). Ask them to add it — or to grant you the manager role.`,
60
+ message: `Criteria on goal ${goal.id} can be authored only by the goal's owner or a manager (ADR 0154). Ask them to add it — or to make you a manager.`,
61
61
  } };
62
62
  }
63
63
  if (goal.status !== 'open') {
@@ -112,7 +112,7 @@ module.exports = function buildDoneWhenRouter() {
112
112
  // which stays Archon.
113
113
  // - So a non-Archon must name a goal_id (version-level criteria stay Archon),
114
114
  // must be that goal's OWNER or MANAGER (isGoalOwnerOrManager — the same
115
- // predicate behind visibility/invite/role management), and the goal must be
115
+ // predicate behind visibility/invite/membership management), and the goal must be
116
116
  // open on a planning/building version. That bounds the blast radius to a
117
117
  // workspace they already hold. An unauthorized caller gets an honest 403
118
118
  // goal_not_yours (task 1002614 — goal reads are open to any builder, so
@@ -45,7 +45,7 @@ const goalConflicts = require('../goal-conflicts');
45
45
  const { isGoalOwner, authorizeGoalManagement } = require('../goal-authz');
46
46
 
47
47
  const VALID_GOAL_STATUSES = ['open', 'achieved', 'archived'];
48
- const VALID_MEMBER_ROLES = ['lead', 'member'];
48
+ const VALID_MEMBERSHIP_KINDS = ['lead', 'member'];
49
49
  const METIC_PLUS = ['metic', 'archon'];
50
50
  const RESOLVE_ACTIONS = ['sequence', 'flag_release', 'rescope'];
51
51
  // Private goals (task 1952 / ADR 0112): the visibility values, and the resolve
@@ -131,43 +131,48 @@ function authorizeGoalTaskCreate({ goal, version, isMember, builderRank, touches
131
131
  // isGoalOwner + authorizeGoalManagement are imported at the top of this file and
132
132
  // re-exported at the bottom (task 1002538 / ADR 0154).
133
133
 
134
- // authorizeMemberRoleChange — the ORDERED, FAIL-CLOSED authorization decision for
135
- // PATCH /goals/:id/members/:builderId (task 1735). Today the only role-assignment
134
+ // authorizeMembershipKindChange — the ORDERED, FAIL-CLOSED authorization decision
135
+ // for PATCH /goals/:id/members/:builderId (task 1735). Today the only assignment
136
136
  // path is the Archon admission route; this gate lets the goal's own authority
137
- // manage roles without an Archon in the loop:
137
+ // manage memberships without an Archon in the loop:
138
+ //
139
+ // THE FIELD IS `membership_kind` (task 1003013, ADR 0177): ADR 0174 reserves the
140
+ // old word for the three crafts — Artist, Ideator, Builder — and a goal membership
141
+ // is a per-goal position, neither a craft nor a rank.
142
+
138
143
  // - the owner, a manager, or an Archon may promote member→lead(manager);
139
144
  // - ONLY the owner (or an Archon) may demote lead(manager)→member;
140
145
  // - the OWNER's row can never be demoted — owner authority is created_by, not
141
- // the role value, so a demoted owner row would just lie to the member list.
146
+ // the membership value, so a demoted owner row would just lie to the list.
142
147
  // PURE + exported (the authorizeGoalTaskCreate precedent above) so every
143
148
  // rejection is pinned by a DB-free unit test. The route fetches goal /
144
149
  // memberships, then calls this; it rejects on the FIRST failure (fail-closed —
145
150
  // a missing/ambiguous input denies):
146
151
  // goal_not_found → goal_not_open → not_owner_or_manager → not_a_member
147
- // (target) → no-op same-role → owner_only_demotion → owner_role_fixed.
152
+ // (target) → no-op same-kind → owner_only_demotion → owner_membership_fixed.
148
153
  // Authority (not_owner_or_manager) is checked BEFORE the target lookup so an
149
154
  // unauthorized actor never learns who is or isn't a member (the O1 no-leak
150
- // ordering precedent in the abuse matrix). The DB helper (setGoalMemberRole)
155
+ // ordering precedent in the abuse matrix). The DB helper (setGoalMembershipKind)
151
156
  // re-asserts the owner-demotion guard atomically in the UPDATE itself — this
152
157
  // gate is the testable contract, the SQL guard is the backstop.
153
- // Returns { ok:true }, { ok:true, noop:true } (role already as requested), or
158
+ // Returns { ok:true }, { ok:true, noop:true } (already as requested), or
154
159
  // { ok:false, status, body } (the exact HTTP status + JSON the route sends).
155
- function authorizeMemberRoleChange({ goal, actorId, actorRank, actorMembership, targetMembership, targetId, role }) {
160
+ function authorizeMembershipKindChange({ goal, actorId, actorRank, actorMembership, targetMembership, targetId, membershipKind }) {
156
161
  if (!goal) {
157
162
  return { ok: false, status: 404, body: { error: 'goal_not_found' } };
158
163
  }
159
164
  if (goal.status !== 'open') {
160
165
  return { ok: false, status: 409, body: {
161
166
  error: 'goal_not_open', status: goal.status,
162
- message: 'Member roles can only be changed on an open goal.',
167
+ message: 'Memberships can only be changed on an open goal.',
163
168
  } };
164
169
  }
165
170
  const actorIsOwner = isGoalOwner(goal, actorId);
166
- const actorIsManager = !!actorMembership && actorMembership.role === 'lead';
171
+ const actorIsManager = !!actorMembership && actorMembership.membership_kind === 'lead';
167
172
  if (actorRank !== 'archon' && !actorIsOwner && !actorIsManager) {
168
173
  return { ok: false, status: 403, body: {
169
174
  error: 'not_owner_or_manager',
170
- message: 'Only this goal\'s owner or a manager (or an Archon) may change member roles.',
175
+ message: 'Only this goal\'s owner or a manager (or an Archon) may change memberships.',
171
176
  } };
172
177
  }
173
178
  if (!targetMembership) {
@@ -176,10 +181,10 @@ function authorizeMemberRoleChange({ goal, actorId, actorRank, actorMembership,
176
181
  message: 'That builder is not a member of this goal — admit them first.',
177
182
  } };
178
183
  }
179
- if (targetMembership.role === role) {
184
+ if (targetMembership.membership_kind === membershipKind) {
180
185
  return { ok: true, noop: true };
181
186
  }
182
- if (role === 'member') {
187
+ if (membershipKind === 'member') {
183
188
  // A demotion. Managers promote; only the owner (or an Archon) demotes.
184
189
  if (actorRank !== 'archon' && !actorIsOwner) {
185
190
  return { ok: false, status: 403, body: {
@@ -189,7 +194,7 @@ function authorizeMemberRoleChange({ goal, actorId, actorRank, actorMembership,
189
194
  }
190
195
  if (isGoalOwner(goal, targetId)) {
191
196
  return { ok: false, status: 409, body: {
192
- error: 'owner_role_fixed',
197
+ error: 'owner_membership_fixed',
193
198
  message: 'The goal owner cannot be demoted — transfer ownership first.',
194
199
  } };
195
200
  }
@@ -621,7 +626,7 @@ module.exports = function buildGoalsRouter() {
621
626
  // resolvedBy: the joiner — arriving as a member cancels their own still-
622
627
  // pending ask on this goal, atomically with the membership write (task
623
628
  // 1002888). Self-joining is the same intent as withdrawing the request.
624
- const member = await db.addGoalMember({ goalId: id, builderId: req.builder.id, role: 'member', resolvedBy: req.builder.id });
629
+ const member = await db.addGoalMember({ goalId: id, builderId: req.builder.id, membershipKind: 'member', resolvedBy: req.builder.id });
625
630
  return res.status(201).json({ ok: true, member });
626
631
  } catch (err) {
627
632
  console.error('[gds] POST /goals/:id/join', err);
@@ -941,7 +946,7 @@ module.exports = function buildGoalsRouter() {
941
946
  const id = parseId(req, res, { code: 'bad_goal_id' });
942
947
  if (id === null) return;
943
948
  if (validateOrRespond(req, res, {
944
- role: { type: 'string' },
949
+ membership_kind: { type: 'string' },
945
950
  // builder_id accepts a JSON number OR a numeric string (coerced below);
946
951
  // declared untyped so strict-mode accepts either form.
947
952
  builder_id: {},
@@ -953,9 +958,9 @@ module.exports = function buildGoalsRouter() {
953
958
  if (!Number.isInteger(builderId) || builderId <= 0) {
954
959
  return res.fail('bad_builder_id', { status: 400, message: 'builder_id must be a positive integer.' });
955
960
  }
956
- const role = req.body.role || 'member';
957
- if (!VALID_MEMBER_ROLES.includes(role)) {
958
- return res.fail('bad_role', 400, { valid: VALID_MEMBER_ROLES });
961
+ const membershipKind = req.body.membership_kind || 'member';
962
+ if (!VALID_MEMBERSHIP_KINDS.includes(membershipKind)) {
963
+ return res.fail('bad_membership_kind', 400, { valid: VALID_MEMBERSHIP_KINDS });
959
964
  }
960
965
  try {
961
966
  const goal = await db.getGoal(id);
@@ -985,7 +990,7 @@ module.exports = function buildGoalsRouter() {
985
990
  // resolvedBy: the ADDER (not the target) — a direct add closes the target's
986
991
  // pending ask, and the audit trail should name whoever admitted them
987
992
  // (task 1002888).
988
- const member = await db.addGoalMember({ goalId: id, builderId, role, resolvedBy: req.builder.id });
993
+ const member = await db.addGoalMember({ goalId: id, builderId, membershipKind, resolvedBy: req.builder.id });
989
994
  return res.status(201).json({ ok: true, member });
990
995
  } catch (err) {
991
996
  console.error('[gds] POST /goals/:id/members', err);
@@ -993,12 +998,14 @@ module.exports = function buildGoalsRouter() {
993
998
  }
994
999
  });
995
1000
 
996
- // PATCH /goals/:id/members/:builderId — role management by the goal's own
1001
+ // PATCH /goals/:id/members/:builderId — membership management by the goal's own
997
1002
  // authority (task 1735): the owner, a manager, or an Archon promotes
998
1003
  // member→lead(manager); ONLY the owner (or an Archon) demotes; the owner's
999
- // row is never demotable. The decision is the pure ordered gate above
1000
- // (authorizeMemberRoleChange); the DB write re-asserts the owner-demotion
1001
- // guard atomically (setGoalMemberRole), so a race cannot demote an owner.
1004
+ // row is never demotable. The body field is `membership_kind` (task 1003013 —
1005
+ // ADR 0174 reserves `role` for the three crafts). The decision is the pure
1006
+ // ordered gate above (authorizeMembershipKindChange); the DB write re-asserts
1007
+ // the owner-demotion guard atomically (setGoalMembershipKind), so a race
1008
+ // cannot demote an owner.
1002
1009
  // rank: any-builder (requireBuilder) + the in-handler (owner | manager |
1003
1010
  // Archon) gate — like the authoring keystone, the wall is in-handler, NOT a
1004
1011
  // rank middleware. Pinned in route-rank-check.MODULE_EXPECTED_RANKS['lifecycle'].
@@ -1008,11 +1015,11 @@ module.exports = function buildGoalsRouter() {
1008
1015
  const builderId = parseId(req, res, { param: 'builderId', code: 'bad_builder_id', positiveInt: true });
1009
1016
  if (builderId === null) return;
1010
1017
  if (validateOrRespond(req, res, {
1011
- role: { required: true, type: 'string' },
1018
+ membership_kind: { required: true, type: 'string' },
1012
1019
  })) return;
1013
- const role = req.body.role;
1014
- if (!VALID_MEMBER_ROLES.includes(role)) {
1015
- return res.fail('bad_role', 400, { valid: VALID_MEMBER_ROLES });
1020
+ const membershipKind = req.body.membership_kind;
1021
+ if (!VALID_MEMBERSHIP_KINDS.includes(membershipKind)) {
1022
+ return res.fail('bad_membership_kind', 400, { valid: VALID_MEMBERSHIP_KINDS });
1016
1023
  }
1017
1024
  try {
1018
1025
  const goal = await db.getGoal(id);
@@ -1023,14 +1030,14 @@ module.exports = function buildGoalsRouter() {
1023
1030
  db.getGoalMember({ goalId: id, builderId }),
1024
1031
  ])
1025
1032
  : [null, null];
1026
- const decision = authorizeMemberRoleChange({
1033
+ const decision = authorizeMembershipKindChange({
1027
1034
  goal,
1028
1035
  actorId: req.builder.id,
1029
1036
  actorRank: req.builder.rank,
1030
1037
  actorMembership,
1031
1038
  targetMembership,
1032
1039
  targetId: builderId,
1033
- role,
1040
+ membershipKind,
1034
1041
  });
1035
1042
  if (!decision.ok) {
1036
1043
  return res.status(decision.status).json(decision.body);
@@ -1038,17 +1045,17 @@ module.exports = function buildGoalsRouter() {
1038
1045
  if (decision.noop) {
1039
1046
  return res.json({ ok: true, member: targetMembership, changed: false });
1040
1047
  }
1041
- const member = await db.setGoalMemberRole({ goalId: id, builderId, role });
1048
+ const member = await db.setGoalMembershipKind({ goalId: id, builderId, membershipKind });
1042
1049
  if (!member) {
1043
1050
  // The atomic backstop fired: the membership vanished, or ownership
1044
1051
  // moved onto the target between the gate's read and this write.
1045
1052
  // Honest conflict, not silent success.
1046
- return res.fail('role_change_conflict', { status: 409, message: 'The goal membership changed underneath this request — re-read the goal and retry.' });
1053
+ return res.fail('membership_change_conflict', { status: 409, message: 'The goal membership changed underneath this request — re-read the goal and retry.' });
1047
1054
  }
1048
1055
  return res.json({ ok: true, member, changed: true });
1049
1056
  } catch (err) {
1050
1057
  console.error('[gds] PATCH /goals/:id/members/:builderId', err);
1051
- return res.fail('role_change_failed', { status: 500, message: 'internal error' });
1058
+ return res.fail('membership_change_failed', { status: 500, message: 'internal error' });
1052
1059
  }
1053
1060
  });
1054
1061
 
@@ -1531,9 +1538,9 @@ module.exports = function buildGoalsRouter() {
1531
1538
  // for reuse. The shouldRefuseSessionClaim (claims.js) precedent.
1532
1539
  module.exports.authorizeGoalTaskCreate = authorizeGoalTaskCreate;
1533
1540
  // task 1735: the pure ordered gates for PATCH /goals/:id/members/:builderId and
1534
- // POST /goals/:id/transfer — exported for tests/goal_member_roles.mjs (DB-free),
1541
+ // POST /goals/:id/transfer — exported for tests/goal_member_kinds.mjs (DB-free),
1535
1542
  // same precedent.
1536
- module.exports.authorizeMemberRoleChange = authorizeMemberRoleChange;
1543
+ module.exports.authorizeMembershipKindChange = authorizeMembershipKindChange;
1537
1544
  module.exports.authorizeOwnershipTransfer = authorizeOwnershipTransfer;
1538
1545
  // task 1736: the shared management prefix gate (conflict resolution) —
1539
1546
  // exported for tests/goal_conflicts.mjs (DB-free), same precedent.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.238",
3
+ "version": "1.19.239",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.238",
9
+ "version": "1.19.239",
10
10
  "license": "AGPL-3.0-or-later",
11
11
  "dependencies": {
12
12
  "express": "^4.21.2",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.238",
3
+ "version": "1.19.239",
4
4
  "description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
5
5
  "license": "AGPL-3.0-or-later",
6
6
  "main": "src/platform-server.js",