@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
@@ -18,9 +18,15 @@
18
18
  // in text instead (visible, unpinged, honest). #871's parse:[] still stands:
19
19
  // nothing typed into content can ever ring anyone.
20
20
  //
21
+ // THE MIRROR'S SECOND HALF (R22, task 1003035): when the item closes, the SAME
22
+ // message is edited to carry the outcome, so the channel never shows a stale open
23
+ // window and the room never has to guess which of two messages is current. The
24
+ // item's state lives in the DATABASE — this edit is a mirror of it, never a
25
+ // source, and nothing is ever cast from Discord.
26
+ //
21
27
  // BEST-EFFORT BY DESIGN, like every broadcast in this module: it never throws,
22
- // so a Discord hiccup can never fail a window open — the room in the hall is
23
- // the durable surface, this is the nudge.
28
+ // so a Discord hiccup can never fail a window open or a close — the room in the
29
+ // hall is the durable surface, this is the nudge.
24
30
 
25
31
  'use strict';
26
32
 
@@ -78,22 +84,30 @@ async function postBoardAnnouncement(payload, deps = {}) {
78
84
  }
79
85
  }
80
86
 
81
- const subject = payload.subject_type === 'constitutional_amendment'
82
- ? 'A constitutional amendment'
83
- : 'A Full Idea';
87
+ const subject = subjectLabel(payload.subject_type);
84
88
  const clock = payload.window_minutes
85
89
  ? `within ${payload.window_minutes} minutes`
86
90
  : 'at your convenience — the sitting has no clock and closes on the first vote';
87
- const origin = ((api.branding && api.branding().domains) || {}).buildersOrigin || '';
88
- // Straight to THAT item: ?item=<id> — the Board Room panel reads it and
89
- // scrolls to / highlights the named sitting (the hash stays the tab).
90
- const link = `${origin ? origin.replace(/\/$/, '') : ''}/government?item=${encodeURIComponent(payload.item_id)}#board-room`;
91
+ const link = itemLink(payload.item_id);
91
92
  const line = `\u{1F3DB}\u{FE0F} ${subject} is before the board (item ${payload.item_id}). `
92
93
  + `${mentions.join(' ')} — your vote is wanted ${clock}. Vote in the hall: ${link}`;
93
94
 
94
95
  const post = deps.post || webhook.postWebhookMentions;
95
- await post('board_room', line, result.mentioned);
96
+ const sent = await post(CHANNEL, line, result.mentioned);
96
97
  result.posted = true;
98
+ // Remember WHICH message this was, so R22's close can be edited onto it. A
99
+ // failed remember is not a failed announcement: the nudge is already in the
100
+ // channel, and the close then falls back to posting its outcome fresh.
101
+ const messageId = sent && (sent.message_id || (sent.message && sent.message.id));
102
+ if (messageId) {
103
+ const record = deps.recordMessage || ((row) => db.recordBoardMessage(row));
104
+ try {
105
+ await record({ itemId: payload.item_id, channel: CHANNEL, messageId: String(messageId) });
106
+ result.recorded = String(messageId);
107
+ } catch (e) {
108
+ log(`[discord] board announcement posted but not recorded (item ${payload.item_id}; the close will post fresh): ${e && e.message}`);
109
+ }
110
+ }
97
111
  log(`[discord] board announcement posted (item ${payload.item_id}; ${result.mentioned.length} mentioned, ${result.named.length} named)`);
