lead-data-displayrun.md00_intake/stub.mdThe shell renders frames but no real data. The three overlapping detail pages each re-fetch and re-render the same lead header, users, skills, activity and progress — duplicated display logic with no shared loader. To make the workspace the one place a lead lives, it must display the lead's real data accurately from a single load.
Add one server-component aggregate loader that, given a lead id, assembles the whole engagement in
parallel — the lead (a lead doc, incl. isActiveProject), its proposals, quote, milestones, invoices,
lead-expert matches/people, and recent activity (StatusHistory + Activity) — scoped by tenant. Render
that real data into the workspace sections (ported from the demo's section registry), read-only:
This is the read side only: it kills the triple re-fetch (one loader, the existing cache-tag conventions) and shows accurate data. No persona scoping yet (render the full manager-level aggregate); no actions.
can() — stub 3.@sustentus/services/server (lead, proposal, quote, invoice,
milestone, lead-expert-match, status-history, activity); do not add new collections.apps/docs/app/technical/cache-tags so the surface can be
invalidated per lead (sets up stub 5's post-transition refresh).apps/demo/components/workspace/sections.tsx +
apps/demo/lib/workspace/format.ts. Port display logic; the demo's projection.ts is a mock-only
device — real data comes from the DB, so no projection is needed.apps/web/app/(app)/workspace/[id]/** (loader), apps/web/components/workspace/**.01_define/output/spec.mdThe workspace shell (stub 1) renders the route, header, journey strip and section frames, but no
real data — every section is an empty placeholder. Today the same engagement is scattered across
service-leads/[id], projects/[id] and expert/bids/[id], each of which re-fetches and re-renders the
same lead header, people, skills, activity and progress: duplicated display logic with no shared loader.
For the workspace to become the one place a lead lives, it must display the lead's real data
accurately from a single load. This is the second feature of the lead-workspace epic (Refine the
Bridge / Q2 2026 Objective 3 — Validate Technical Infrastructure & Payout Flow: leads completing
end-to-end, and lead-to-payout cycle time): trustworthy, unified data is what lets us prove the bridge
actually moves a lead from intake to payout.
Add one server-component aggregate loader that, given a lead id, assembles the whole engagement in
parallel and tenant-scoped, then renders it into the section frames stub 1 scaffolded (the
section registry), read-only. The loader assembles, from existing @sustentus/services/server
models/services (no new collections):
lead doc, incl. isActiveProject), its proposals, quote, milestones
(with their blockers and action items), invoices, lead-expert matches / people,
CSAT, and recent activity (StatusHistory + Activity).Each scaffolded section renders that real data:
This is the read side only: it kills the triple re-fetch (one loader instead of three pages
re-fetching), tags the aggregate read with the existing cache-tag conventions
(apps/docs/app/technical/cache-tags) so it can be invalidated per lead — which sets up stub 5's
post-transition refresh — and shows accurate data. It renders the full manager-level aggregate (no
per-persona scoping yet — stub 3) and performs no actions (the levers rail and header
next-best-action CTA slot stay the empty placeholders from stub 1).
Decisions settled this Define:
csat, blocker, action-item), so the loader
assembles them too — every section stub 1 scaffolded renders real values, none is left empty by
omission.apps/demo/components/workspace/sections.tsx + apps/demo/lib/workspace/format.ts —
formatMoney, the *Chip helpers). The demo's projection.ts is a mock-only device and is not
ported — real data comes from the DB. Nothing is imported from apps/demo.@sustentus/services workflow accessors (the same getStatus / resolveStatusLabel stub 1 uses),
never a locally-duplicated status map.service-leads/[id] / projects/[id] pages.@sustentus/services workflow accessors,
matching the lead's true status — no locally-duplicated status map.apps/demo, and no
new collection is added.can() — stub 3 (persona-capability-matrix). This stub renders the full aggregate.service-leads/[id], projects/[id], expert/bids/[id], the
finance / BRD pages) or their loaders; consolidating or redirecting them.apps/demo; refactoring existing inline role === … checks; adding any new
collection or business logic.@sustentus/services/server) are decided. Per-persona scoping and actions are explicitly later stubs.Per the epic breakdown's recorded note, this Define read the predecessor run's spec
(pipeline/runs/workspace-shell/01_define/output/spec.md) to anchor the route + section registry, and
the demo design source (apps/demo/components/workspace/sections.tsx) to confirm the display logic to
port — both beyond the docs-only intake band, but necessary because the surface to fill and the logic to
port are code, not prose. Recorded per the contract.
02_build/output/notes.mdapps/web/lib/queries/workspace.ts (the loader): getWorkspaceAggregate(tenantId, leadId, persona)
assembles one lead's whole engagement into a plain, serialisable WorkspaceAggregate. Tenant-scoped;
fetched in parallel within two dependency waves — wave 1 (lead, accepted + pending proposals, blockers,
action items, activity) keyed by lead id; wave 2 (quote, delivery milestones, invoice-summary milestones,
CSAT) hangs off the accepted proposal id / the lead's customer, which is the real domain shape (quote and
milestones belong to the accepted proposal). Wrapped in unstable_cache, tagged per-lead so stub 5 can
invalidate it. Every status chip is resolved through statusService.resolveView (label + canonical hex),
never a local map.apps/web/lib/queries/workspace-cache.ts: workspaceCacheTags.detail (tenant:{t}:workspace:{lead}) +
revalidateWorkspaceCache — the per-lead invalidation hook stub 5 will call after a transition.apps/web/components/workspace/aggregate.ts: the pure WorkspaceAggregate view-model (structure ported
from the demo's WorkspaceLead, with engine-resolved chips). Both the loader and the sections import it.apps/web/components/workspace/sections.tsx: the nine section bodies (Overview, Requirements/BRD,
Proposals, Quote, Delivery, Invoices, CSAT, People, Activity) ported from apps/demo/components/workspace/ sections.tsx, display-only — the demo's action levers (ActionButton/can()) are dropped (deferred
to stubs 4–6). Each section has an empty/locked state for an absent satellite. Plus WORKSPACE_SECTION_ COMPONENTS, the id→component map the view renders.apps/web/components/workspace/status-chip.tsx: renders an engine-resolved chip (label tinted with the
status' canonical hex).apps/web/components/workspace/format.ts: app-local formatMoney / formatDate (ported; no apps/demo
import).apps/web/components/workspace/workspace-view.tsx: now takes the aggregate and renders the real
sections; header status comes from the resolved lead chip; the levers rail + CTA slot stay the stub-1
placeholders.apps/web/app/(app)/workspace/[id]/page.tsx: calls getWorkspaceAggregate; an unknown/invalid id (the
loader returns null on CastError/BSONError or a missing lead) renders notFound().buildAggregate
in lib/queries/workspace.ts (two parallel waves; people are read from the populated lead).@sustentus/services/server reads the existing service-leads/[id] /
projects/[id] pages use, so values match (live cross-check on the preview deploy).EmptyState fallback (e.g. "No invoices yet — billing starts once delivery is under way.").statusService.resolveView(name, persona); no local label/colour map (the only canonical-name
references are the billed/delivered rollup arithmetic, not display).unstable_cache tagged with workspaceCacheTags.detail + serviceLeadCacheTags.detail.apps/demo; no new collection
added — sections/format ported as app-local code; loader reuses existing services only.pnpm --filter ./apps/web typecheck passes clean (after rebuilding @sustentus/services +
@sustentus/ui dist — the local dist was stale, which surfaced unrelated pre-existing errors in
sdm/vendor/expert pages that resolve once packages are built, as CI does).eslint on the changed files — 0 problems.findAcceptedByLead + findPendingReviewByLead) —
the proposal service exposes targeted queries, not a single "all bids by lead".findByProposalOrderedWithInvoiceSummary), which
carries each milestone's invoice with an engine-resolved status; amounts are percentage × contract value. The lead has no single "contract value" field, so value = quote total → quote amount → accepted
proposal price → budget.activityService.findByLead feed (most recent 12), which already includes the
STATUS_CHANGE entries — so the status-history transitions surface here without a separate raw-model
read from apps/web.customer/expert/manager/vendor (the lead doesn't populate a
separate csm); lead-expert match fit-scores belong to the bid-pool view, not this roster.apps/web/lib/queries/ (alongside the existing cached service-leads / proposals
queries) rather than literally under the route folder named in the spec's touches: — that's where the
cache-tag convention puts cached reads. The page imports it.03_release/output/investor-update.mdWho it's for: All six roles — customer, expert, CSM, SDM, admin, vendor. What shipped: The lead workspace now renders a lead's real engagement — quote, milestones, invoices, CSAT and activity — from one tenant-scoped load. Why it matters: Accurate, unified lead data is the read path the payout flow depends on — groundwork toward a shorter lead-to-payout cycle (Refine the Bridge · Q2 Objective 3).
Dig deeper: https://github.com/sustentus/sustentus/pull/568 · no changelog — internal data milestone
03_release/output/release.mdweb preview deployed Ready. Re-checked after the release commit before merge.apps/docs/app/technical/cache-tags in this PR — registered the new getWorkspaceAggregate cached query + the workspaceCacheTags.detail per-lead tag (the doc asks new tagged domains to register).03_release/output/investor-update.md. Recorded: no end-user changelog — internal/in-progress surface./code-review medium (standard complexity) — two parallel finder angles (correctness; cleanup/conventions/reuse) over the feature's six code files, then verification.
contract value × milestone percentage; the invoices' true stored totals were never fetched. Now the loader fetches real invoice amounts via invoiceService.findProjectInvoicesByQuote(quote._id) and merges each invoice's engine-resolved status from the milestone invoice-summary (by invoice id); billed/paid sum the real totals. (Milestone amounts + delivered stay percentage-of-contract — that's how a milestone is valued, matching the demo.) This is what makes AC #2's "real values, verified against the existing finance pages" hold.issued defaulted a missing/unresolved quote status to quote_draft, so a real quote with no resolvable status rendered the "not issued yet" empty state. Changed to quote.status?.name !== "quote_draft" so only an explicit draft is hidden.quote.total (incl. service fee) while the Proposals table shows proposal.price (the expert's bid). These are different entities by design (a contract total ≠ a bid), same structure as the demo — not a bug.initials and currencySymbol helpers (apps/web/lib/dashboard-utils.ts), dropping a duplicate currency-symbol map. Deliberately did not reuse fmt — it k-abbreviates (£15k), and the workspace needs exact amounts. Converted four inline prop object-types in sections.tsx to named types (CONVENTIONS.md: "Prop types are defined as a type directly above the component").WORKSPACE_SECTION_COMPONENTS id→component registry stays — it's the per-persona section-list seam stub 3 (persona-capability-matrix) consumes, and it mirrors the demo's SECTION_REGISTRY. The local dueLabel stays: its twin lives in components/service-leads/lead-action-items/action-items-panel.tsx, which the spec's Out-of-scope forbids touching — deduping it is a logged follow-up, not this PR's work.pnpm --filter ./apps/web typecheck clean, eslint on changed files clean.getWorkspaceAggregate / buildAggregate, parallel within dependency waves.EmptyState per section; a real-but-status-less quote no longer falsely shows "not issued" (review fix).@sustentus/services workflow accessors — statusService.resolveView, no local label/colour map.unstable_cache with workspaceCacheTags.detail + serviceLeadCacheTags.detail; registered in the cache-tags doc.apps/demo; no new collection — confirmed.