Skip to content

fix(attention): a creator who left a pod gets no "waiting on you" rows from it - #2271

Merged
lilyshen0722 merged 1 commit into
mainfrom
growth/attention-listed-members
Oct 16, 2026
Merged

lilyshen0722 merged 1 commit into
mainfrom
growth/attention-listed-members

Conversation

@lilyshen0722

@lilyshen0722 lilyshen0722 commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

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

attentionItemService counted pod.createdBy as a member in both places it decides membership:

  • the writer, currentHumanMembers, which decides who gets a row;
  • the reader, getOpenQueue, which decides 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.

The fix

Both paths 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.

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

  • 17 fixtures move to the shape production stores: members: [id], with the creator listed when they're a member. They used to mock members: [{ userId }] (TASK-170 found no such entry anywhere) and a creator with members: [], which are exactly the two shapes the old rule accepted.
  • The board-row test used to expect a row for an unlisted creator, i.e. it encoded the gap. It now lists the owner.
  • New tests:
    • the writer opens no row for a creator who left;
    • the queue serves them none.
  • Mutations: putting the creator back into the writer reds the writer test; putting it back into the queue reds the queue test.
  • Ran locally on Node 22:
    • attentionItemService plus the activity queue and activity suites, 66/66;
    • the other 10 unit suites that reach the attention service (approvals, decisions, tasks API, decision-card bridges, registry), 183/183.
  • The four __tests__/service suites (real DBs, which CI runs) already list the creator in members.

🤖 Generated with Claude Code

https://claude.ai/code/session_0157AG2wo8mJYqKezXNs48Ya

…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 lilyshen0722 left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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) filters users by isListedPodMember, feeding all five write paths (:127, :284, :314, :335, :364);
  • reader — getOpenQueue (:581) builds allowed from pods where the recipient is listed, and valid drops 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._id is undefined, m.toString() is '[object Object]'), so those fixtures described a Pod.members that does not exist. Migrating to members: ['sam'] was required for the tests to mean anything.
  • The fixtures that changed from createdBy: 'sam', members: [] to members: ['sam'] are expressing the same intent correctly — "sam is a member" used to be smuggled in through createdBy, 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

@lilyshen0722
lilyshen0722 added this pull request to the merge queue Oct 16, 2026
Merged via the queue into main with commit d091d8c Oct 16, 2026
24 checks passed
samxu01 pushed a commit that referenced this pull request Oct 16, 2026
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant