fix(authz): createdBy stops counting as membership, and the creator cannot leave (TASK-166) - #1945
Merged
Merged
Conversation
…annot leave (TASK-166) Wren's ruling: `leavePod` refuses a creator's leave, `createdBy` stays as the record of who made the pod, and the permissive sites move to the strict predicate. Both sides of the activity pair move — the read rule and the write gate that mirrored it. Thirteen predicate terms across five files stop reading `createdBy` as membership, and one guard is added: - `controllers/podController.ts` — `leavePod` refuses the creator with 409 `creator_cannot_leave`. `createdBy` is written only at creation and nothing transfers it, while `removeMember` is gated on it with no admin fallback, so a creator who left would strand the pod with nobody able to remove a member. - `routes/podInvites.ts` (3 sites), `routes/activity.ts` (2) and `services/decisionRequestService.ts` (1) — the strict predicate, imported from `utils/isPodMember` rather than the module's permissive default. All four files bound the default, which is the permissive rule. - `services/activityService.ts` (7 terms) — the `createdBy` arm is dropped from `getRecap`, `getUserFeed`, `getDecisionHistory` (query and filter), `getPodFeed`, the legacy approval gate, and `getPendingApprovals`, so the read rule and the write gate that mirrors it are the same rule. One live defect surfaced while doing it, and it is the reason the `getPodFeed` control arm exists: that function carried a THIRD hand-rolled copy of the rule, `String(member.userId) || String(m)`. `String(undefined)` is the non-empty string 'undefined', so the fallback was unreachable and no member listed as a plain ObjectId ever matched. In production only the `createdBy` term beside it ever matched, which made `GET /api/activity/pods/:podId` creator-only. Narrowing that term without the fix would have refused every caller; the arm caught it, and the copy now calls `isListedPodMember`. Fourteen mutations, one per term, each alone: every one reddens exactly its named arm and nothing else. Baseline 9 suites / 95 tests, and the wider run is green — routes 139 / 1065, services 160 / 1737, controllers 14 / 175.
samxu01
pushed a commit
that referenced
this pull request
Sep 27, 2026
…ure shape, a mutation that never applied, and load-red All three came out of the TASK-166/#1945 verification on 2026-09-27 and all three arrive looking like a result. Fixture shape: a `DocumentArray`'s cast is part of the measurement. `doc.members.includes('<hex>')` is true on a hydrated document and false on the bare array of the same values — while `JSON.parse(JSON.stringify(doc)).members .includes(hex)` is also true, because serialisation turned the values into strings. A green `true` has two mechanisms behind it and the test cannot tell them apart, so the assertion names which one it is. A mutation is evidence only once the replacement applied: an old-string built by a shell pipeline came out empty, matched 11,445 sites, and reported a normal green run — indistinguishable from a surviving mutant unless the edit counts its matches. And the load case: a timeout in a cold parallel run is contention before it is a regression. Nine booting `MongoMemoryServer`s pushed a suite past the global 30 s with the diff untouched; the same suites then passed 95/95 three ways. Docs only; no code, no version bump.
samxu01
pushed a commit
that referenced
this pull request
Sep 27, 2026
…ure shape, a mutation that never applied, and load-red All three came out of the TASK-166/#1945 verification on 2026-09-27 and all three arrive looking like a result. Fixture shape: a `DocumentArray`'s cast is part of the measurement. `doc.members.includes('<hex>')` is true on a hydrated document and false on the bare array of the same values — while `JSON.parse(JSON.stringify(doc)).members .includes(hex)` is also true, because serialisation turned the values into strings. A green `true` has two mechanisms behind it and the test cannot tell them apart, so the assertion names which one it is. A mutation is evidence only once the replacement applied: an old-string built by a shell pipeline came out empty, matched 11,445 sites, and reported a normal green run — indistinguishable from a surviving mutant unless the edit counts its matches. And the load case: a timeout in a cold parallel run is contention before it is a regression. Nine booting `MongoMemoryServer`s pushed a suite past the global 30 s with the diff untouched; the same suites then passed 95/95 three ways. Docs only; no code, no version bump.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Cut from
67d8e252(main after #1942). Backend only, no version bump.Wren's ruling (pod 74691):
leavePodrefuses a creator's leave,createdBystays as the record of who made the pod, the permissive sites move to the strict predicate, and both sides of the activity pair move — the read rule and the write gate that mirrored it.What changed
Thirteen predicate terms across five files stop reading
createdByas membership, plus one guard.controllers/podController.tsleavePodcreator_cannot_leaveroutes/podInvites.ts:62create,:104list,:144revokeroutes/activity.ts:338seed,:365createservices/decisionRequestService.ts:373chooseDecisionservices/activityService.tsgetRecap,getUserFeed,getDecisionHistoryquery + filter,getPodFeed,requireActivityApprovalMember,getPendingApprovalsAll four route/service files bound
require('../utils/isPodMember')— the module default, which is the permissive rule (utils/isPodMemberexports the permissive predicate twice, as default and as namedisPodMember, and the strict one once, asisListedPodMember). They now import the strict one by name. That export shape is a trap in its own right and wants a row; it is not this PR's.Why the guard:
createdByis written only at creation and nothing transfers it — a repo-wide search fortransferOwner|changeOwner|reassignOwnerreturns zero — whileremoveMemberis gated oncreatedBywith no admin fallback ("Only pod admin can remove members"). A creator who left would strand the pod with nobody able to remove a member. Refused rather than stripped, so the field keeps meaning what its name says.Why both sides of the activity pair:
requireActivityApprovalMember's docstring said its read rule was "pod membership (with the creator fallback for old rows), so write authorization must use the identical predicate". That was accurate — which is exactly why narrowing one side alone would have broken read/write agreement on the rows the comment is about. Both moved, and the docstring now says what the rule is.A live defect this surfaced, and the arm that caught it
getPodFeedcarried a third hand-rolled copy of the membership rule:String(undefined)is the non-empty string'undefined', so|| String(m)was unreachable and no member listed as a plain ObjectId or string ever matched. In production only thecreatedByterm beside it ever matched (Vera's census: 0 of 424 pods carry amembers.userIdentry), which madeGET /api/activity/pods/:podIdcreator-only.So narrowing that term without touching the copy would have refused every caller, members included. The control arm — "still serves a listed member" — is what failed and made me look; the copy now calls
isListedPodMember, which also leaves this function with one definition rather than two. Blast radius, measured rather than assumed: nothing else in the repo requests that path (grep foractivity/podsacrossfrontend/,cli/andbackend/, excluding the route definition, is empty), which is why a read path that refused all non-creator members went unnoticed.Witnesses — one arm per site
leavePodguard removedleavePod refuses the creator with 409 creator_cannot_leave and keeps them listedpodInvitescreate clause backrefuses to create an invite for a creator who leftpodInviteslist clause backrefuses to list invites for a creator who leftpodInvitesrevoke clause backrefuses to revoke an invite for a creator who leftactivityseed clause backrefuses a creator who is no longer listed, and never reaches the seederactivitycreate clause backrefuses a creator who is no longer listed, and writes nothinggetRecapterm backbuilds the viewer's pod list from membership, not from createdBygetUserFeedterm backselects the viewer's pods by membership, not by createdBygetDecisionHistoryquery term backreads settled history by membership onlygetDecisionHistoryfilter term backrefuses a creator who left the pod, although createdBy still names themgetPodFeedclause backrefuses a creator who left the podfails closed when the pod's creator has left itgetPendingApprovalsterm backqueries approvals from listed membership onlychooseDecisionclause backrefuses a creator who left the pod before claiming the decisionBaseline and restore: 9 suites / 95 tests, exit 0. Fourteen mutations, each applied alone, each reddening exactly its named arm and nothing else — no survivors.
Three things about the arms worth keeping:
activity.write-membershippreviously asserted the creator was admitted withmembers: []; the file still has an arm for a creator who is listed, so the inversion cannot pass by refusing every pod that names a creator. Same shape inpodInvites(a listed creator still manages invites) andgetPodFeed(a listed member still gets the feed — which is the arm that found the third copy).getRecap/getUserFeed/getDecisionHistoryquery /getPendingApprovalsfilter MongoDB-side; the Pod mock returns whichever fixture it is handed, so a row assertion would have been satisfied by the mock. The comment on each says that, rather than implying a row was checked.getDecisionHistoryneeded two arms, not one. Its query drops the term (M9) and its in-process filter re-checks the returned rows (M10); mutating either alone reddens one arm, so neither witness is doing the other's work.Wider runs and lint
__tests__/unit/routes— 139 suites / 1065 tests, green.__tests__/unit/services— 160 suites / 1737, green.__tests__/unit/controllers— 14 suites / 175, green..tsfiles: 0 eslint errors and — checked by intersecting eslint's line numbers with the diff's+ranges rather than comparing counts — 0 warnings on any touched line (fatal: truechecked while there: none). The one warning the change introduced (activity.tsmax-len, from the longer strict ident) was fixed by wrapping the guard, so nothing lands on a touched line.Disclosed, not hidden: the changed
.jstest files carry eslint errors from the un-gated.jscorpus (~2,279 repo-wide) —import/no-unresolved+import/extensionson every.jstest file's.tsrequires, including the two new files. They follow the neighbouring files' convention exactly; I did not add a third style to that corpus, and the burn-down is its own task.Gate: Vera.