98
112
  } catch (e) {
99
113
  result.skipped = 'post_failed';
@@ -102,4 +116,97 @@ async function postBoardAnnouncement(payload, deps = {}) {
102
116
  return result;
103
117
  }
104
118
 
105
- module.exports = { postBoardAnnouncement };
119
+ // ── shared composition ───────────────────────────────────────────────────────
120
+
121
+ const CHANNEL = 'board_room';
122
+
123
+ function subjectLabel(subjectType) {
124
+ return subjectType === 'constitutional_amendment' ? 'A constitutional amendment' : 'A Full Idea';
125
+ }
126
+
127
+ // Straight to THAT item: ?item=<id> — the Board Room panel reads it and scrolls
128
+ // to / highlights the named sitting (the hash stays the tab).
129
+ function itemLink(itemId) {
130
+ const origin = ((api.branding && api.branding().domains) || {}).buildersOrigin || '';
131
+ return `${origin ? origin.replace(/\/$/, '') : ''}/government?item=${encodeURIComponent(itemId)}#board-room`;
132
+ }
133
+
134
+ // ── the close (BV1.R22 of goal 1000069) ──────────────────────────────────────
135
+
136
+ // PURE. The outcome message that replaces the summons, composed from the event
137
+ // payload alone — no Discord call, no DB read, so the wording is testable and the
138
+ // tally can never disagree with the row the government closed.
139
+ //
140
+ // WHAT IT DELIBERATELY DOES NOT SAY: nobody's name and nobody's vote. The open
141
+ // ballot is a HALL surface (ADR 0175 §9 — votes are visible to everyone there);
142
+ // mirroring who objected into a chat channel turns a reasoned objection into a
143
+ // public callout, and the reason plus its flagged section only make sense
144
+ // alongside the idea. The count is the honest summary; the link is the record.
145
+ function composeOutcomeLine(payload) {
146
+ const subject = subjectLabel(payload.subject_type);
147
+ const tally = payload.tally || {};
148
+ const yes = Number(tally.yes) || 0;
149
+ const no = Number(tally.no) || 0;
150
+ const verdict = payload.outcome === 'passed'
151
+ ? 'RATIFIED by the board'
152
+ : 'RETURNED to its author';
153
+ // A zero-vote pass is a real, designed outcome (ADR 0175 §3 — an absence is
154
+ // nulled, never a block), so it says what happened rather than leaving the
155
+ // room to read "0 in favour" as a mistake.
156
+ const counted = (yes + no) === 0
157
+ ? 'nobody objected before the window closed'
158
+ : `${yes} in favour, ${no} ${no === 1 ? 'objection' : 'objections'}`;
159
+ const how = payload.cause === 'expiry'
160
+ ? 'the window ran out'
161
+ : 'the vote closed it';
162
+ const tail = payload.outcome === 'passed'
163
+ ? 'The record:'
164
+ : 'The objection and the section it flags are in the hall:';
165
+ return `\u{1F3DB}\u{FE0F} ${subject} (item ${payload.item_id}) \u2014 ${verdict}. `
166
+ + `${counted}; ${how}. ${tail} ${itemLink(payload.item_id)}`;
167
+ }
168
+
169
+ // Mirror the close onto the announcement. Result shape mirrors the announcer's:
170
+ // { edited, posted, skipped }
171
+ // edited — the summons itself now reads as the outcome (the happy path)
172
+ // posted — no message to edit, so the outcome went out fresh instead
173
+ // skipped — a NAMED reason; never a throw, so a Discord hiccup can never fail
174
+ // a close (the close already committed — this runs after it).
175
+ async function postBoardOutcome(payload, deps = {}) {
176
+ const result = { edited: false, posted: false, skipped: null };
177
+ if (!payload || payload.item_id == null) { result.skipped = 'no_item'; return result; }
178
+ if (payload.outcome !== 'passed' && payload.outcome !== 'returned') { result.skipped = 'no_outcome'; return result; }
179
+ const log = deps.log || console.log;
180
+ const line = composeOutcomeLine(payload);
181
+ try {
182
+ const getMessage = deps.getMessage || ((itemId) => db.getBoardMessage(itemId));
183
+ let row = null;
184
+ try { row = await getMessage(payload.item_id); } catch (_) { /* unreadable reads as unannounced */ }
185
+ if (row && row.message_id) {
186
+ const edit = deps.edit || webhook.editWebhook;
187
+ await edit(row.channel || CHANNEL, row.message_id, line);
188
+ result.edited = true;
189
+ const mark = deps.markEdited || ((itemId) => db.markBoardMessageEdited(itemId));
190
+ // The stamp is bookkeeping ONLY — `edited_at IS NULL` answers "does the
191
+ // channel still show this as open?" without asking Discord. A failed stamp
192
+ // must not turn a mirrored close into a reported failure.
193
+ try { await mark(payload.item_id); } catch (_) { /* the edit is what matters */ }
194
+ log(`[discord] board close mirrored onto the announcement (item ${payload.item_id}; ${payload.outcome})`);
195
+ return result;
196
+ }
197
+ // Nothing to edit: the window was never announced (Discord off at open time,
198
+ // an item older than R22), or the id could not be kept. Posting the outcome
199
+ // fresh keeps the room honest — and because this branch is reached only when
200
+ // there is NO stored message, a failed edit can never duplicate a notice.
201
+ const post = deps.post || webhook.postWebhook;
202
+ await post(CHANNEL, line);
203
+ result.posted = true;
204
+ log(`[discord] board close posted fresh — no announcement to edit (item ${payload.item_id}; ${payload.outcome})`);
205
+ } catch (e) {
206
+ result.skipped = 'mirror_failed';
207
+ log(`[discord] board close mirror skipped (the close itself is unaffected): ${e && e.message}`);
208
+ }
209
+ return result;
210
+ }
211
+
212
+ module.exports = { postBoardAnnouncement, postBoardOutcome, composeOutcomeLine };
@@ -79,6 +79,47 @@ async function countRecentDiscordBugFilings(builderId, sinceIso) {
79
79
  return rows[0].n;
80
80
  }
81
81
 
82
+ // --- the board mirror's own bookkeeping (R22 of goal 1000069) ----------------
83
+ //
84
+ // Which message announced which board item, so the close can be EDITED onto the
85
+ // same message instead of appended after it. Mirror state, discord-owned
86
+ // (discord_001_board_messages) — government's schema stays ignorant of whether a
87
+ // mirror exists.
88
+
89
+ // Upsert: R21 announces once, but a re-announced item (a re-fired hook, a manual
90
+ // re-drive) must leave ONE row pointing at the message that is actually live.
91
+ async function recordBoardMessage({ itemId, channel, messageId }) {
92
+ const { rows } = await pool.query(
93
+ `INSERT INTO discord_board_messages (item_id, channel, message_id)
94
+ VALUES ($1, $2, $3)
95
+ ON CONFLICT (item_id) DO UPDATE
96
+ SET channel = EXCLUDED.channel, message_id = EXCLUDED.message_id,
97
+ posted_at = now(), edited_at = NULL
98
+ RETURNING item_id, channel, message_id, posted_at, edited_at`,
99
+ [itemId, channel, String(messageId)],
100
+ );
101
+ return rows[0] || null;
102
+ }
103
+
104
+ async function getBoardMessage(itemId) {
105
+ const { rows } = await pool.query(
106
+ `SELECT item_id, channel, message_id, posted_at, edited_at
107
+ FROM discord_board_messages WHERE item_id = $1`,
108
+ [itemId],
109
+ );
110
+ return rows[0] || null;
111
+ }
112
+
113
+ // Stamped only after Discord accepted the edit, so `edited_at IS NULL` keeps
114
+ // meaning "the channel still shows this window as open".
115
+ async function markBoardMessageEdited(itemId) {
116
+ const { rowCount } = await pool.query(
117
+ `UPDATE discord_board_messages SET edited_at = now() WHERE item_id = $1`,
118
+ [itemId],
119
+ );
120
+ return { stamped: rowCount > 0 };
121
+ }
122
+
82
123
  async function recordApplause({ targetTaskId, discordUserId }) {
83
124
  const { rowCount } = await pool.query(
84
125
  `INSERT INTO discord_applause (target_task_id, discord_user_id)
@@ -97,6 +138,9 @@ module.exports = {
97
138
  getDiscordLinks,
98
139
  countRecentDiscordBugFilings,
99
140
  recordApplause,
141
+ recordBoardMessage,
142
+ getBoardMessage,
143
+ markBoardMessageEdited,
100
144
  // kernel db helpers, re-exported off the doorway (one `db.*` namespace)
101
145
  insertAuditLog: (...a) => api.insertAuditLog(...a),
102
146
  // createTask is a LIFECYCLE write — resolve the `lifecycle` kernel port at call
@@ -0,0 +1,50 @@
1
+ -- discord_001_board_messages.sql — which message announced which board item
2
+ -- (task 1003035 / R22 of goal 1000069, ADR 0175 §8 / criterion C6).
3
+ --
4
+ -- R21 posts an announcement when a window opens; R22 EDITS THAT SAME MESSAGE when
5
+ -- the item closes, so the channel never shows a stale open window. Editing needs
6
+ -- the message id Discord handed back, and a window can stay open across restarts —
7
+ -- so the id has to be durable, and this is the smallest durable place for it.
8
+ --
9
+ -- WHY THIS TABLE LIVES IN THE DISCORD MODULE, NOT IN GOVERNMENT'S SCHEMA: a Discord
10
+ -- message id is mirror state, not government state (ADR 0033 — Discord is a one-way
11
+ -- window, never authority). government_board_items must stay ignorant of whether a
12
+ -- mirror exists; the mirror remembers its own bookkeeping. Module migrations are
13
+ -- ADDITIVE and NAMESPACED (ADR 0083 §Decision #5 / ADR 0108 §5): every object is
14
+ -- discord_-prefixed, the filename stem is unique across repos, and there is no
15
+ -- down-migration. Idempotent: safe to re-run.
16
+ --
17
+ -- NO FOREIGN KEY to government_board_items ON PURPOSE. A module migration may not
18
+ -- reach across the module boundary into a sibling's table (the same rule that keeps
19
+ -- anti-self-vote in the route rather than in a constraint — see
20
+ -- government_003_board_tables.sql), and the discord module is optional: an instance
21
+ -- that runs the board with Discord disabled must not carry a dangling reference.
22
+ -- A row here whose item is gone is harmless — the mirror simply has nothing to edit.
23
+
24
+ BEGIN;
25
+
26
+ CREATE TABLE IF NOT EXISTS discord_board_messages (
27
+ -- One announcement per board item: the mirror is edited in place, never appended
28
+ -- to, so PRIMARY KEY (not merely UNIQUE) is the honest shape and the upsert below
29
+ -- has a conflict target that can never admit a second row for the same sitting.
30
+ item_id bigint PRIMARY KEY,
31
+
32
+ -- The channel key (webhook.js CHANNELS), not a Discord snowflake: the edit must
33
+ -- go back through the SAME webhook that posted, and the webhook URL is resolved
34
+ -- from this key at edit time (config/env can be re-pointed between the post and
35
+ -- the close without stranding the row).
36
+ channel text NOT NULL,
37
+
38
+ -- Discord snowflakes exceed 2^53 and are strings in Discord's own API — text, so
39
+ -- no JSON round-trip can ever round one off.
40
+ message_id text NOT NULL,
41
+
42
+ posted_at timestamptz NOT NULL DEFAULT now(),
43
+
44
+ -- Set when the close is mirrored. NULL means "announced, not yet resolved" — a
45
+ -- read that answers "does this channel still show an open window?" without
46
+ -- asking Discord anything.
47
+ edited_at timestamptz
48
+ );
49
+
50
+ COMMIT;
@@ -6,6 +6,7 @@
6
6
  "default": false,
7
7
  "maintenance": { "status": "core-maintained" },
8
8
  "contributes": {
9
+ "migrations": true,
9
10
  "routes": ["discord", "bug-attachments"],
10
11
  "pollers": ["discord-bot"],
11
12
  "uiSections": []
@@ -45,6 +45,7 @@ let _shipListenerOff = null;
45
45
  let _strandListenerOff = null;
46
46
  let _roleSyncListenerOff = null;
47
47
  let _boardListenerOff = null;
48
+ let _boardCloseListenerOff = null;
48
49
  function registerSeams() {
49
50
  if (!api.hasProvider('discord.isLinked')) {
50
51
  api.registerProvider('discord.isLinked', async (builderId) => !!(await db.getDiscordLink(builderId)));
@@ -64,6 +65,11 @@ function registerSeams() {
64
65
  // — the government module emits, this module posts. Outbound only (§8).
65
66
  if (_boardListenerOff) _boardListenerOff();
66
67
  _boardListenerOff = api.on('board.item.opened', (payload) => boardBroadcast.postBoardAnnouncement(payload));
68
+ // R22: the same sitting, closed — the announcement is EDITED into an outcome
69
+ // notice, so the channel never shows a stale open window. Fires on a return as
70
+ // well as a pass; still outbound only, still nothing cast from Discord.
71
+ if (_boardCloseListenerOff) _boardCloseListenerOff();
72
+ _boardCloseListenerOff = api.on('board.item.closed', (payload) => boardBroadcast.postBoardOutcome(payload));
67
73
  if (_roleSyncListenerOff) _roleSyncListenerOff();
68
74
  _roleSyncListenerOff = api.on('builder.role-sync-needed', ({ builderId }) =>
69
75
  roleSync.syncBuilderRole(builderId).catch((e) =>
@@ -7,7 +7,10 @@
7
7
  // module needs lives IN the module. scripts/discord/lib.js stays for the two CORE
8
8
  // consumers that still post webhooks (src/bongos/approval-broadcast.js + the
9
9
  // scripts/discord/post.js CLI). KEEP THE TWO IN SYNC — same channels, same
10
- // 2000-char cap, same allowed_mentions suppression (#871). Config is read through
10
+ // 2000-char cap, same allowed_mentions suppression (#871) — the R22 board-mirror
11
+ // edit path (postCore's `wait=true` + editWebhook) is deliberately module-only:
12
+ // no core consumer edits a message it posted, so copying it into the twin would
13
+ // add an unused surface rather than keep the two honest. Config is read through
11
14
  // the published doorway (configPath) so the path resolves identically.
12
15
  //
13
16
  // Discord is a one-way window into the GDS (the trust-boundary ADR): this only
@@ -48,7 +51,7 @@ function loadWebhooks() {
48
51
  // #871's rule holds in both: parse:[] always, so nothing typed INTO content can
49
52
  // ever ring — postWebhookMentions adds an explicit server-resolved user
50
53
  // allowlist and nothing else.
51
- async function postCore(channel, text, allowedMentions) {
54
+ function resolveWebhookUrl(channel) {
52
55
  if (!CHANNELS.has(channel)) {
53
56
  throw new Error(`Unknown Discord channel '${channel}'. Known: ${[...CHANNELS].join(', ')}`);
54
57
  }
@@ -59,18 +62,34 @@ async function postCore(channel, text, allowedMentions) {
59
62
  `or env DISCORD_${channel.toUpperCase()}_WEBHOOK. See scripts/discord/README.md.`
60
63
  );
61
64
  }
62
- // Discord hard-caps message content at 2000 chars.
63
- const content = String(text).slice(0, 2000);
64
- const res = await fetch(url, {
65
+ return url;
66
+ }
67
+
68
+ // Discord hard-caps message content at 2000 chars — one place, so a post and an
69
+ // edit of the same text can never disagree about what got truncated.
70
+ function capContent(text) {
71
+ return String(text).slice(0, 2000);
72
+ }
73
+
74
+ // R22 (the board mirror) needs the id of the message it just posted so the close
75
+ // can be edited onto it, so the POST asks for the created message back
76
+ // (`?wait=true` — Discord then answers 200 + the message JSON instead of 204 with
77
+ // no body). The extra field is additive: every existing caller ignores the return.
78
+ async function postCore(channel, text, allowedMentions) {
79
+ const url = resolveWebhookUrl(channel);
80
+ const res = await fetch(`${url}${url.includes('?') ? '&' : '?'}wait=true`, {
65
81
  method: 'POST',
66
82
  headers: { 'Content-Type': 'application/json' },
67
- body: JSON.stringify({ content, allowed_mentions: allowedMentions }),
83
+ body: JSON.stringify({ content: capContent(text), allowed_mentions: allowedMentions }),
68
84
  });
69
85
  if (!res.ok) {
70
86
  const body = await res.text().catch(() => '');
71
87
  throw new Error(`Discord webhook POST -> ${res.status}: ${body.slice(0, 300)}`);
72
88
  }
73
- return { ok: true, status: res.status }; // 204 No Content on success
89
+ // `wait=true` answers with the message; a body we cannot parse is not a failed
90
+ // post — the message IS in the channel, we just cannot edit it later.
91
+ const message = await res.json().catch(() => null);
92
+ return { ok: true, status: res.status, message, message_id: message && message.id ? String(message.id) : null };
74
93
  }
75
94
 
76
95
  // SECURITY (#871): suppress ALL mention pings. The content is DB-sourced and
@@ -91,4 +110,30 @@ async function postWebhookMentions(channel, text, userIds = []) {
91
110
  return postCore(channel, text, { parse: [], users });
92
111
  }
93
112
 
94
- module.exports = { CHANNELS, WEBHOOKS_PATH, loadWebhooks, postWebhook, postWebhookMentions };
113
+ // editWebhook — rewrite a message this webhook already posted, in place (R22 of
114
+ // goal 1000069). PATCH /webhooks/<id>/<token>/messages/<message_id>, which is the
115
+ // only edit a webhook is allowed: its OWN messages, in the channel it owns.
116
+ //
117
+ // FULLY SUPPRESSED, ALWAYS. The edit is an outcome notice, not a summons — and an
118
+ // edit that carried a mentions allowlist could re-ring a board that already voted.
119
+ // parse:[] with no `users` is #871's default, and the mention exception stays
120
+ // scoped to the one post that means "your vote is wanted".
121
+ async function editWebhook(channel, messageId, text) {
122
+ const url = resolveWebhookUrl(channel);
123
+ const [base] = String(url).split('?');
124
+ const res = await fetch(`${base.replace(/\/$/, '')}/messages/${encodeURIComponent(String(messageId))}`, {
125
+ method: 'PATCH',
126
+ headers: { 'Content-Type': 'application/json' },
127
+ body: JSON.stringify({ content: capContent(text), allowed_mentions: { parse: [] } }),
128
+ });
129
+ if (!res.ok) {
130
+ const body = await res.text().catch(() => '');
131
+ throw new Error(`Discord webhook PATCH -> ${res.status}: ${body.slice(0, 300)}`);
132
+ }
133
+ return { ok: true, status: res.status };
134
+ }
135
+
136
+ module.exports = {
137
+ CHANNELS, WEBHOOKS_PATH, loadWebhooks, resolveWebhookUrl,
138
+ postWebhook, postWebhookMentions, editWebhook,
139
+ };
@@ -106,6 +106,31 @@ async function emitWindowOpened(item) {
106
106
  } catch (_) { /* a nudge that fails is a quiet sitting, never a failed open */ }
107
107
  }
108
108
 
109
+ // The close event (R22's Discord mirror): the summons that R21 posted is edited
110
+ // into an outcome notice, so the channel never shows a stale open window. Emitted
111
+ // for a RETURN as well as a pass, with the tally the close already counted — no
112
+ // consumer should have to re-read votes to state what happened. Best-effort by
113
+ // construction, like the open nudge.
114
+ async function emitItemClosed(closed, { cause, votes } = {}) {
115
+ try {
116
+ if (!api.emitAsync) return;
117
+ const cast = votes || [];
118
+ await api.emitAsync('board.item.closed', {
119
+ item_id: String(closed.id),
120
+ subject_type: closed.subject_type,
121
+ subject_id: String(closed.subject_id),
122
+ outcome: closed.outcome,
123
+ cause: cause || 'vote',
124
+ closed_at: closed.closed_at || null,
125
+ tally: {
126
+ yes: cast.filter((v) => v.direction === 'yes').length,
127
+ no: cast.filter((v) => v.direction === 'no').length,
128
+ total: cast.length,
129
+ },
130
+ });
131
+ } catch (_) { /* a mirror that fails is a quiet channel, never a failed close */ }
132
+ }
133
+
109
134
  // ── the vote (BV1.R08 of goal 1000069, ADR 0175 §§4/6/9) ────────────────────
110
135
  //
111
136
  // OPEN BALLOT, ONE VOTE, NO EDITING. A vote is member + direction + timestamp,
@@ -388,6 +413,16 @@ async function closeItem(item, { cause = 'vote' } = {}) {
388
413
  } catch (_) { /* emitAsync isolates listener errors; nothing that moves credits subscribes */ }
389
414
  }
390
415
 
416
+ // R22's mirror event: BOTH outcomes, after commit, emitAsync. Separate from
417
+ // `board.item.passed` on purpose — that one is the pass CONTRACT the credit lane
418
+ // reads (docs/specs/board-room-pass-event-contract.md: seven fields, no tally),
419
+ // and a mirror needs the opposite things: it fires on a return too, and it
420
+ // carries the count so the channel can state the outcome without reading the DB.
421
+ // Widening the contract event to cover returns would put a payer on a path that
422
+ // also fires for "nothing was ratified". Nothing that decides or pays may
423
+ // subscribe here — the row is the durable fact, this is a nudge.
424
+ await emitItemClosed(closed, { cause, votes });
425
+
391
426
  return {
392
427
  closed: true, item: closed, outcome, cause, author_karma: authorKarma,
393
428
  ...(credit ? { credit } : {}),
@@ -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). */