The letter was stamped with whoever pressed generate, not with the solicitor who owned the case. When an assistant prepared one for their supervising solicitor, the client was told the assistant was their lawyer, and the wrong name was then frozen into the saved agreement.
A client care letter opens by telling the client who is acting for them. That name is rendered from quote.case.owner.person. The draft generator had no case to read, because in the new-case flow the case row does not exist yet, so it invented one: it passed the caller straight through as the owner. Every other layer then behaved correctly on top of a false premise.
ctx.person, as the lawyer. It had nothing else to offer.case: { owner } from that caller, so the template’s lawyerName becomes the assistant.In plain English: the letter answered “who is your lawyer?” with “whoever is holding the mouse”.
packages/api/src/modules/agreements/procedures/generateDraftCCLProcedure.ts
const markdownAgreement =
await generateDraftCCLFromMarkdownForQuote({
quote: input.quote,
firmEmployee: {
...ctx.firmEmployee,
person: ctx.person,
},
})
The procedure only ever received a quote draft. It had no case id and no nominated lawyer, so the caller was the only person it knew about.
The generated letter is saved as TipTap JSON on quote.clientCareLetter. getAgreementHtml prefers that stored letter over regenerating from the template, and then writes the rendered result back into ClientAgreement.htmlContent.
So the wrong name is persisted in two places before anyone reads the letter, and it survives a later reassignment of the case. Fixing the generator does nothing for letters already written.
Support escalated one matter where the letter named a team member rather than the supervising solicitor who owned the case. The trace is a clean two-step: agreement.generateDraftClientCareLetter at 16:37:23Z, then quote.solicitor.create at 16:38:07Z with the team member’s name already baked into the letter JSON. Full identifiers are on V2-76; names and matter references are deliberately left out of this page.
The name sits in the letter body, so the only way to count this is to read the letters. I extracted the 300 characters that follow the “Your lawyer” heading in every custom letter written in the last 12 months and checked whether the current case owner is named in that block. Reading the block rather than the whole letter matters: an owner’s name often appears further down in a signature, which hides the defect.
| Cause | Signed | Unsigned | Revoked | Total |
|---|---|---|---|---|
| This defect, names someone on the owner’s team | 340 | 50 | 3 | 393 |
| Case was reassigned, letter names a previous owner | 131 | 27 | 1 | 159 |
| Names nobody I could match to the firm, likely hand edited | 81 | 42 | 0 | 123 |
| All letters flagged for review | 552 | 119 | 4 | 675 |
5,041 custom letters in the window; 675 (13.4%) do not name their current owner; 8 had no recognisable “Your lawyer” block at all, which is why block extraction is a safe filter. Every one of the 675 had an issued agreement row, so none of these were idle drafts.
Causes are tested in order, so a case that was both reassigned and drafted by an assistant is counted as reassigned. The “who was named” query drops that precedence and finds 419, which puts the overlap at 26.
Some of the 123 unrecognised letters will also be this defect where the assistant has since left the team, so their name no longer matches anyone on it.
All figures are the trailing 12 months, chosen so the scan finishes in seconds. An earlier unbounded pass over whole letter bodies flagged roughly three times as many letters, so the all-time total is larger.
Only firms that use teams are exposed. A solicitor drafting on their own case was always given their own name, correctly, by accident.
Lawyer resolution moved out of the procedure into resolveLawyerForDraftCCL, which takes a case id, a nominated owner, or both, and never looks at who is calling. The case wins when present, because it is the record of who is actually retained. The nominated owner exists only for the new-case wizard, where the case row does not exist yet and the lead lawyer has just been chosen from a dropdown.
.input(
z.object({
quote: QuoteDraftSchema,
caseId: z.string().optional(),
ownerFirmEmployeeId: z.string().optional(),
}).refine((i) => !!i.caseId || !!i.ownerFirmEmployeeId)
)
…
const lawyerFirmEmployee =
await resolveLawyerForDraftCCL({
caseId: input.caseId,
ownerFirmEmployeeId: input.ownerFirmEmployeeId,
callerFirmId: ctx.firmEmployee.firmId,
})
The schema refuses a request that identifies no lawyer, so the old behaviour is now unreachable rather than merely discouraged.
ctx.person.FORBIDDEN.P2025 becomes NOT_FOUND rather than a 500.On the frontend the two identifiers are threaded through CreateQuoteWizard context so both customise steps and the summary preview modal send the same thing. The existing-case page passes its route id; the new-case wizard passes the chosen lead lawyer.
I drove both flows by hand against a local stack, signed in as a non-solicitor assistant sitting on a solicitor’s team. Both were needed because they send different inputs, and only one of them was reachable from the reported case.
/solicitor/cases/<id>/create-quote. The request carried caseId set and ownerFirmEmployeeId unset, which is the shape that matters: the assistant’s browser has no idea who the lawyer is, and does not get a say. The letter named the solicitor who owns the case.
This is the exact path the support report came from.
Assistant opens a case, picks the solicitor as lead lawyer, and the request carries ownerFirmEmployeeId only, since no case row exists yet. The letter named the solicitor, and a query against the saved quote confirmed the owner’s name is in the stored JSON and the assistant’s is not.
Verified in the database, not just on screen, because persistence is what made the wrong name permanent in the first place.
Worth recording, because the reproduction was gated by three things that have nothing to do with this defect and cost more time than the fix:
solicitor.canCreateCases (the BYOC flag) enabled in the admin app. Nothing in the UI says so.ECONNREFUSED :6010. Fee plan lookup calls the incentive service, which lives in the v3 repository and has to be started separately, with its own database migrated first.Also note the wizard only offers a “lead lawyer” dropdown to non-solicitors who sit on two or more teams; a solicitor is routed straight to their own case. That is why testing this needs a genuine assistant account rather than a second solicitor.
Run these read-only against production PostgreSQL, in order. The pattern that makes them fast is the same in all three: cast the letter JSON to text exactly once per row inside a MATERIALIZED CTE, and compare against a 300-character window rather than the whole document. Scanning full letter bodies instead takes minutes and produces worse answers.
WITH win AS MATERIALIZED (
SELECT
q.id AS quote_id,
c.id AS case_id,
c."ownerId" AS owner_id,
btrim(concat_ws(' ', op."firstName", op."lastName")) AS owner_name,
CASE
WHEN strpos(l.letter, 'Your lawyer') > 0
THEN substr(l.letter, strpos(l.letter, 'Your lawyer'), 300)
END AS lawyer_block
FROM "Quote" q
JOIN "Case" c ON c.id = q."caseId"
JOIN "FirmEmployee" ofe ON ofe.id = c."ownerId"
JOIN "Person" op ON op.id = ofe."personId"
CROSS JOIN LATERAL (SELECT q."clientCareLetter"::text AS letter) l
WHERE q."clientCareLetter" IS NOT NULL
AND q."createdAt" >= now() - interval '12 months'
)
SELECT
count(*) AS letters,
count(*) FILTER (WHERE lawyer_block IS NULL) AS no_lawyer_block,
count(*) FILTER (
WHERE lawyer_block IS NOT NULL
AND length(owner_name) > 3
AND lawyer_block NOT ILIKE '%' || owner_name || '%'
) AS owner_missing_from_block
FROM win;
Expect one row: 5041 · 8 · 675. If no_lawyer_block is anything other than a handful, the heading has changed and the window extraction needs revisiting before you trust the third column.
-- reuse the win CTE above, then:
flagged AS (
SELECT * FROM win
WHERE lawyer_block IS NOT NULL
AND length(owner_name) > 3
AND lawyer_block NOT ILIKE '%' || owner_name || '%'
)
SELECT
CASE
WHEN EXISTS (
SELECT 1 FROM "CaseReassignment" cr
JOIN "FirmEmployee" pfe ON pfe.id = cr."oldFirmEmployeeId"
JOIN "Person" pp ON pp.id = pfe."personId"
WHERE cr."caseId" = s.case_id
AND s.lawyer_block ILIKE '%' || btrim(concat_ws(' ', pp."firstName", pp."lastName")) || '%'
) THEN 'reassigned - previous lawyer'
WHEN EXISTS (
SELECT 1 FROM "TeamMember" tm
JOIN "Team" t ON t.id = tm."teamId"
JOIN "FirmEmployee" mfe ON mfe.id = tm."firmEmployeeId"
JOIN "Person" mp ON mp.id = mfe."personId"
WHERE t."ownerId" = s.owner_id
AND mfe.id <> s.owner_id
AND length(btrim(concat_ws(' ', mp."firstName", mp."lastName"))) > 3
AND s.lawyer_block ILIKE '%' || btrim(concat_ws(' ', mp."firstName", mp."lastName")) || '%'
) THEN 'names a team member'
ELSE 'unrecognised name'
END AS bucket,
coalesce(ag.state, 'no ccl agreement row') AS agreement_state,
count(*) AS quotes
FROM flagged s
LEFT JOIN LATERAL (
SELECT
CASE
WHEN ca.status = 'completed'
OR ca."completedAgreementSnapshotS3Key" IS NOT NULL
OR ca."externalSignedFileCaseFileId" IS NOT NULL
OR EXISTS (
SELECT 1 FROM "ClientAgreementSignatureRequest" sr
WHERE sr."agreementId" = ca.id AND sr.status = 'signed'
)
THEN 'signed'
WHEN ca.status = 'revoked' THEN 'revoked'
ELSE 'pending - unsigned'
END AS state
FROM "ClientAgreement" ca
WHERE ca."quoteId" = s.quote_id AND ca.type = 'clientCareLetter'
ORDER BY ca."createdAt" DESC
LIMIT 1
) ag ON true
GROUP BY 1, 2
ORDER BY quotes DESC;
Expect eight rows summing to 675, led by names a team member · signed · 340. The row that decides the migration is names a team member · pending - unsigned · 50. Do not sample this with a bare LIMIT: an unordered 200-row sample put the defect at 34% of the flagged letters when the true figure is 58%, because unordered rows follow physical order and skew old.
-- reuse win + flagged, then:
SELECT
CASE WHEN m.is_solicitor THEN 'named a solicitor colleague'
ELSE 'named a NON-solicitor' END AS who_was_named,
CASE
WHEN ag.status = 'completed'
OR ag."completedAgreementSnapshotS3Key" IS NOT NULL
OR ag."externalSignedFileCaseFileId" IS NOT NULL
THEN 'signed'
WHEN ag.status = 'revoked' THEN 'revoked'
ELSE 'pending - unsigned'
END AS agreement_state,
count(*) AS quotes
FROM flagged s
JOIN LATERAL (
SELECT mfe."solicitorId" IS NOT NULL AS is_solicitor
FROM "TeamMember" tm
JOIN "Team" t ON t.id = tm."teamId"
JOIN "FirmEmployee" mfe ON mfe.id = tm."firmEmployeeId"
JOIN "Person" mp ON mp.id = mfe."personId"
WHERE t."ownerId" = s.owner_id
AND mfe.id <> s.owner_id
AND length(btrim(concat_ws(' ', mp."firstName", mp."lastName"))) > 3
AND s.lawyer_block ILIKE '%' || btrim(concat_ws(' ', mp."firstName", mp."lastName")) || '%'
ORDER BY mfe."solicitorId" NULLS FIRST
LIMIT 1
) m ON true
LEFT JOIN LATERAL (
SELECT ca.status, ca."completedAgreementSnapshotS3Key", ca."externalSignedFileCaseFileId"
FROM "ClientAgreement" ca
WHERE ca."quoteId" = s.quote_id AND ca.type = 'clientCareLetter'
ORDER BY ca."createdAt" DESC
LIMIT 1
) ag ON true
GROUP BY 1, 2
ORDER BY quotes DESC;
Expect five rows totalling 419, led by named a NON-solicitor · signed · 325. The total exceeds 393 because the inner join replaces the reassignment precedence; treat the 26 difference as overlap, not as new cases. solicitorId IS NULL is the same flag the product uses to decide who counts as a lawyer.
Swap the aggregate for SELECT quote_id, case_id, owner_name, member_name, lawyer_block … ORDER BY random() LIMIT 10 and read the blocks. You are checking one thing: whether the block genuinely presents a non-solicitor as the client’s lawyer, or whether the owner’s name simply sits further down than 300 characters. Ten rows settles it.
Then group the same set by firm with min(created_at) and max(created_at). If the letters cluster into a handful of firms, remediation is a few conversations rather than a mass notification, and the first date is what compliance will ask for. It should line up with when assistants could first draft quotes.
quote.clientCareLetter and ClientAgreement.htmlContent. Clearing the fields to force regeneration is simpler but discards hand edits, and roughly a fifth of letters are edited.The defect was one line of convenience: a generator that needed an owner and had a caller to hand. It survived because a draft was treated as disposable while the system quietly persisted it twice, and because the only person who could see the letter was the person whose name was wrongly on it. Anything that renders a person’s name into a document a client signs should resolve that person from the record, and should never be able to answer the question with “you”.
Sources: V2-76 · generateDraftCCLProcedure · getAgreementHtml · CCL markdown templates