customer-surface-fixesrun.md02_define/output/spec.mdTwo customer-persona surfaces are wrong in product code, so no amount of seeding can make them
right. The customer dashboard's "What could go wrong" card renders a hardcoded fixture
(apps/web/app/(app)/customer/dashboard/_customer-fixture.ts, flagged P0 in the file's own header)
— every customer sees the same two invented blockers ("Waiting on API key", "Connector module
delayed") regardless of their project, while the risk badge beside them is computed from real data.
And /customer/satisfaction's "Awaiting your feedback" strip filters leads on
statusNames: ["csat"] (page.tsx:37), a status that does not exist in the workflow engine
(db/workflows/workflows.json — the post-delivery status is survey_sent), so no engine-legal lead
can ever light it and the customer is never told a survey is waiting.
Both are audit findings — _source/audit-report.md Phase 3, "Surfaces seed data cannot light"
items 1–2 — and decision D3 of _source/decisions.md put them in scope. They sit on the
Refine the bridge initiative, objective Q2-2026 Objective 1 — Establish Product-Market Fit
with Vendor Partners: the demo tenant is the vendor-facing proof, and a dashboard that shows the
same fictional blockers to everyone, plus a CSAT loop that never opens, undercuts it directly.
Blockers card. Carry the customer's real open blockers through the existing envelope.
CustomerProjectService.getActiveProjectForCustomer already fetches them via
blockerService.listOpenByLead and feeds them to computeProjectHealth; the envelope just doesn't
expose them. Add a blockers field to CustomerProjectEnvelope (label / owner / impact — exactly
the blocker model's fields, which are also the shape the card already renders), populate it from
the blockers already read, and have the dashboard pass project.blockers to RiskBlockersFeedback
instead of the fixture. No extra query.
The blocker model's owner spans five parties, and two of them — Partner and Vendor — are
raised on the expert workbench and the CSM blocker queue in wording written for internal readers.
The envelope therefore carries only the three customer-facing owners (Customer, Your team,
Expert). Health is deliberately not filtered the same way: every open blocker keeps feeding
computeProjectHealth, so withholding the wording never understates the risk the customer is shown. Delete _customer-fixture.ts and its import — that is the
file's only remaining consumer. The card's existing empty state ("No blockers — project on track")
becomes reachable and is the honest answer when there are none.
Satisfaction strip. Point the "Awaiting your feedback" filter at the engine's post-delivery
status: statusNames: ["survey_sent"]. Submitting the survey already progresses the lead
survey_sent → completed (submitCustomerCsatAction → leadService.progressStatusPositive), so a
completed survey drops off the strip on the next load with no further change. Drop the dead "csat"
entry from that action's ELIGIBLE_CSAT_LEAD_STATUSES guard in the same pass — it names the same
nonexistent status and can only mislead the next reader.
No other persona surface changes, and no seeding changes: blocker documents are already written by
the demo storyline, and this run makes the customer dashboard show whatever is there.
apps/web/app/(app)/customer/dashboard/_customer-fixture.ts no longer exists and nothing in
apps/web imports it.status: "open")
customer-facing blockers on the signed-in customer's active-project lead — owner Customer,
Your team or Expert — each with its real label, owner and impact, and renders the
"No blockers — project on track" state when the lead has none.Partner or Vendor never appear on the card; they still feed
computeProjectHealth, so the risk badge continues to account for them.CustomerProjectEnvelope exposes the active project's open blockers, populated from the read
that already feeds computeProjectHealth — the dashboard adds no new database round-trip.survey_sent belonging to the signed-in customer appears in the "Awaiting your
feedback" strip on /customer/satisfaction; a lead in any other status does not.completed)."csat" remains as a lead-status filter or guard under
apps/web/app/(app)/customer/satisfaction/.CONVENTIONS.md.storyline-full-coverage-generator and
saas-vendor-storyline own the data.progressStatusPositive fails (it is caught and logged today). Adding a bulk
CSAT-by-leads lookup to remove that row is a larger change than this fix warrants.blocker model, its indexes, and blockerService — read as-is.Context budget: within the Inputs table. Read the stub, _source/breakdown.md +
_source/decisions.md (this scope has no scope.md — the breakdown records D1–D11 as its
equivalent), the knowledge map, _shared/github.md, and the four code sites the stub cites
file:line, plus blocker.ts and leads/index.ts to confirm the field shape and the filter path.
03_build/output/notes.mdfeat: customer-surface-fixes — real blockers on the customer risk card,
feat: customer-surface-fixes — satisfaction strip off the engine's survey stage,
chore: customer-surface-fixes — build notespackages/services/src/db/services/customer-project/index.ts: added
CustomerProjectBlocker and a blockers field on CustomerProjectEnvelope, populated by a new
exported blockersFrom mapper. The rows come from the blockerService.listOpenByLead call that
already ran to feed computeProjectHealth — the same array now feeds both, so the risk card and
the risk badge cannot disagree, and the dashboard makes no extra query. toEnvelope's
openBlockers parameter widened from { label: string }[] to
Pick<IBlocker, "label" | "owner" | "impact">[]; health still reads only label.apps/web/app/(app)/customer/dashboard/page.tsx: dropped the _customer-fixture import and
passed project.blockers to RiskBlockersFeedback. The component's existing
"No blockers — project on track" branch is now reachable and is the empty state; its markup and
copy are untouched (spec: data wiring, not UI work).apps/web/app/(app)/customer/dashboard/_customer-fixture.ts: deleted — the dashboard was its
only consumer.apps/web/app/(app)/customer/satisfaction/page.tsx: statusNames: ["csat"] →
["survey_sent"], with a comment recording why that status is the right one and how a row
leaves the strip.apps/web/app/(app)/customer/satisfaction/[leadId]/actions.ts: dropped the dead "csat" entry
from ELIGIBLE_CSAT_LEAD_STATUSES; the guard now names only the engine status.packages/services/src/db/services/customer-project/index.test.ts: new — unit coverage for
blockersFrom, written from the acceptance criteria.apps/web/AGENTS.md: removed the parenthetical that documented the quarantined fixture as a
flagged P0 exception — the file it pointed at no longer exists._customer-fixture.ts no longer exists and nothing in apps/web imports it — file deleted
via git rm; grep -rn "_customer-fixture\|customerData" apps/web/ returns nothing.listOpenByLead already filters status: "open" and scopes by tenant + lead;
blockersFrom keeps only owners Customer/Your team/Expert;
RiskBlockersFeedback branches on blockers.length === 0.Partner/Vendor blockers never reach the card but still feed health — blockersFrom
filters, computeProjectHealth still receives the unfiltered openBlockers array.
Added at Verify from a code-review finding (commit owner-filter).CustomerProjectEnvelope exposes the open blockers from the read that already feeds
computeProjectHealth, with no new round-trip — blockersFrom(openBlockers) maps the
in-memory array that was already fetched; no new service call.survey_sent lead for the signed-in customer appears in the strip; other statuses do not
— findAll filters status: { $in: ["survey_sent"] } alongside the existing customerId
and tenantId scoping.submitCustomerCsatAction calls progressStatusPositive, which moves the lead
survey_sent → completed, so it no longer matches the filter."csat" status string remains under customer/satisfaction/ — verified by grep.index.test.ts: fields carried
through, empty list, order and count preserved, and internal fields (tenantId, lead,
status, escalated, escalationCount) dropped rather than leaked.customer/dashboard, customer/satisfaction, and the customer-project envelope.
blockerService itself is unchanged, so the CSM blocker queue and expert workbench readers
are untouched.blockersFrom is a new export on a module that has no barrel line, so
it is public to the package but not to @sustentus/services/server. It is exported so the unit
tier can reach it — getActiveProjectForCustomer needs Mongo and there is no integration tier
yet. If you would rather it stayed module-private, the alternative is dropping the test.blocker documents with status: "open"
on the customer's active-project lead for the card to show rows, and a lead in survey_sent for
the strip. Both are the storyline seeder's job (storyline-full-coverage-generator,
saas-vendor-storyline), not this run — an empty card and an absent strip on the preview are
correct behaviour here, not a regression. Check the empty state renders cleanly either way.progressStatusPositive throws it is caught and logged, leaving the
lead in survey_sent and a stale row on the strip; clicking it lands on the survey page, which
redirects back because a CSAT exists. Duplicate submission is blocked by the CSAT unique index
and by that redirect, so the residue is cosmetic.owner narrows to BlockerOwner in the envelope but RiskBlockersFeedback still types its prop
as string. Assignable, so no error; tightening the component prop would be UI work this spec
excludes.Context budget: within the Inputs table — the spec, run.md, CONVENTIONS.md,
apps/web/AGENTS.md, packages/services/AGENTS.md, the knowledge map, _shared/github.md, and
the files named in touches: plus blocker.ts, leads/index.ts and vitest.shared.ts to confirm
the field shape, the filter path and the test include pattern.
04_verify/output/verify.mdprocess.env read (so nothing owed to turbo.json → globalEnv), no payments, no route-policy
or Clerk change. The diff's only DB contact is a read-path filter (statusNames) and an
in-memory projection of rows that were already being fetched.complexity: standard); the CI Claude review is off —
.github/workflows/claude-code-review.yaml is gated on vars.ENABLE_CLAUDE_REVIEW == 'true' and
its check run reported skipped — so the review was run here. 2 findings: 1 fixed on branch,
1 accepted (already recorded as spec Out of scope).Preview: https://web-git-claude-customer-surface-fixes-9xb0ku-sustentus.vercel.app
_customer-fixture.ts gone, nothing imports it — git rm'd; grep -rn "_customer-fixture\|customerData" apps/web/ returns nothing (agent)CustomerProjectEnvelope exposes the blockers with no new round-trip — traced in the diff:
blockersFrom(openBlockers) maps the array listOpenByLead had already returned for
computeProjectHealth; no added service call (agent)Partner/Vendor blockers withheld from the card, still counted by health — traced:
blockersFrom filters, computeProjectHealth still receives the unfiltered array; four unit
cases assert the split (agent)"csat" lead-status filter or guard under customer/satisfaction/ — grep returns nothing
(agent)tenantId from getTenantOrNull(),
customerId from resolveAppUserForTenant behind a role !== "customer" redirect; neither
derives from searchParams (agent)Quality Project success on dbd1a26, the head carrying
the owner filter: format, lint, typecheck (incl. the satisfies readonly BlockerOwner[]
clause) and all seven blockersFrom cases (agent, read from CI)web deployment Ready on the branch (agent)survey_sent lead appears in "Awaiting your feedback"; other statuses do not
(operator — NOT YET PERFORMED)notifyX call and changes no notification
trigger; submitCustomerCsatAction's existing behaviour is unchanged (agent)The five unticked lines are the signed-in half. The agent has no preview credentials, so they are recorded as not performed rather than assumed. The quality gate should not be confirmed until they are reported — see "Open at hand-off".
Internally-owned blockers rendered on the customer's card (code-review) — fixed on branch,
0765dfa. blockersFrom passed all five BlockerOwner values through, so Partner and
Vendor rows — raised on the expert workbench and the CSM blocker queue, worded for internal
readers — would have been repeated verbatim to the customer. Now filtered to Customer /
Your team / Expert, with the allow-list written as const satisfies readonly BlockerOwner[]
so renaming an owner breaks the build rather than silently hiding a row the customer should see.
This narrowed an already-approved acceptance criterion, so spec.md was updated in the same
commit and the PR body's criteria text reconciled from it (file → PR, one direction). Flagged
here because the Spec-approved tick predates the change.
computeProjectHealth. The risk badge can therefore read Medium/High while the visible
list is shorter than that implies, or empty. Chosen over the alternative (filtering health
too), which would let an internal blocker silently understate the customer's risk. Note the
badge could already outrun the list before this run — overdue milestones and overdue invoices
raise severity with no blocker at all — but the fixture always rendered two rows, so the
combination has never actually been seen on screen. Worth a look during the operator pass.Stale row if progressStatusPositive fails (code-review) — accepted, no change. Already
recorded in spec.md → Out of scope at Define: the transition is the mechanism, duplicate
submission is blocked by the CSAT unique index, and the survey page redirects when a response
exists, so the residue is a cosmetic row that bounces back. Removing it needs a bulk
CSAT-by-leads lookup this run does not warrant.
findAll's customer filter is fail-open (security-review, defence-in-depth — not a
vulnerability, no change) — leadService.findAll applies filter.customer only
if (customerId && isValid(customerId)); an absent or malformed id silently widens the query to
tenant-wide. Not exploitable: the caller passes resolved.user._id.toString(), always a valid
ObjectId, and there is no attacker-reachable path to an invalid value. Recorded because this run
makes the code path reachable for the first time — statusNames: ["csat"] previously matched
zero rows unconditionally, masking the shape. Worth hardening if findAll ever gains a caller
whose customerId comes from less trustworthy input.
The dead csat status survives elsewhere (observation, out of scope) —
apps/web/components/projects/projects-list/projects-list.tsx lists "csat" in both
PROJECT_STATUS_NAMES and ALL_LEAD_STATUS_NAMES, alongside other non-engine spellings
(pre-qualification, analysis, requirements_analysis, awaiting_customer_feedback,
processing, work-in-progress, closed_lost). Deliberately permissive legacy superset,
untouched by this run — the spec scoped the cleanup to customer/satisfaction/. Candidate for a
chore lane: reconcile that list against the workflow engine.
(apps/web/lib/workspace/views.ts's "csat" is a workspace section id, not a lead status —
correctly left alone.)
dbd1a26 settled with
no failures: Quality Project, Migrate preview database, Audit database, and the four
pipeline advisories. Migrate production database and Review diff against CONVENTIONS.md
skipped (not applicable pre-merge / ENABLE_CLAUDE_REVIEW off).survey_sent data may be absent
on the preview — the storyline seeder that lights them is storyline-full-coverage-generator /
saas-vendor-storyline, later in this batch. An empty card and an absent strip are correct
behaviour here; the empty state rendering cleanly is the thing to confirm.#build — the agent cannot record one, and the surfaces are
unlit on the preview until the storyline stubs land.Context budget: within the Inputs table — spec, notes, run.md, the branch diff against
origin/main, _shared/github.md, plus blocker.ts, leads/index.ts,
risk-blockers-feedback.tsx and claude-code-review.yaml to settle the two findings and the
review-enablement question.
05_ship/output/changelog.mdTwo things on your project pages now show you your own project.
Your risk rating, go-live date and delivery confidence are unchanged — they were already computed from your project's real milestones, blockers and invoices.
05_ship/output/investor-update.mdWho it's for: Customers What shipped: The customer dashboard lists the real blockers open on their project, and the satisfaction page says when a survey is waiting. Why it matters: Refine the Bridge is about the quality of each interaction — this dashboard showed everyone the same invented blockers, and the feedback loop never opened.
Both were product bugs; seeding could never have fixed them.
Dig deeper: https://github.com/sustentus/sustentus/pull/814 · https://help.sustentus.com/changelog/2026-08-17-customer-surface-fixes
05_ship/output/release.md6e61fe9 — Quality Project, Migrate preview database, Audit database and the
four pipeline advisories all passed; re-checked after this commit before the mergecustomer-project envelope, and technical/packages/services documents entrypoints, not
envelope fields (grepped: no CustomerProjectEnvelope reference anywhere under technical/**)business/service-journey/delivery — added "What the customer sees on the risk
card" under Computed project health (the customer-facing owner filter, and the deliberate
badge-vs-list asymmetry it creates), and recorded that a survey_sent lead now surfaces to the
customer under "Awaiting your feedback" until they respondship-note.yaml fires on the squash-merge and mails
05_ship/output/investor-update.md to #product-update. Not sent by this stage._customer-fixture.ts gone, nothing imports it — grep, in Verify (agent)listOpenByLead filters status: "open" and scopes by tenant
+ lead; RiskBlockersFeedback branches on blockers.length === 0. Not demonstrated on the
preview — see the caveat below.Partner/Vendor blockers never appear on the card but still feed computeProjectHealth —
added at Verify from a code-review finding (0765dfa); four unit cases assert the splitCustomerProjectEnvelope exposes the blockers with no new round-trip — code-traced; the
mapper reuses the array already fetched for healthsurvey_sent lead appears in the strip; other statuses do not — code-traced; the query
filters status: { $in: ["survey_sent"] } under existing tenant + customer scoping.
Not demonstrated on the preview.progressStatusPositive moves the lead to completed). Not demonstrated on the preview."csat" lead-status filter or guard under customer/satisfaction/ — grep, in Verifycustomer-project/index.test.ts,
green in the Quality suite on 6e61fe9The five signed-in Definition-of-Done lines in 04_verify/output/verify.md were never
demonstrated on the Vercel preview — the agent has no preview credentials, and no operator pass was
reported back. They are ticked above on code tracing and unit coverage, not on a human seeing
them work. The gate was authorised regardless: Jamie ticked Ready to merge and instructed the
ship directly. Recorded here so the run's evidence is not overstated.
The surfaces are also unlit on the preview until the storyline seeder lands
(storyline-full-coverage-generator, saas-vendor-storyline, later in this batch) — there are no
blocker documents or survey_sent leads to show, so an operator pass today would have confirmed
the empty states and little else.
No walkthrough clip was posted to #build: the agent cannot record one, and there is nothing lit
to record.
Context budget: within the Inputs table — the spec, verify record, run.md, _shared/github.md,
the knowledge map, and the four apps/docs pages checked for impact
(service-journey/delivery, feature-role-matrix/projects, technical/packages/services,
initiatives/refine-the-bridge + okrs/2026-Q2 for the ship-note tie-in).