@bongos/core 1.19.213 → 1.19.215

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.bongos-core.json CHANGED
@@ -2,11 +2,11 @@
2
2
  "artifact": "bongos-core",
3
3
  "manifest_schema": 1,
4
4
  "generator": "scripts/gds/package-core.js",
5
- "core_version": "1.19.213",
6
- "core_contract": "1.19.213",
7
- "source_commit": "7cd1ca35002e12c66a3937a55bf8c9e6e69fd231",
5
+ "core_version": "1.19.215",
6
+ "core_contract": "1.19.215",
7
+ "source_commit": "697713806bfbd222ba0423c021aee84f56218f10",
8
8
  "source_ref": "HEAD",
9
- "built_at": "2026-08-17T23:35:49.388Z",
9
+ "built_at": "2026-08-18T00:22:11.691Z",
10
10
  "redaction": {
11
11
  "model": "docs-redacted+functional-verbatim",
12
12
  "docs_redacted": 310,
@@ -17,7 +17,7 @@
17
17
  "gate": "passed"
18
18
  },
19
19
  "file_count": 1633,
20
- "tree_sha256": "08e3fcc476f1d9209616049d1d79c946c56394953d7891706e2e41f8e3b28067",
20
+ "tree_sha256": "61d4b6bf40452f45053fe94cf02b44d682b2aed0e9684b0799a0d33eda1f675d",
21
21
  "files": [
22
22
  {
23
23
  "path": ".claude/skills/blocker-review/SKILL.md",
@@ -1372,7 +1372,7 @@
1372
1372
  {
1373
1373
  "path": "docs/api/openapi.json",
1374
1374
  "mode": "0000644",
1375
- "sha256": "274bd57fc6c0b83aba02cedb5cc6daa2fca4a9d74ae5268e527a81ebc10b50e0"
1375
+ "sha256": "675d873f35c886145c80d954b885692ef0608b4e5b1b843943611f59260eceed"
1376
1376
  },
1377
1377
  {
1378
1378
  "path": "docs/architecture.md",
@@ -3557,7 +3557,7 @@
3557
3557
  {
3558
3558
  "path": "modules/lifecycle/db.js",
3559
3559
  "mode": "0000644",
3560
- "sha256": "7fa58e9a9c09ba4857d57fe7cc1ed36d827d34f8cc1119ae42dc9dffdd4653b5"
3560
+ "sha256": "3d88ff6be3d8378f0cf2826af92437dfafbeb589a677aa8928d9f325124eac5b"
3561
3561
  },
3562
3562
  {
3563
3563
  "path": "modules/lifecycle/done-when.js",
@@ -3652,7 +3652,7 @@
3652
3652
  {
3653
3653
  "path": "modules/lifecycle/routes/goals.js",
3654
3654
  "mode": "0000644",
3655
- "sha256": "a291701ef3b3f8bb548d8e3475707e52562d0c9f03d334a885ff2022c90358dc"
3655
+ "sha256": "8763c40223e27e9d6fc65ea76442e479301407ba70e77b9582c5ffcd09f17a11"
3656
3656
  },
3657
3657
  {
3658
3658
  "path": "modules/lifecycle/routes/lifecycle.js",
@@ -4282,12 +4282,12 @@
4282
4282
  {
4283
4283
  "path": "package-lock.json",
4284
4284
  "mode": "0000644",
4285
- "sha256": "b9113d8027c9a36b9d9923e3a850c655a0dfb57ce59633ba81042f1116127882"
4285
+ "sha256": "5a687e1b2df8e52068aaa27dfa92dd50ed65164905c74f9fe121750d023e95d9"
4286
4286
  },
4287
4287
  {
4288
4288
  "path": "package.json",
4289
4289
  "mode": "0000644",
4290
- "sha256": "27122dc34fcd4dbe96f345a2cdf6045447a4a94a20217e43f52c803e25576e02"
4290
+ "sha256": "6b2203d8fa449d80e6ff225fd5f8dbe4969b4a72ca94ff61e478635fb754d445"
4291
4291
  },
4292
4292
  {
4293
4293
  "path": "public-docs/index.html",
@@ -5902,7 +5902,7 @@
5902
5902
  {
5903
5903
  "path": "src/module-api.js",
5904
5904
  "mode": "0000644",
5905
- "sha256": "c59f5553c52308967b71da155eab8249d7c8687a37184626b4c4b03ccf2d9fc7"
5905
+ "sha256": "e2884e30e53fd3fe3c121df94ce9738bde706f3ddb414c023e6fd546ec1777ab"
5906
5906
  },
5907
5907
  {
5908
5908
  "path": "src/module-loader/catalog.js",
@@ -6797,7 +6797,7 @@
6797
6797
  {
6798
6798
  "path": "tests/goal_membership_requests.mjs",
6799
6799
  "mode": "0000644",
6800
- "sha256": "4bc1ecb2bec4345b55e074a06009d31f97e4b5e06b214669a72e485298307ac8"
6800
+ "sha256": "59253dac0064ae1a9c7dd67ef87c1d32ff924d3c3dadf7cdb9553dbc76f88eb0"
6801
6801
  },
6802
6802
  {
6803
6803
  "path": "tests/goal_routes.mjs",
@@ -6812,7 +6812,7 @@
6812
6812
  {
6813
6813
  "path": "tests/goal_tier.mjs",
6814
6814
  "mode": "0000644",
6815
- "sha256": "508b8b99afe67f64c86b5e1b382052e02a4cc916bec58e28d396ba3716998baf"
6815
+ "sha256": "c2e37bb797c1e9af23970bfee7587b16cd13f1fac23c0e334a5ea0d362174491"
6816
6816
  },
6817
6817
  {
6818
6818
  "path": "tests/goal_wall_proof.mjs",
@@ -4276,7 +4276,7 @@
4276
4276
  "goals"
4277
4277
  ],
4278
4278
  "summary": "POST /goals/:id/invitations/:reqId/respond",
4279
- "description": "POST /goals/:id/invitations/:reqId/respond { action: accept|decline } — ONLY the invited builder resolves their OWN pending invite (task 1952). accept → they're added as a member; decline → the row is rejected. A protected-scope goal still requires a Metic+ member, re-checked against the accepting builder's current rank (the goal could have been re-scoped after the invite). rank: any-builder (requireBuilder) + the in-handler authorizeInvitationResponse.\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
4279
+ "description": "POST /goals/:id/invitations/:reqId/respond { action: accept|decline } — ONLY the invited builder resolves their OWN pending invite (task 1952). accept → they're added as a member (only while the goal is still OPEN, and a protected-scope goal still requires a Metic+ member — both re-checked against the CURRENT goal row by authorizeInvitationAccept, task 1002887); decline → the row is rejected, allowed whatever the goal's state, since that is inbox cleanup rather than admission. rank: any-builder (requireBuilder) + the in-handler authorizeInvitationResponse.\n\n**Rank:** `any-builder` — Any authenticated builder (row-level ownership enforced in-handler).",
4280
4280
  "x-rank": "any-builder",
4281
4281
  "x-source": "modules/lifecycle/routes/goals.js",
4282
4282
  "parameters": [
@@ -854,15 +854,48 @@ async function achieveGoalIfComplete({ goalId }, deps = {}) {
854
854
  // owner's row — bypassing authorizeMemberRoleChange's owner-only-demotion
855
855
  // guards. Role changes go through PATCH /goals/:id/members/:builderId only
856
856
  // (the acceptMembershipRequestAndAddMember precedent). role defaults to 'member'.
857
+ //
858
+ // ARRIVING AS A MEMBER CLOSES ANY PENDING ASK (task 1002888 / idea 1000688).
859
+ // The membership write used to be a bare upsert that never looked at
860
+ // goal_membership_requests, so a builder's pending row SURVIVED their arrival by
861
+ // any other route — the goal flips to public and they self-join, or an Archon
862
+ // adds them directly. The stale row lingered in the owner/manager inbox, a later
863
+ // approve resolved a request for an existing member, and a later deny stamped
864
+ // 'rejected' on someone who is in fact a member. Harmless to access control,
865
+ // wrong in the audit trail.
866
+ //
867
+ // BOTH kinds are cancelled, not just 'request'. The partial-unique index
868
+ // (core_188: one pending row per goal+builder, either direction) means there is
869
+ // at most ONE pending row to close, and a stale pending INVITE is the same bug
870
+ // mirrored — the invitee could later decline an invitation to a goal they are
871
+ // already in, stamping 'rejected' on a member. 'cancelled' is the right terminal
872
+ // state (core_188: "withdrawn/rescinded"); 'accepted' stays reserved for an
873
+ // accept/approve flow that actually ran, so the inbox history never claims a
874
+ // decision nobody made. resolvedBy is the ACTOR — the joiner themselves on
875
+ // /join, the adding owner/manager/Archon on /members.
876
+ //
877
+ // One CTE, not two queries — the acceptMembershipRequestAndAddMember precedent:
878
+ // the cancel commits with the membership write or not at all, so an error
879
+ // between them can't leave the pair half-applied. The two arms are independent
880
+ // (Postgres runs a data-modifying CTE to completion whether or not the primary
881
+ // query reads it), and both see the same snapshot.
857
882
 
858
- async function addGoalMember({ goalId, builderId, role = 'member' }, deps = {}) {
883
+ async function addGoalMember({ goalId, builderId, role = 'member', resolvedBy = null }, deps = {}) {
859
884
  const activePool = (deps.pool && process.env.NODE_ENV === 'test') ? deps.pool : pool;
860
885
  const { rows } = await activePool.query(
861
- `INSERT INTO goal_members (goal_id, builder_id, role)
862
- VALUES ($1, $2, $3)
863
- ON CONFLICT (goal_id, builder_id) DO UPDATE SET role = goal_members.role
864
- RETURNING goal_id, builder_id, role, joined_at`,
865
- [goalId, builderId, role]
886
+ `WITH added AS (
887
+ INSERT INTO goal_members (goal_id, builder_id, role)
888
+ 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
891
+ ), cancelled AS (
892
+ UPDATE goal_membership_requests
893
+ SET status = 'cancelled', resolved_by = $4, resolved_at = now(), updated_at = now()
894
+ WHERE goal_id = $1 AND builder_id = $2 AND status = 'pending'
895
+ RETURNING id
896
+ )
897
+ SELECT goal_id, builder_id, role, joined_at FROM added`,
898
+ [goalId, builderId, role, resolvedBy]
866
899
  );
867
900
  return rows[0];
868
901
  }
@@ -324,6 +324,44 @@ function authorizeInvitationResponse({ request, goalId, actorId }) {
324
324
  return { ok: true };
325
325
  }
326
326
 
327
+ // authorizeInvitationAccept — the ORDERED, FAIL-CLOSED gate for the ACCEPT half of
328
+ // POST /goals/:id/invitations/:reqId/respond, run against the GOAL after
329
+ // authorizeInvitationResponse has cleared the request row (task 1002887).
330
+ //
331
+ // Accepting is an ADMISSION, so it needs the same goal-state floor every other
332
+ // admission path already has: the create side requires an open goal
333
+ // (authorizeGoalManagement) and the join-request approve side inherits that check
334
+ // verbatim — only invitation-accept was missing it, so an invite issued while a
335
+ // goal was open could still add a member AFTER the goal was achieved or archived.
336
+ // DECLINE is deliberately NOT gated here: clearing a stale invitation out of your
337
+ // inbox is cleanup, not admission (the route returns before calling this).
338
+ // Ordering mirrors the sibling routes: goal_not_found → goal_not_open →
339
+ // metic_required_for_protected_scope (the pre-existing protected-scope membership
340
+ // floor, re-asserted against the ACCEPTING builder's current rank because the goal
341
+ // may have been re-scoped since the invite; the actor-is-Archon half of
342
+ // protectedScopeAdmissionBlock does not apply — the invitee is consenting).
343
+ //
344
+ // Unlike its {status, body} siblings this returns a res.fail SPEC ({ code, status,
345
+ // message, details }) because the accept branch already answers through res.fail —
346
+ // routing it through the envelope keeps the existing metic_required_for_protected_scope
347
+ // response byte-identical while still being PURE + exported for DB-free tests.
348
+ function authorizeInvitationAccept({ goal, scopeProtected, actorRank }) {
349
+ if (!goal) {
350
+ return { ok: false, code: 'goal_not_found', status: 404,
351
+ message: 'The goal behind this invitation no longer exists.' };
352
+ }
353
+ if (goal.status !== 'open') {
354
+ return { ok: false, code: 'goal_not_open', status: 409,
355
+ message: 'This goal is no longer open — an invitation can only be accepted while it is. You can still decline it.',
356
+ details: { status: goal.status } };
357
+ }
358
+ if (scopeProtected && !METIC_PLUS.includes(actorRank)) {
359
+ return { ok: false, code: 'metic_required_for_protected_scope', status: 403,
360
+ message: 'This goal is scoped to a protected module and requires a Metic+ member.' };
361
+ }
362
+ return { ok: true };
363
+ }
364
+
327
365
  // authorizeJoinRequestResponse — the gate for POST /goals/:id/requests/:reqId/respond
328
366
  // (task 1952): an owner|manager|Archon approves/denies a pending join-request. The
329
367
  // authority prefix is authorizeGoalManagement VERBATIM (goal exists/open + owner|
@@ -580,7 +618,10 @@ module.exports = function buildGoalsRouter() {
580
618
  return res.fail('goal_private', { status: 409, message: 'This goal is private — request to join (POST /goals/:id/requests) or wait for an invitation.' });
581
619
  }
582
620
  }
583
- const member = await db.addGoalMember({ goalId: id, builderId: req.builder.id, role: 'member' });
621
+ // resolvedBy: the joiner — arriving as a member cancels their own still-
622
+ // pending ask on this goal, atomically with the membership write (task
623
+ // 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 });
584
625
  return res.status(201).json({ ok: true, member });
585
626
  } catch (err) {
586
627
  console.error('[gds] POST /goals/:id/join', err);
@@ -786,9 +827,10 @@ module.exports = function buildGoalsRouter() {
786
827
 
787
828
  // POST /goals/:id/invitations/:reqId/respond { action: accept|decline } — ONLY the
788
829
  // invited builder resolves their OWN pending invite (task 1952). accept → they're
789
- // added as a member; decline → the row is rejected. A protected-scope goal still
790
- // requires a Metic+ member, re-checked against the accepting builder's current rank
791
- // (the goal could have been re-scoped after the invite).
830
+ // added as a member (only while the goal is still OPEN, and a protected-scope goal
831
+ // still requires a Metic+ member — both re-checked against the CURRENT goal row by
832
+ // authorizeInvitationAccept, task 1002887); decline → the row is rejected, allowed
833
+ // whatever the goal's state, since that is inbox cleanup rather than admission.
792
834
  // rank: any-builder (requireBuilder) + the in-handler authorizeInvitationResponse.
793
835
  router.post('/goals/:id/invitations/:reqId/respond', auth.requireBuilder, async (req, res) => {
794
836
  const id = parseId(req, res, { code: 'bad_goal_id' });
@@ -811,13 +853,14 @@ module.exports = function buildGoalsRouter() {
811
853
  return res.json({ ok: true, action, request: resolved });
812
854
  }
813
855
 
814
- // accept — re-assert the protected-scope Metic+ membership invariant against
815
- // the accepting builder's CURRENT rank (the goal may have been re-scoped since
816
- // the invite). The invitee is consenting, so the actor-is-Archon half of the
817
- // admission rule does NOT apply here — only the Metic+ membership floor does.
856
+ // accept — the goal-state + protected-scope floor, read against the goal's
857
+ // CURRENT row (fail-closed if it is gone). See authorizeInvitationAccept.
818
858
  const goal = await db.getGoal(id);
819
- if (goal && scopeIncludesProtected(goal.scope_modules) && !METIC_PLUS.includes(req.builder.rank)) {
820
- return res.fail('metic_required_for_protected_scope', { status: 403, message: 'This goal is scoped to a protected module and requires a Metic+ member.' });
859
+ const admit = authorizeInvitationAccept({
860
+ goal, scopeProtected: !!goal && scopeIncludesProtected(goal.scope_modules), actorRank: req.builder.rank,
861
+ });
862
+ if (!admit.ok) {
863
+ return res.fail(admit.code, { status: admit.status, message: admit.message, details: admit.details });
821
864
  }
822
865
  // Resolve + add atomically (one statement) — never an 'accepted' invite
823
866
  // with no membership (the task-1952 review fix).
@@ -939,7 +982,10 @@ module.exports = function buildGoalsRouter() {
939
982
  if (scopeIncludesProtected(goal.scope_modules) && !METIC_PLUS.includes(target.rank)) {
940
983
  return res.fail('metic_required_for_protected_scope', { status: 403, message: 'A protected-scope goal requires a Metic+ member; sub-Metic membership is deferred to the Phase-5 ADR.' });
941
984
  }
942
- const member = await db.addGoalMember({ goalId: id, builderId, role });
985
+ // resolvedBy: the ADDER (not the target) — a direct add closes the target's
986
+ // pending ask, and the audit trail should name whoever admitted them
987
+ // (task 1002888).
988
+ const member = await db.addGoalMember({ goalId: id, builderId, role, resolvedBy: req.builder.id });
943
989
  return res.status(201).json({ ok: true, member });
944
990
  } catch (err) {
945
991
  console.error('[gds] POST /goals/:id/members', err);
@@ -1499,6 +1545,9 @@ module.exports.RESOLVE_ACTIONS = RESOLVE_ACTIONS;
1499
1545
  module.exports.protectedScopeAdmissionBlock = protectedScopeAdmissionBlock;
1500
1546
  module.exports.authorizeCreateJoinRequest = authorizeCreateJoinRequest;
1501
1547
  module.exports.authorizeInvitationResponse = authorizeInvitationResponse;
1548
+ // task 1002887: the goal-state floor on the ACCEPT half of the invitation-respond
1549
+ // route — exported for tests/goal_membership_requests.mjs, same precedent.
1550
+ module.exports.authorizeInvitationAccept = authorizeInvitationAccept;
1502
1551
  module.exports.authorizeJoinRequestResponse = authorizeJoinRequestResponse;
1503
1552
  // task 1953: the withdraw gate — exported for tests/goal_membership_requests.mjs,
1504
1553
  // same precedent as its siblings above.
package/package-lock.json CHANGED
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "@bongos/core",
3
- "version": "1.19.213",
3
+ "version": "1.19.215",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "@bongos/core",
9
- "version": "1.19.213",
9
+ "version": "1.19.215",
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.213",
3
+ "version": "1.19.215",
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",
package/src/module-api.js CHANGED
@@ -853,8 +853,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
853
853
  // landed since 1.19.211 with no explicit bump. run 32080157462. (task 1002620)
854
854
  // 1.19.213 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
855
855
  // landed since 1.19.212 with no explicit bump. run 32081051255. (task 1002620)
856
+ // 1.19.214 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
857
+ // landed since 1.19.213 with no explicit bump. run 32081917908. (task 1002620)
858
+ // 1.19.215 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
859
+ // landed since 1.19.214 with no explicit bump. run 32084187695. (task 1002620)
856
860
  // ---------------------------------------------------------------------------
857
- const CORE_VERSION = '1.19.213'; // CI auto-patch carrier (ADR 0161); see changelog above
861
+ const CORE_VERSION = '1.19.215'; // CI auto-patch carrier (ADR 0161); see changelog above
858
862
 
859
863
  // A namespaced logger so a module's log lines are attributable + consistent.
860
864
  // Usage: const log = api.logger('dev-box'); log.info('mounted');
@@ -13,6 +13,11 @@
13
13
  // • listInboxFor — the three buckets, and that joinRequests gates on
14
14
  // owner (goals.created_by) OR manager (goal_members 'lead').
15
15
  // • listPendingRequestsForGoal — pending both-kinds for one goal.
16
+ // • addGoalMember (task 1002888) — arriving as a member by ANY route cancels
17
+ // that builder's still-pending ask, atomically and kind-agnostically.
18
+ // • authorizeInvitationAccept (task 1002887) — accepting an invite re-checks the
19
+ // goal is still OPEN (a stale invite can't admit to an achieved/archived goal);
20
+ // declining stays allowed, so the route declines before ever reading the goal.
16
21
  // • the migration is additive + private-safe (default 'public', partial unique).
17
22
 
18
23
  import { strict as assert } from 'node:assert';
@@ -31,6 +36,7 @@ const {
31
36
  getMembershipRequest,
32
37
  resolveMembershipRequest,
33
38
  acceptMembershipRequestAndAddMember,
39
+ addGoalMember,
34
40
  listInboxFor,
35
41
  listPendingRequestsForGoal,
36
42
  } = require('../modules/lifecycle/db.js');
@@ -39,6 +45,7 @@ const {
39
45
  protectedScopeAdmissionBlock,
40
46
  authorizeCreateJoinRequest,
41
47
  authorizeInvitationResponse,
48
+ authorizeInvitationAccept,
42
49
  authorizeJoinRequestResponse,
43
50
  authorizeWithdrawJoinRequest,
44
51
  decorateGoalScope,
@@ -190,6 +197,92 @@ await test('listPendingRequestsForGoal: pending both-kinds for one goal', async
190
197
  assert.deepEqual(find(pool.queries, /goal_membership_requests/).params, [5]);
191
198
  });
192
199
 
200
+ // =========================================================================
201
+ // addGoalMember — arriving as a member CLOSES any pending ask (task 1002888 /
202
+ // idea 1000688). The bug: the membership write was a bare upsert that never
203
+ // read goal_membership_requests, so a pending row survived the builder's
204
+ // arrival by any OTHER route (visibility flips to public and they self-join;
205
+ // an Archon adds them directly). The stale row sat in the owner/manager inbox,
206
+ // a later approve resolved a request for an existing member, and a later deny
207
+ // stamped 'rejected' on someone who is in fact a member.
208
+ // =========================================================================
209
+
210
+ const ADD_MEMBER_ROW = { goal_id: 7, builder_id: 42, role: 'member', joined_at: 'T0' };
211
+ const addMemberPool = () => fakePool((s) => (/^WITH added AS/.test(s) ? { rows: [ADD_MEMBER_ROW] } : null));
212
+
213
+ // join-cancels-request: the joiner arrives under their own steam.
214
+ await test('addGoalMember: a join cancels the joiner\'s pending ask in ONE statement', async () => {
215
+ const pool = addMemberPool();
216
+ const out = await addGoalMember({ goalId: 7, builderId: 42, role: 'member', resolvedBy: 42 }, { pool });
217
+ assert.deepEqual(out, ADD_MEMBER_ROW, 'still returns the member row — the route response shape is unchanged');
218
+ assert.equal(pool.queries.length, 1, 'ONE statement: the cancel commits with the membership write or not at all');
219
+ const q = find(pool.queries, /^WITH added AS/);
220
+ assert.ok(/INSERT INTO goal_members[\s\S]*ON CONFLICT \(goal_id, builder_id\) DO UPDATE SET role = goal_members\.role/.test(norm(q.sql)),
221
+ 'the membership upsert is the first arm, role still preserved (task 1002689)');
222
+ assert.ok(/UPDATE goal_membership_requests[\s\S]*SET status = 'cancelled', resolved_by = \$4/.test(norm(q.sql)),
223
+ 'the second arm closes the ask as cancelled, attributed to the actor');
224
+ assert.ok(/WHERE goal_id = \$1 AND builder_id = \$2 AND status = 'pending'/.test(norm(q.sql)),
225
+ 'scoped to THIS goal+builder, and only a pending row');
226
+ assert.deepEqual(q.params, [7, 42, 'member', 42], 'goal, builder, role, resolvedBy — in that order');
227
+ });
228
+
229
+ // add-cancels-request: an owner/manager/Archon admits them directly. Same
230
+ // statement; resolvedBy is the ADDER, so the audit trail names who admitted them.
231
+ await test('addGoalMember: a direct member-add cancels the target\'s pending ask, attributed to the adder', async () => {
232
+ const pool = addMemberPool();
233
+ await addGoalMember({ goalId: 7, builderId: 42, role: 'lead', resolvedBy: 99 }, { pool });
234
+ const q = find(pool.queries, /^WITH added AS/);
235
+ assert.deepEqual(q.params, [7, 42, 'lead', 99],
236
+ 'resolved_by is the ADDER (99), not the target (42) — who admitted them is the audit fact');
237
+ });
238
+
239
+ // The mirror-case decision (task 1002888): a stale pending INVITE is the same
240
+ // bug reflected — the invitee could later decline an invitation to a goal they
241
+ // are already in, stamping 'rejected' on a member. core_188's partial-unique
242
+ // index allows at most ONE pending row per (goal, builder) in EITHER direction,
243
+ // so "any pending row" is unambiguous and the cancel is deliberately kind-blind.
244
+ await test('addGoalMember: the cancel is kind-AGNOSTIC — a stale pending invite closes too', async () => {
245
+ const pool = addMemberPool();
246
+ await addGoalMember({ goalId: 7, builderId: 42, resolvedBy: 42 }, { pool });
247
+ const cancelArm = norm(find(pool.queries, /^WITH added AS/).sql).match(/UPDATE goal_membership_requests.*?RETURNING id/)[0];
248
+ assert.ok(!/kind\s*=/.test(cancelArm),
249
+ 'no kind filter: both a pending request and a pending invite are closed by arrival');
250
+ });
251
+
252
+ // 'accepted' stays reserved for an accept/approve flow that actually ran, so the
253
+ // inbox history never claims a decision nobody made; resolved rows are the audit
254
+ // trail and must survive untouched.
255
+ await test('addGoalMember: closes as \'cancelled\' (never \'accepted\'), and only pending rows', async () => {
256
+ const pool = addMemberPool();
257
+ await addGoalMember({ goalId: 7, builderId: 42, resolvedBy: 42 }, { pool });
258
+ const sql = norm(find(pool.queries, /^WITH added AS/).sql);
259
+ assert.ok(!/status = 'accepted'/.test(sql), "arrival never fabricates an 'accepted' decision");
260
+ assert.ok(/status = 'pending'/.test(sql), 'already-resolved rows stay as the audit trail');
261
+ });
262
+
263
+ await test('addGoalMember: resolvedBy defaults to null for a non-route caller', async () => {
264
+ const pool = addMemberPool();
265
+ await addGoalMember({ goalId: 7, builderId: 42 }, { pool });
266
+ assert.equal(find(pool.queries, /^WITH added AS/).params[3], null);
267
+ });
268
+
269
+ // Both route call sites must actually SUPPLY the actor — the db helper defaults
270
+ // resolvedBy to null, so a missed wire-up would silently drop the attribution
271
+ // while still cancelling. The routes are verified against live Postgres (see
272
+ // goal_routes.mjs header), so this pins the wiring at the source level.
273
+ await test('routes: both /join and /members pass resolvedBy to addGoalMember', () => {
274
+ const here = dirname(fileURLToPath(import.meta.url));
275
+ const src = readFileSync(join(here, '..', 'modules', 'lifecycle', 'routes', 'goals.js'), 'utf8');
276
+ const calls = src.match(/db\.addGoalMember\(\{[^}]*\}\)/g) || [];
277
+ assert.equal(calls.length, 2, 'exactly two membership-add call sites: POST /join and POST /members');
278
+ for (const c of calls) {
279
+ assert.ok(/resolvedBy:\s*req\.builder\.id/.test(c),
280
+ `every membership add attributes the cancel to the acting builder — got: ${c}`);
281
+ }
282
+ assert.ok(calls.some((c) => /builderId:\s*req\.builder\.id/.test(c)), '/join adds the caller themselves');
283
+ assert.ok(calls.some((c) => /builderId,/.test(c)), '/members adds the named target builder');
284
+ });
285
+
193
286
  // =========================================================================
194
287
  // The migration is additive + private-safe
195
288
  // =========================================================================
@@ -281,6 +374,54 @@ await test('authorizeInvitationResponse: the invitee (string vs number id) → A
281
374
  assert.deepEqual(authorizeInvitationResponse({ request: INVITE, goalId: '5', actorId: 2 }), { ok: true });
282
375
  });
283
376
 
377
+ // authorizeInvitationAccept (task 1002887) — the goal-state floor on ACCEPT only.
378
+ const OPEN_GOAL = { id: 5, status: 'open', scope_modules: null };
379
+ await test('authorizeInvitationAccept: open, unprotected goal → ALLOW', () => {
380
+ assert.deepEqual(authorizeInvitationAccept({ goal: OPEN_GOAL, scopeProtected: false, actorRank: 'thetes' }), { ok: true });
381
+ });
382
+ await test('authorizeInvitationAccept: the goal is gone → 404 (fail-closed, never a member of nothing)', () => {
383
+ const d = authorizeInvitationAccept({ goal: null, scopeProtected: false, actorRank: 'archon' });
384
+ assert.equal(d.ok, false); assert.equal(d.status, 404); assert.equal(d.code, 'goal_not_found');
385
+ });
386
+ for (const status of ['achieved', 'archived']) {
387
+ await test(`authorizeInvitationAccept: ${status} goal → 409 goal_not_open (a stale invite can't admit)`, () => {
388
+ const d = authorizeInvitationAccept({ goal: { ...OPEN_GOAL, status }, scopeProtected: false, actorRank: 'archon' });
389
+ assert.equal(d.ok, false); assert.equal(d.status, 409); assert.equal(d.code, 'goal_not_open');
390
+ assert.deepEqual(d.details, { status }, 'carries the blocking status, like every sibling goal_not_open');
391
+ });
392
+ }
393
+ await test('authorizeInvitationAccept: goal-state is checked BEFORE scope (ordering, like the join route)', () => {
394
+ const d = authorizeInvitationAccept({ goal: { ...OPEN_GOAL, status: 'archived' }, scopeProtected: true, actorRank: 'thetes' });
395
+ assert.equal(d.code, 'goal_not_open', 'a closed goal refuses on state, not on the scope floor');
396
+ });
397
+ await test('authorizeInvitationAccept: open + protected + sub-Metic invitee → 403 metic_required (unchanged)', () => {
398
+ const d = authorizeInvitationAccept({ goal: OPEN_GOAL, scopeProtected: true, actorRank: 'thetes' });
399
+ assert.equal(d.status, 403); assert.equal(d.code, 'metic_required_for_protected_scope');
400
+ assert.equal(d.message, 'This goal is scoped to a protected module and requires a Metic+ member.');
401
+ assert.equal(d.details, undefined, 'no details — the res.fail envelope stays byte-identical to the pre-1002887 response');
402
+ });
403
+ await test('authorizeInvitationAccept: open + protected + Metic invitee → ALLOW (consent, no Archon actor needed)', () => {
404
+ assert.deepEqual(authorizeInvitationAccept({ goal: OPEN_GOAL, scopeProtected: true, actorRank: 'metic' }), { ok: true });
405
+ });
406
+
407
+ // DECLINE stays allowed on a closed goal — clearing a stale invitation out of your
408
+ // inbox is cleanup, not admission. The route's decline branch must therefore return
409
+ // BEFORE the goal is ever fetched; pinned at the source level (the addGoalMember
410
+ // resolvedBy precedent above), since the live route is exercised in goal_routes.mjs.
411
+ await test('routes: invitation-respond declines before the goal-state gate', () => {
412
+ const here = dirname(fileURLToPath(import.meta.url));
413
+ const src = readFileSync(join(here, '..', 'modules', 'lifecycle', 'routes', 'goals.js'), 'utf8');
414
+ const handler = src.slice(src.indexOf("router.post('/goals/:id/invitations/:reqId/respond'"));
415
+ const body = handler.slice(0, handler.indexOf('\n });'));
416
+ const declineAt = body.indexOf("if (action === 'decline')");
417
+ const gateAt = body.indexOf('authorizeInvitationAccept({');
418
+ assert.ok(declineAt > 0 && gateAt > 0, 'both branches present in the respond handler');
419
+ assert.ok(declineAt < gateAt, 'decline resolves and returns before the accept-only goal gate');
420
+ assert.ok(body.indexOf('db.getGoal(') > declineAt, 'decline never even reads the goal');
421
+ assert.ok(gateAt < body.indexOf('db.acceptMembershipRequestAndAddMember('),
422
+ 'the gate runs before the membership add');
423
+ });
424
+
284
425
  // authorizeJoinRequestResponse — owner|manager|Archon resolves a join-request.
285
426
  const OWNER_GOAL = { id: 5, status: 'open', created_by: '1', visibility: 'private' };
286
427
  const REQUEST = { id: 8, goal_id: '5', builder_id: '2', kind: 'request', status: 'pending' };
@@ -154,20 +154,25 @@ await test('setGoalStatus: unknown goal returns null', async () => {
154
154
  // =========================================================================
155
155
  // addGoalMember
156
156
  // =========================================================================
157
+ // task 1002888 wrapped the bare upsert in a CTE that also cancels any pending
158
+ // membership request, so the statement now STARTS with `WITH added AS (` — the
159
+ // INSERT is the first arm. The pending-ask cancellation itself is pinned in
160
+ // tests/goal_membership_requests.mjs; here we only re-anchor the role contract.
157
161
  await test('addGoalMember: upsert PRESERVES the existing role (re-join cannot demote a lead)', async () => {
158
- const pool = fakePool((s) => (/^INSERT INTO goal_members/.test(s) ? { rows: [{ ...MEMBER_ROW, role: 'lead' }] } : null));
162
+ const pool = fakePool((s) => (/^WITH added AS/.test(s) ? { rows: [{ ...MEMBER_ROW, role: 'lead' }] } : null));
159
163
  const out = await addGoalMember({ goalId: 7, builderId: 42, role: 'member' }, { pool });
160
164
  assert.equal(out.role, 'lead', 'an existing lead row survives a role:member re-add');
161
- const ins = find(pool.queries, /^INSERT INTO goal_members/);
162
- assert.deepEqual(ins.params, [7, 42, 'member']);
165
+ const ins = find(pool.queries, /^WITH added AS/);
166
+ assert.deepEqual(ins.params, [7, 42, 'member', null], 'resolvedBy defaults to null when no actor is supplied');
167
+ assert.match(ins.sql, /INSERT INTO goal_members/, 'the membership write is the first CTE arm');
163
168
  assert.match(ins.sql, /ON CONFLICT \(goal_id, builder_id\) DO UPDATE SET role = goal_members\.role/,
164
169
  'conflict keeps the stored role — role changes go through PATCH /goals/:id/members/:builderId only (task 1002689)');
165
170
  });
166
171
 
167
172
  await test('addGoalMember: role defaults to member', async () => {
168
- const pool = fakePool((s) => (/^INSERT INTO goal_members/.test(s) ? { rows: [MEMBER_ROW] } : null));
173
+ const pool = fakePool((s) => (/^WITH added AS/.test(s) ? { rows: [MEMBER_ROW] } : null));
169
174
  await addGoalMember({ goalId: 7, builderId: 42 }, { pool });
170
- assert.equal(find(pool.queries, /^INSERT INTO goal_members/).params[2], 'member');
175
+ assert.equal(find(pool.queries, /^WITH added AS/).params[2], 'member');
171
176
  });
172
177
 
173
178
  // =========================================================================