csm-outreach-threadrun.md00_intake/stub.mdThe CommunicationSection shows, per blocked account, the CSM's conversation with the blocking party —
the latest message (lastMessage), whether they've replied (responseStatus: pending | responded) and
when last contacted (lastContact) — all dummy (apps/web/lib/mock/csm.ts,
the lastContact / responseStatus / lastMessage fields on each blocker). There is no record of CSM
outreach: nothing stores that the CSM chased the owner, what was said, or whether a reply came back.
customer-dashboard-data/project-messaging (the per-lead thread model) to carry CSM
outreach messages tied to a blocker/account, rather than introducing a second thread model. Capture
sender (CSM), text, createdAt, and the addressed actor.responseStatus (pending until the blocking actor replies after the CSM's last message)
and lastContact (time of the CSM's most recent outreach), and surface the lastMessage
preview per account.responseStatus, and real
lastContact — no mock.responseStatus flips to responded when the blocking actor replies after the CSM's last message.customer-dashboard-data/project-messaging: that stub is itself flagged as
potentially epic-sized. If it has landed, extend its model with the actor/blocker association; if not,
agree the minimal thread shape together so this doesn't fork it.blockerId tag vs a
filtered view of the account thread).touches: packages/services/src/db/models (extend message/thread model),
packages/services/src/server (outreach post + responseStatus derivation),
apps/web/components/dashboard/csm/communication-section.tsx.01_define/output/spec.mdThe CSM is the exception manager for delivery — they push the 5–10% of accounts stuck before
go-live through to value, which is exactly what Build the Bridge → Q2 2026 Objective 3 (Validate
Technical Infrastructure & Payout Flow) depends on. The blockers table and action strip are now real
(csm-blocker-queue, PR #511), but the communication section of the Go live control dashboard is
still dummy: CsmSections passes the mock blockers array straight into
CommunicationSection, so the latest message, whether the blocking party has replied
(responseStatus) and when they were last contacted (lastContact) are all fabricated from
apps/web/lib/mock/csm.ts. Nothing records that the CSM chased the owner, what was said, or whether a
reply came back — so against a now-real blocker queue the CSM still can't see who has gone silent or
what to chase next, leaving the exception-management loop the objective rests on half-wired.
Give each go-live blocker a real, persisted outreach conversation by extending the already-landed
project-messaging thread (ProjectMessage, the per-lead customer↔team thread) — no parallel
message collection — and pairing it to the real blocker rows csm-blocker-queue (#511) now produces:
blocker reference to ProjectMessage
so a message can be scoped to a specific go-live blocker, while still belonging to the account's
single lead thread. Messages with no blocker remain the account-level thread.team; the customer replies as customer — reusing the existing
senderRole semantics. This round covers the customer blocking actor only (see Out of scope).lastMessage (latest message
preview), lastContact (timestamp of the CSM's most recent outreach), and responseStatus —
pending after the CSM's most recent outreach until the customer replies, then responded.leadId csm-blocker-queue added to the CsmPortfolioAccount
envelope (and the blocker id on each CsmBlockerQueueRow). The read can extend the
csm-blocker-queue service envelope or sit beside it in @sustentus/services/server; a post action
appends a CSM outreach message to the shared thread. Both reuse the page's existing mine/tenant
scope./csm/dashboard, fetch
outreach alongside csmBlockerQueueService and pass it through CsmSections to
CommunicationSection, replacing the mock blockers array it receives today.Dependency — csm-blocker-queue (PR #511). That feature supplies the real blocker rows this section
pairs outreach to and the leadId-bearing portfolio envelope the join needs. It is built and ticked
Ready to merge, but not yet squash-merged into main, so its code is not on this run's branch yet.
Build of this run rebases onto #511 once it merges — a sequencing step, not an unresolved
requirement. Every decision about what gets built is settled below.
responseStatus, and a real lastContact — CommunicationSection no longer receives
the mock blockers array, and no value comes from lib/mock/csm.project-messaging thread (tagged with the blocker it addresses, no parallel message collection)
and appears immediately as the latest message.responseStatus reads pending after the CSM's most recent outreach and flips to responded
once the customer replies after it.leadId on the portfolio envelope and the blocker id on each CsmBlockerQueueRow, not by a
mock account name.mine/tenant
scope — a CSM never sees another tenant's thread, nor an unowned account's when scope is mine./csm/dashboard
(fetched alongside csmBlockerQueueService), not from a mock array.project-messaging does not have today.project-messaging's existing scope (Ably if present), else
server-fetch on load.apps/dashboards — that app stays the dummy source-of-truth.02_build/output/notes.mdBuilt on the merged csm-blocker-queue (#511): the branch was rebased onto main
(which now carries csmBlockerQueueService, the leadId portfolio-envelope field, and
the renamed CsmSections) before implementing.
packages/services/src/db/models/project-message.ts: added an optional blocker
ref to ProjectMessage (+ a { tenantId, blocker, createdAt } index) so a message can
be tagged to the go-live blocker it addresses while staying on the account's single
lead thread — no parallel message collection.packages/services/src/db/services/project-message/index.ts: added the CSM-side
outreach surface — getOutreachForBlockers (one tenant-scoped read of blocker-tagged
messages → per-blocker OutreachState: latest preview, the CSM's last-contact time, and
responseStatus pending→responded once the customer replies after the CSM's last
outreach) and postOutreachForCsm (appends a team message tagged with the blocker on
the lead's thread, notifies the customer via the existing publish path).packages/services/src/db/services/csm-blocker-queue/index.ts: added leadId to
CsmBlockerQueueRow so per-account features join cleanly without re-resolving the
portfolio.packages/services/src/db/services/csm-outreach/{index,instance}.ts: new
CsmOutreachService.getOutreachForCsm — composes the blocker queue (the canonical
blocker set + account/stage/who/status) with the per-blocker outreach derivation, so the
communication cards pair exactly to the go-live queue's blockers. Exported via the
services barrel → @sustentus/services/server.apps/web/app/(app)/csm/dashboard/page.tsx: fetches outreach alongside the blocker
queue and passes it (and the post action) into CsmSections.apps/web/app/(app)/csm/dashboard/actions.ts: added postOutreachAction — locates the
blocker in the CSM's tenant queue (tenant boundary), then posts via postOutreachForCsm.apps/web/components/dashboard/csm/csm-sections.tsx: drops the mock blockers import;
passes real outreachRows + postOutreach to the communication section.apps/web/components/dashboard/csm/communication-section.tsx: rewired to real outreach
rows; each card shows the real latest message, response status and last-contact, and adds
an inline compose box to post outreach (optimistically updated in place on success).responseStatus / lastContact per blocker — CommunicationSection no longer receives the mock blockers array; data is from csmOutreachService.responseStatus reads pending after the CSM's last outreach, flips to responded once the customer replies after it (deriveOutreach).leadId (portfolio envelope) + the blocker id on CsmBlockerQueueRow.mine/tenant scope; post resolves the blocker within the CSM's tenant queue./csm/dashboard, fetched alongside csmBlockerQueueService.ProjectMessage (customer↔team) thread; CSM posts as
team. Non-customer blocking actors (expert/vendor/partner) are out of scope this round.postOutreachAction recomputes the CSM's tenant blocker queue to resolve leadId and
enforce the tenant boundary — correct and reuses existing logic; a lighter lookup could
replace it later if the manual-post path ever gets hot.03_release/output/changelog.mdAs a CSM, you can now message the customer holding up a go-live — right from the blocker on your Go live control dashboard. Each conversation shows the latest message, whether the customer has replied since you last reached out, and when you last made contact, so you can see at a glance who has gone quiet and who to chase next.
03_release/output/investor-update.mdWho it's for: Customer success managers What shipped: A CSM can message the customer holding up a go-live blocker and see, per blocker, the latest reply, whether the customer has responded, and when they were last contacted. Why it matters: Faster human follow-up on stuck accounts advances Build the Bridge — Q2's objective to validate technical infrastructure and the payout flow.
Dig deeper: https://github.com/sustentus/sustentus/pull/515 · https://help.sustentus.com/changelog/2026-06-23-csm-outreach-thread
03_release/output/release.md7b0d614)apps/docs/app/technical/**feature-role-matrix/projects (CSM can send blocker outreach to the customer) and service-journey/delivery (CSM outreach extension to the project message thread + optional blocker field)apps/help/app/changelog/2026-06-23-csm-outreach-thread/) + investor draft in this PRweb (platform.sustentus.com) + help (help.sustentus.com) both green on 7b0d614/code-review (high effort) over the feature diff — no blocking correctness bugs. Tenant/portfolio
scoping is enforced (outreach read keyed off the CSM's scoped queue; post resolves the blocker in the
CSM's tenant queue and the lead is tenant-scoped). Soft-delete + tenant filters match the sibling
customer message methods.relativeContact in communication-section.tsx derives "Xm ago" from Date.now()
at render, so a card crossing a minute boundary could differ between SSR and hydration — cosmetic and
self-correcting on hydration; not worth added state complexity.responseStatus / lastContact per blocker — CommunicationSection takes server outreach rows; mock blockers feed removed.postOutreachForCsm, senderRole team); card updates in place.responseStatus pending until the customer replies after the CSM's last outreach (deriveOutreach).leadId (portfolio envelope) + blocker id on CsmBlockerQueueRow.mine/tenant) read + post./csm/dashboard, fetched alongside csmBlockerQueueService.