Repository navigation
feat(digest): the daily digest is what your agents did while you were away (TASK-257) - #2267
Conversation
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
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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) carrypodName: 'Payments API'and nopodId. 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
left a comment
There was a problem hiding this comment.
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
recordDecisionand the email), the creator's queue still serves the row (isCurrentMemberis 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:
dmServiceputs the human in members of both, so both are inrecap.pods. The filter drops only creator-not-member pods. - Labels:
resolveAgentDisplayLabelreads 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
|
Written by sprint-review, a Commonly agent — review 5543963079 is VOID. Do not read it as a verdict. I measured Two reasons it must not stand:
No |
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
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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 -> excludedAnd 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. needsYoufails 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
left a comment
There was a problem hiding this comment.
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
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
left a comment
There was a problem hiding this comment.
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
left a comment
There was a problem hiding this comment.
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
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
postssummary 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:
Waiting on you: <ask> (<pod>)<Agent> in <pod>: <latest update> (+N more)<TASK-id> <title> is <status> (<pod>)The email shows the top 5. Each line states something that happened, so there's nothing for a model to narrate wrong.
DIGEST_AWAY_MS). On-demandPOST /daily-digest/generatehas no away rule.generateDigestContent, the prompt builder, the cross-conversation insight and score helpers, anddigestTemplateService.ts, plus the fail-closed test arm for the deleted LLM call.createEmptyDigestand the scheduler's selection are unchanged; the scheduler passesrequireAway.Dry run against live data (read-only, the deployed getRecap)
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
requireAway.requireAway;tscis clean, and so is eslint (apart from the backend.jstest-file import rules that aren't gated).Commit 3: Cofounder's privacy HOLD at 8c40620, fixed
Finding (reproduced):
createdBykeeps a departed creator's id afterleavePod, 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.recap.pods, the person's pods byPod.membersalone.membersthe wayleavePoddoes;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.attentionItemServicetoisListedPodMember. That reshapes 19 fixtures in its tests, which mock pod shapes TASK-170 found nowhere in production (members: [{ userId }], a creator withmembers: []). It's its own PR.Folded in from the same read (non-blocking):
resolveAgentDisplayLabel(displayName, then instanceId, then username), not by its bot username.botMetadata.agentName === 'scout', never by name, so a person's own agent called "Scout…" is still their agent.Each mutation fails an arm:
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
🤖 Generated with Claude Code
https://claude.ai/code/session_0157AG2wo8mJYqKezXNs48Ya