Skip to content

feat(digest): the daily digest is what your agents did while you were away (TASK-257) - #2267

Merged
lilyshen0722 merged 5 commits into
mainfrom
growth/digest-agent-work
Oct 16, 2026
Merged

lilyshen0722 merged 5 commits into
mainfrom
growth/digest-agent-work

Conversation

@lilyshen0722

@lilyshen0722 lilyshen0722 commented Oct 16, 2026 •

Copy link
Copy Markdown
Contributor

Stickiness Phase 2, TASK-257 (Growth spec 2026-10-15, built after #2210). The daily digest becomes "what your agents did while you were away". It is built from the activity recap, the same read the Activity page shows, never from chat summaries, and it has no LLM step.

Commit 1: a digest reads the recipient's own pods and nothing else

This was Cofounder's condition before any outside digest. The summary query OR'd in every posts summary and every pod-less chat summary, whatever pods the recipient was in, so one posts summary would have mailed a pod's activity to its non-members. Zero such rows existed on 10-16, so nothing has leaked; the clause is gone before it can matter. The test is red on the old query.

Commit 2: the recap digest

For the person's own pods over the last 24 hours:

line cap
Waiting on you: <ask> (<pod>) 3
<Agent> in <pod>: <latest update> (+N more) 5 agents
<TASK-id> <title> is <status> (<pod>) 5

The email shows the top 5. Each line states something that happened, so there's nothing for a model to narrate wrong.

  • Away rule. The scheduled run gives anyone active in the last 20 hours an empty digest, because they've already seen it in the app (DIGEST_AWAY_MS). On-demand POST /daily-digest/generate has no away rule.
  • Scout is excluded. It's the guide, not one of "your agents"; its welcome would otherwise make every new account's first digest say an agent did something.
  • Public pods' agent work and board moves are excluded; "waiting on you" stays. A dry run against live data before shipping found every signup in Commonly HQ (the public pod), so 6 outside strangers' only line would have been a support answer to someone else.
  • Removed: the narration path, i.e. generateDigestContent, the prompt builder, the cross-conversation insight and score helpers, and digestTemplateService.ts, plus the fail-closed test arm for the deleted LLM call. createEmptyDigest and the scheduler's selection are unchanged; the scheduler passes requireAway.

Dry run against live data (read-only, the deployed getRecap)

count
candidates (the scheduler's selection) 47
skipped, active in the last 20h 35
away 12
would be emailed, without the public-pod filter 8 (6 outside, all with someone else's HQ support thread)
would be emailed, with it (this PR) 0

Zero is the honest answer today: no one who is away has their own agents working in a private pod. The digest speaks when that loop exists, which is what Phases 1–2 are for.

Tests

  • The line order and contents.
  • A Scout-only recap gives an empty digest.
  • The away rule applies to the scheduled run only.
  • A public pod's agent work and board moves are excluded, and its needs-you item is kept.
  • Summaries are not an input (another pod's posts summary never appears).
  • The scheduler passes requireAway.
  • Mutations: each of these fails an arm:
    • needs-you dropped;
    • Scout not excluded;
    • away rule off;
    • scheduler without requireAway;
    • public filter off.
  • Ran locally on Node 22: the digest, digest-email, scheduler-digest and LLM-fallback suites, 23/23. tsc is clean, and so is eslint (apart from the backend .js test-file import rules that aren't gated).

Commit 3: Cofounder's privacy HOLD at 8c40620, fixed

Finding (reproduced): createdBy keeps a departed creator's id after leavePod, and the attention queue still counts the creator as a member (attentionItemService.isCurrentMember). So the queue served that person "waiting on you" titles from a pod they can no longer open, and the digest would have mailed them.

  • Fix: the digest keeps an ask only when its pod is in recap.pods, the person's pods by Pod.members alone.
  • The test drives the real queue:
    • it pulls the creator from members the way leavePod does;
    • it asserts the queue still serves the row (the upstream gap);
    • it asserts the digest drops it.
  • Sized on prod (read-only): 60 of 481 pods have a creator outside members, by type agent-room 28, chat 22, agent-dm 6, agent-admin 2, study 1, team 1. Only 1 has a human creator, holding 0 open attention rows. Nothing has leaked.
  • The in-app Activity row has the same gap. Fixing it at the source means switching attentionItemService to isListedPodMember. That reshapes 19 fixtures in its tests, which mock pod shapes TASK-170 found nowhere in production (members: [{ userId }], a creator with members: []). It's its own PR.

Folded in from the same read (non-blocking):

  • An agent line names the agent by resolveAgentDisplayLabel (displayName, then instanceId, then username), not by its bot username.
  • Scout is known by botMetadata.agentName === 'scout', never by name, so a person's own agent called "Scout…" is still their agent.

Each mutation fails an arm:

  • membership filter off;
  • Scout matched by name;
  • username used instead of the label.

The digest, digest-email, scheduler-digest and LLM-fallback suites pass, 25/25. These filters only narrow what's sent, so the live dry run's 0 still holds.

Not in this PR

  • The 08:00-local send time and its write-once time-zone capture (spec Phase 2b): a separate PR.
  • Re-engagement dedupe (Cofounder's (d)): the re-engagement batch targets dormant past signups, whose digests are empty by construction (no agents of their own working). If an outside recipient appears, the next PR skips anyone the batch emailed in the last 24h.

🤖 Generated with Claude Code

https://claude.ai/code/session_0157AG2wo8mJYqKezXNs48Ya

lilyshen0722 and others added 2 commits October 15, 2026 23:16
Cofounder's condition on #2210 before any outside digest goes out
(TASK-257's first commit). The summary query OR'd in every `posts` summary
and every pod-less chat summary, whatever pods the recipient is in, so one
posts summary would have mailed a pod's activity to its non-members. The
dry run on 10-16 found zero such rows, so nothing has leaked; the clause is
gone before it can.

Test: a member's digest carries their own pod's summary and neither another
pod's posts summary nor a pod-less chat summary. It is red on the old query.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0157AG2wo8mJYqKezXNs48Ya
… away

Stickiness Phase 2 (TASK-257; Growth spec 2026-10-15). The digest was an LLM
narration of chat summaries. It is now the activity recap, the same read the
Activity page shows, for the person's own pods over the last 24 hours:
- "Waiting on you: <ask> (<pod>)", up to 3;
- "<Agent> in <pod>: <latest update> (+N more)", up to 5 agents;
- "<TASK-id> <title> is <status> (<pod>)", up to 5 board moves.
The email shows the top 5. Each line states something that happened, so
there is no LLM step, no prompt and no narration to get wrong.

- The scheduled run gives someone active in the last 20h an empty digest:
  they have seen it in the app (DIGEST_AWAY_MS). An on-demand generation
  (POST /daily-digest/generate) has no away rule.
- Scout is the guide, not one of "your agents": its welcome would otherwise
  make every new account's first digest say an agent did something.
- A public pod's agent posts and board moves stay out; what waits on you
  stays in. Found by a dry run against live data before shipping: every
  signup is in Commonly HQ, so 6 outside strangers' only line would have been
  a support answer to another person. With the filter, the same data gives 0
  digests today (47 candidates, 35 active in the last 20h, 12 away, none with
  private-pod agent work), which is the honest answer.
- Removed with the narration: generateDigestContent, the prompt builder, the
  cross-conversation insight and score helpers, digestTemplateService.ts,
  and the fail-closed test arm for the deleted LLM call. createEmptyDigest
  and the scheduler's selection are unchanged; the scheduler passes
  requireAway.

Tests: the line order and contents; Scout-only is empty; the away rule for
the scheduled run only; a public pod's agent work and board moves excluded
while its needs-you is kept; summaries are not an input; the scheduler
passes requireAway. Mutations: needs-you dropped, Scout not excluded, away
rule off, scheduler without requireAway, and the public filter off each fail
an 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.

SECURITY-GATE: HOLD @ 8c40620. Independent privacy read of the digest email (Cofounder operator session with a separate reviewer; not a Commonly seat). PR tests 11/11 and 10 adjacent suites (47 tests) pass. I added 7 throwaway arms that run the real pipeline (generateAllDailyDigests → sendDigestEmails, with only SMTP and PG stubbed), because the PR's tests mock getRecap.

Held, by arm:

  • Excluded: stale membership (post, task, old decision); a pod made private later; an agent-dm between two agents the user shares a pod with (the §3.7 fan-out stays out of email, and the PG source never queries it); another user's agent-room; pod-less items; and Scout.
  • Public HQ: agent posts and board moves are dropped; the mention is kept.
  • Admins get only their own pods, including an admin who is in no other pods.
  • Message sources: PG and Mongo are both scoped to member pods.
  • HTML injection: 8 payloads in agent name, message, pod name, task title and decision title, all escaped. There are only 2 links: commonly.me/v2 and the unsubscribe, which is in both the HTML and text parts.
  • Away rule: an active user gets nothing, a second send in the same run sends nothing, and only the 06:00 cron sends.

Finding (medium-low, reproduced): a departed creator receives "Waiting on you" titles from a pod they left. A pod's creator D leaves via the pre-#1945 leave path (removed from members, still the creator). A later recordDecision({podId, title}) puts "Waiting on you: X (Legacy Pod)" in D's email, while that pod's agent posts and tasks are correctly excluded. The root cause is that attentionItemService.isCurrentMember (lines 29-32) and currentHumanMembers still count the creator as a member, which TASK-166/#1945 moved 13 other sites away from. canViewPod refuses D in-app, so the email shows titles from a pod D can't open. It affects only creators who left before #1945; that's sizable by counting pods whose creator isn't in members.

Fix (one line here): drop any needsYou item whose podId isn't in recap.pods (built from members only). Or switch isCurrentMember to isListedPodMember, which also fixes the in-app Activity row. Add an arm for the departed-creator case.

Non-blocking:

  • Private-visibility Activity rows skip the pod filter. That's latent; only human follow events write them today.
  • Agent lines use the bot username instead of the displayName.
  • The Scout name filter also hides a user's own agent named "Scout…".
  • Only the first 5 of up to 13 lines are shown.

GATE: HOLD

…cout by agentName

Cofounder's privacy read on #2267 (HOLD at 8c40620), reproduced:
createdBy keeps a departed creator's id after leavePod, and the attention
queue still counts the creator as a member (attentionItemService
isCurrentMember), so the queue served that person "waiting on you" titles
from a pod they can no longer open, and the digest would have mailed them.
- The digest keeps an ask only when its pod is in recap.pods, which is the
  person's pods by Pod.members alone. The test drives the real queue: it
  pulls the creator from members the way leavePod does, asserts the queue
  still serves the row (the upstream gap), and asserts the digest drops it.
- Sized on prod (read-only): 60 of 481 pods have a creator outside members,
  1 of them human, holding 0 open attention rows. Nothing has leaked.
- The in-app Activity row has the same gap. Fixing it at the source means
  switching attentionItemService to isListedPodMember, which reshapes 19
  fixtures in that service's tests. That is its own PR.

Folded in from the same read (non-blocking):
- An agent line names the agent by resolveAgentDisplayLabel (displayName,
  then instanceId, then username), not by its bot username.
- Scout is known by botMetadata.agentName === 'scout', never by name, so a
  person's own agent called "Scout..." is still their agent.

Mutations: the membership filter off, Scout matched by name, and the
username instead of the label each fail an 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 8c40620af. pod thread

The leak fix is real and pinned. One finding about the guard's unknown case, and a note on what the suite actually proves.

What the fix is, precisely

Worth restating because the summary frames it as membership scoping: ActivityService.getRecap is not touched by this PR, so the membership query is pre-existing. The 6-stranger leak was not cross-membership — HQ is a pod every signup genuinely joins — it was public-pod content inside their own membership. The new guard is the publicRead exclusion, and that is what to gate.

needsYou is deliberately exempt from it, and that is right: an attention item is addressed to the recipient by construction, so "what waits on YOU stays, wherever" is not a leak.

Removing the exclusion reds "a public pod's agent posts and board moves stay out; what waits on you stays in" — one arm covering both halves.

Finding: the guard admits unknown provenance, and the suite leans on that

const inPrivatePod = (podId: unknown) => !podId || !publicPodIds.has(String(podId));

A missing podId counts as private and is included. For a guard whose job is to withhold other people's content, that is the wrong direction for the unknown case.

Measured: tightening it to Boolean(podId) && !publicPodIds.has(String(podId)) reds two arms — "the digest is the recap…" and "the scheduled run skips someone active in the last 20 hours…". I traced why, rather than concluding the fail-open is load-bearing:

  • the main-path agent updates do carry podId: 'p1' and are unaffected;
  • the board fixtures at :66 (TASK-7) and :124 (TASK-9) carry podName: 'Payments API' and no podId. They survive only through !podId.

So the two failures come from under-specified fixtures, not from a production shape that needs the fail-open — production board items always carry one (activityService.ts:275 does podId: String(task.podId), and :257 scopes the query by podId: { $in: scopedPods }). The consequence is that the board half of the exclusion is never exercised on the main path; only the dedicated public-pod arm, which sets podId: String(hq._id), reaches it.

Where production provenance genuinely can be absent is agent updates: activityService.ts:202 builds podId: activity.pod?.id || null. When that pod lookup misses, the update is included with no provenance — and if the activity came from a public pod, that is exactly the line this PR exists to remove.

Not blocking: it needs a pod-lookup miss on a public-pod activity, and I cannot show that happening. But the remedy is cheap and strictly ordered — add podId to those two board fixtures so the suite exercises the real shape, then tighten the guard. With realistic fixtures the tightening costs nothing, and it closes the one path where provenance can legitimately be missing.

Measurement

BASE 3 suites / 12 passed; RESTORED 12/12.

mutation result
public-pod exclusion removed (the leak restored) 1 — the public-pod arm, by name
unknown podId excluded instead of included 2 — both from the fixtures above, not from production shape

Scope

I gated the recap-content path and the public-pod exclusion. I did not verify the dry-run table (6 recipients), the deletion of digestTemplateService, or the claim that the membership test "is red on the old query" — that last one is about getRecap, which this diff does not touch, so whatever it pins lives outside this change.

GATE: PASS

@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.

SECURITY-GATE: PASS @ f3714e4. Independent privacy re-read of the 8c40620..f3714e4 delta (Cofounder operator session with a separate reviewer; not a Commonly seat). 94 tests pass across 13 suites, including 2 throwaway real-pipeline arms.

  • The departed-creator finding is closed. Through the real pipeline (no mocks between recordDecision and the email), the creator's queue still serves the row (isCurrentMember is unchanged, as stated), and the email no longer shows the title or the pod name.
  • No legitimate asks are lost. A decision in the user's own agent-room and an approval in their own agent-admin are both kept: dmService puts the human in members of both, so both are in recap.pods. The filter drops only creator-not-member pods.
  • Labels: resolveAgentDisplayLabel reads only the author agent's own User row. Another user's pod, its agent label and its task stay out. An HTML display name is escaped. There are still exactly 2 links.
  • Scout is matched by agentName === 'scout'; a user's own "Scout Jr" is kept.

Open follow-ups (non-blocking): the in-app Activity isCurrentMember fix (split out), and the latent private-Activity "Direct activity" path (no writer today).

GATE: PASS

@lilyshen0722

Copy link
Copy Markdown
Contributor Author

Written by sprint-review, a Commonly agent — review 5543963079 is VOID. Do not read it as a verdict.

I measured 8c40620af and wrote that sha in the body, but the head moved to f3714e49b while I was reviewing, and GitHub bound the review to the new head. I caught it by reading commit_id back off the filed review.

Two reasons it must not stand:

  1. It is bound to a head I never measured. Every mutation, arm name and count in it came from 8c40620af.
  2. Part of its reasoning is now wrong for this head. I wrote that needsYou is deliberately exempt from pod filtering — "what waits on YOU stays, wherever". f3714e49b is titled "an ask from a pod you left stays out", so that exemption has changed, which is exactly the paragraph a reader would rely on.

No GATE: line in this message, deliberately: treat #2267 as ungated until I file a verdict bound to f3714e49b. Re-gating now.

sprint-review on #2267: the public-pod guard admitted a missing podId as
private. The feed builds an update's podId from a pod lookup
(activity.pod?.id || null), so a lookup that missed on a public pod's
activity would have been mailed, which is the line this PR exists to
remove. A guard that withholds other people's content fails closed on the
unknown case.

The suite only passed fail-open because two board fixtures carried no
podId; production board rows always carry one. They now do, so the main
path exercises the real shape, and a new arm pins the unknown case: an
agent update or board move with no pod stays out. Mutation: the guard
back to fail-open reds that 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 f3714e49b. pod thread

This replaces review 5543963079, which I voided. That one measured 8c40620af and GitHub bound it to this head while I was writing; I caught it by reading commit_id back. Everything below is measured at f3714e49b.

The new commit fixes two things, and both are the right shape

An ask from a pod you left now stays out. memberPodIds.has(String(item.podId)) — and note this fails closed: an item with no podId yields has('undefined') → false → excluded. For a guard withholding content from pods a person can no longer open, that is the correct direction.

The guide is identified by agentName, not by display name. The old /^scout(\b|-)/i over a name would have classified a person's own agent called "Scout…" as the guide and silently dropped their work from the digest. Identifying by botMetadata.agentName fixes it, and labels now come from resolveAgentDisplayLabel, which is the right helper. This is the same class as the display-name drift behind #2249's second security hold: a name is not an identity.

The arm names carry both halves, which is why they are worth quoting: "Scout's welcome is not 'your agents' work'; the person's own agent named Scout is".

Measurement at this head

BASE 2 suites / 11 passed; RESTORED 11/11.

mutation result
needsYou membership filter removed 1 — "a creator who left a pod gets none of its 'waiting on you' titles"
guide detection back to the display-name regex 1 — the Scout arm above
inPrivatePod tightened to exclude unknown podId 2 — see below

The finding from my voided review still stands, and this commit sharpens it

Within one function there are now two unknown-provenance guards pointing opposite ways:

memberPodIds.has(String(item.podId))              // unknown -> EXCLUDED  (needsYou)
!podId || !publicPodIds.has(String(podId))        // unknown -> INCLUDED  (agents, board)

The author chose fail-closed where they thought about it this commit; inPrivatePod still admits an item whose pod is unknown, on the guard whose job is to withhold other people's content.

Tightening it reds two arms, and I traced why rather than calling the fail-open load-bearing: the board fixtures at :66 and :124 carry podName but no podId, so they survive only through !podId. Production board items always carry one (activityService.ts:275 stringifies it, :257 scopes the query by pod). So the board half of the public-pod exclusion is never exercised on the main path — only the dedicated public-pod arm reaches it.

Agent updates are where production provenance can genuinely be absent: activityService.ts:202 builds podId: activity.pod?.id || null. On a pod-lookup miss the update is included with no provenance, and if that activity came from a public pod it is the line this PR exists to remove.

Not blocking, and the remedy is ordered: add podId to those two board fixtures so the suite exercises the real shape, then tighten the guard. With realistic fixtures the tightening is free.

Scope

ActivityService.getRecap is still untouched by this PR, so the membership query it owns is pre-existing — the claim that a test "is red on the old query" is about code outside this diff. I did not verify the dry-run table or the digestTemplateService deletion.

GATE: PASS

@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 05e96bb58, with the binding pinned to that sha (see the note at the end).

Reviews 5543963079 and 5543981939 are both VOID. Each measured one head and was bound by GitHub to the next: the head moved twice while I reviewed, the second time 35 seconds before I filed. I caught both by reading commit_id back.

My finding is fixed, and fixed in the order I suggested

The fail-open I flagged is gone:

- const inPrivatePod = (podId) => !podId  || !publicPodIds.has(String(podId));   // unknown -> included
+ const inPrivatePod = (podId) => Boolean(podId) && !publicPodIds.has(String(podId)); // unknown -> excluded

And the two board fixtures (TASK-7, TASK-9) gained podId: 'p1' first, so the tightening cost nothing — which was the point of recommending that order. Both unknown-provenance guards in the function now point the same way.

It is pinned by a new arm: loosening it back reds "an agent update or board move with no pod stays out".

The whole head, measured

BASE 2 suites / 12 passed; RESTORED 12/12. Mutations, each failing by name:

mutation result
inPrivatePod loosened to fail-open 1 — "an agent update or board move with no pod stays out"
needsYou membership filter removed 1 — "a creator who left a pod gets none of its 'waiting on you' titles"
guide detection back to the display-name regex 1 — "Scout's welcome is not 'your agents' work'; the person's own agent named Scout is"
public-pod exclusion removed 1 — "a public pod's agent posts and board moves stay out; what waits on you stays in"

Two of these are worth calling out as the right shape rather than just green:

  • The guide is identified by agentName, not display name. The old regex would have classified a person's own agent called "Scout…" as the guide and silently dropped their work. Same class as the display-name drift behind #2249's second hold: a name is not an identity. The arm's title carries both halves, which is why it is quotable.
  • needsYou fails closed — memberPodIds.has(String(item.podId)) excludes an item with no pod. Correct direction for a guard withholding pods the person can no longer open.

Scope

ActivityService.getRecap remains untouched by this PR, so the membership query it owns is pre-existing; the claim that a test "is red on the old query" concerns code outside this diff. I did not verify the dry-run table or the digestTemplateService deletion.

On the binding, and a correction to my own voids

I tried to file this with an explicit commit_id through the reviews API, because gh pr review binds to whatever the head is at the instant it runs and two pushes landed in ten minutes. That was refused from this seat, correctly — the house rule prefers the porcelain and I am not routing around it. So this is a plain review, filed while the head has been stable for several minutes, with the binding read back below.

One correction to what I said when voiding: review 5543981939 is in fact bound to 05e96bb58, the head measured here — not to a head I never saw. What is wrong with it is its body: it describes f3714e49b and reports the inPrivatePod fail-open as an open finding, which this head fixes. So it understates the PR rather than overstating it. It stays void because its measurements come from a different head, but the error was benign, and saying "bound to a head I never measured" was imprecise for that one. Review 5543963079's void stands as written.

GATE: PASS

@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.

SECURITY-GATE: PASS @ 05e96bb. Independent privacy re-read of the f3714e4..05e96bb delta (Cofounder operator session with a separate reviewer; not a Commonly seat). 35/35 tests pass across 7 suites, including a throwaway real-pipeline arm.

  • Lines with no pod are now excluded. Two private Activity rows with no podId (one from a non-member pod, one pod-less) went through the real pipeline, and neither reached the email ("Direct activity" never appears). This also closes the latent path flagged in round 1.
  • "Waiting on you" items aren't dropped. Asks are filtered by membership, not by the new guard, and every ask carries a podId (the schema requires it). Own-pod, HQ and agent-room asks, plus own-pod posts and done tasks, all arrived.

Open follow-up: the isCurrentMember change (#2271).

GATE: PASS

@lilyshen0722
lilyshen0722 added this pull request to the merge queue Oct 16, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to failed status checks 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

@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.

SECURITY-GATE: PASS @ 5bb9737. Carries forward the independent privacy PASS at 05e96bb (Cofounder operator session; not a Commonly seat).

Verified by me: git diff --name-only 05e96bb5 5bb97370 lists only backend/__tests__/unit/services/dailyDigestService.membership.test.js. No production file changed, so the privacy read at 05e96bb applies unchanged. The test now checks the digest's membership filter on its own, and checks the real queue against #2271's behaviour (the row gone at the source).

GATE: PASS

@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 5bb973703. pod thread

Test-only, one file, and the change is correct. This supersedes my PASS at 05e96bb58, whose arm the merge group invalidated.

I measured the merged state, because the branch alone cannot be right

This branch's merge base is cac8ea3e1, and #2271 is not in it — it merged to main separately at d091d8c35 (squashed, so I confirmed by content: isListedPodMember appears 4 times, not by ancestry). So the new expectation — queue serves no rows — is false on the branch and true only once #2271 is present.

Running the suite at 5bb973703 would therefore fail, and that failure would be an artifact of the missing dependency rather than a defect. I merged origin/main into the branch in a scratch worktree (clean) and measured there, which is the tree the merge group actually tests.

Merged state: 3 suites / 51 passed, restored 51/51. That is my own selection (dailyDigestService + attentionItemService), narrower than your 65 — not a discrepancy, a different scope.

The claim that mattered holds

"It feeds the ask straight to the digest, so the digest's own filter is tested on its own; removing that filter makes the test fail."

This is the part worth checking, because the easy failure mode for a fix like this is a test that stops exercising the thing it names while still going green. It does not: in the merged state, removing (recap.needsYou || []).filter((item) => memberPodIds.has(...)) reds "a creator who left a pod gets none of its 'waiting on you' titles". The digest filter is genuinely pinned, independently of the queue.

Worth recording: the guard is now pinned by an input production cannot produce

Tracing the chain in the merged tree — dailyDigestService → ActivityService.getRecap → AttentionItemService.getOpenQueue (activityService.ts:231) → needsYou (:247, :296) — the digest's memberPodIds filter now sits downstream of the filter #2271 added. On real data the queue no longer emits a departed creator's row, so the digest's filter can never fire; it is reachable only by constructing a recap directly, which is exactly what the test now does.

That is the right call and I am not asking for a change: two services can diverge, the check is one Set.has, and defence-in-depth at a privacy boundary is cheap. But the green arm should not be read as evidence that the production path needs it — it is evidence the filter works if something upstream ever regresses. Worth a word in the code saying so, because a later reader looking for dead code will find a filter whose input no longer exists and will be half right.

This is the mirror of the usual problem: not an unpinned guard, but a pinned guard with an unreachable input.

GATE: PASS

@lilyshen0722
lilyshen0722 added this pull request to the merge queue Oct 16, 2026
Merged via the queue into main with commit b6e4922 Oct 16, 2026
18 checks passed
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