Repository navigation
fix(attention): a creator who left a pod gets no "waiting on you" rows from it - #2271
Conversation
…s from it Cofounder's privacy read on #2267 found it, and #2267 closed it for the digest only. This closes it at the source, so the in-app Activity queue holds too. attentionItemService counted pod.createdBy as a member in both places it decides membership: the writer (currentHumanMembers, who gets a row) and the reader (getOpenQueue, which rows are served). createdBy keeps a departed creator's id after leavePod, so that person kept getting asks, titles included, from a pod they can no longer open. Both now use isListedPodMember, Pod.members and nothing else: the rule TASK-161/170 settled on for every other membership check. A live creator is unaffected, because Pod's pre-save hook lists the creator in members. Sized on prod (read-only): 60 of 481 pods have a creator outside members, 1 of them human, holding 0 open rows. So nothing changes for anyone today; this keeps it that way. Tests: 17 fixtures move to the shape production stores (members: [id], the creator listed when they are a member; TASK-170 found no {userId} member entries anywhere). The board-row arm that expected a row for an unlisted creator now lists them. New arms: the writer opens no row for a creator who left, and the queue serves them none. Mutations: putting the creator back into either path reds its arm. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0157AG2wo8mJYqKezXNs48Ya
lilyshen0722
left a comment
There was a problem hiding this comment.
Written by sprint-review, a Commonly agent — code gate at a1a9e331b. pod thread
This is the source-side half of the finding #2267 filtered at the surface, and it is the better place for it. Base is exactly main (cac8ea3e1).
Both halves, and both fail closed
createdBy is gone from the membership logic entirely — the only remaining mention is the comment explaining why it was wrong. The predicate is used on both sides:
- writer —
currentHumanMembers(:34) filtersusersbyisListedPodMember, feeding all five write paths (:127,:284,:314,:335,:364); - reader —
getOpenQueue(:581) buildsallowedfrom pods where the recipient is listed, andvaliddrops any row whose pod is not in it.
Both directions refuse on the unknown case, which is what matters for a privacy guard. isListedPodMember opens if (!pod || !userId) return false;, so a missing pod or caller is a non-member; and because allowed is built from a Pod.find result, a row whose pod has since been deleted finds no entry and is dropped rather than served.
The predicate also handles both member shapes — m._id?.toString?.() for a populated member and m?.toString?.() for a raw id — which is what makes the fixture change below necessary rather than cosmetic.
The fixture migration is a correction, and the suite still discriminates
17 fixtures moving is the kind of change that can quietly make a suite agree with anything, so I checked rather than assumed:
- Nothing was dropped: assertions went 74 → 77, tests 34 → 36.
- The old shape was unreal.
members: [{ userId: 'sam' }]matches neither branch of the predicate (m._idis undefined,m.toString()is'[object Object]'), so those fixtures described aPod.membersthat does not exist. Migrating tomembers: ['sam']was required for the tests to mean anything. - The fixtures that changed from
createdBy: 'sam', members: []tomembers: ['sam']are expressing the same intent correctly — "sam is a member" used to be smuggled in throughcreatedBy, which is precisely the bug.
And the suite is not trivially green afterwards. Making the predicate always-true reds five arms across the util and both service paths:
refuses a creator who is not listed · refuses a missing pod, a missing caller, and a non-member · returns only rows whose recipient is still a member… · serves a creator who has left the pod none of its rows · materializes no row for a creator who has left the pod
Measurement
BASE 2 suites / 45 passed; RESTORED 45/45.
| mutation | result |
|---|---|
isListedPodMember forced true (both sides fail open) |
5 failed — as quoted above |
reader's allowed.has(podId) filter removed |
2 failed — the two reader arms |
writer counts createdBy as a member again (the original bug) |
1 failed — "materializes no row for a creator who has left the pod" |
The writer and reader each have their own arm, so a one-sided regression cannot pass; and the third mutation reproduces the reported defect exactly rather than approximating it.
Worth keeping
utils/isPodMember.ts carries the clearest hazard note I have read in this repo: the module once exported a permissive variant twice, so the obvious require('./utils/isPodMember') bound the wrong rule, and TASK-165's mutation of exactly that edit stayed green. Deleting the second rule rather than renaming it, and asserting the module has no callable default, is the right response to a trap that a passing suite could not see. That reasoning is what makes this change trustworthy beyond its own tests.
GATE: PASS
The merge queue dequeued #2267: its departed-creator arm asserted the upstream gap ("the queue still serves the row"), and #2271, merged just ahead of it, closed that gap at the source, so getOpenQueue now returns nothing for a creator who left. The digest code is unchanged. The arm now asserts what is true on main (the queue serves the row to no one), and hands the digest the ask exactly as the queue used to serve it, so the digest's own membership filter is tested by itself. That is the second line of defence: an email never depends on the queue staying right. Verified on main with this branch merged in: the digest, attention, digest-email, scheduler-digest and LLM-fallback suites pass, 65/65. Mutation: the digest membership filter removed reds the arm. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0157AG2wo8mJYqKezXNs48Ya
The source-side half of Cofounder's privacy finding on #2267. #2267 closes it for the email digest; this closes it for the in-app Activity queue.
The gap
attentionItemServicecountedpod.createdByas a member in both places it decides membership:currentHumanMembers, which decides who gets a row;getOpenQueue, which decides which rows are served.createdBykeeps a departed creator's id afterleavePod, so that person kept getting asks, titles included, from a pod they can no longer open.The fix
Both paths now use
isListedPodMember:Pod.membersand nothing else, the rule TASK-161/170 settled on for every other membership check. A live creator is unaffected, because Pod's pre-save hook lists the creator inmembers.Size (prod, read-only)
60 of 481 pods have a creator outside
members. Only 1 has a human creator, and that creator holds 0 open rows, so nothing changes for anyone today. This keeps it that way.Pre-press count (prod, read-only, 2026-10-16 ~06:55Z): of 1,012 open attention rows, the new reader stops serving 0, and 0 humans lose a row. Counted as rows whose recipient is the pod's creator and is not in
members, with bot recipients excluded.Tests
members: [id], with the creator listed when they're a member. They used to mockmembers: [{ userId }](TASK-170 found no such entry anywhere) and a creator withmembers: [], which are exactly the two shapes the old rule accepted.__tests__/servicesuites (real DBs, which CI runs) already list the creator inmembers.🤖 Generated with Claude Code
https://claude.ai/code/session_0157AG2wo8mJYqKezXNs48Ya