Root-cause analyses · Platform v2 · client care letters

Client Care Letters Named The Wrong Lawyer

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.

Date · 11 August 2026 Status · Confirmed Severity · Major Author · Libaan Hassan Ticket · V2-76 Evidence · production PostgreSQL, source, local reproduction
Finding · one paragraph
Client care letter generation took the lawyer’s name from the logged-in user rather than from the case. A solicitor drafting their own letter got the right name by accident; an assistant drafting on a colleague’s case put their own name under “Your lawyer”. I read the “Your lawyer” block of all 5,041 custom letters written in the last 12 months and found 393 that named the wrong person, 340 of them already signed. In 325 of the signed ones the person named was not a solicitor at all. The fix resolves the lawyer from the case owner, and is covered by nine unit tests plus both flows reproduced locally.
393
letters named the wrong lawyer (12 months)
340
of those already signed by the client
325
signed letters naming a non-solicitor
7.8%
of all custom letters in the window

1. The letter described the wrong person

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.

1 · An assistant presses generateThey open the quote wizard on the case their supervising solicitor owns.
2 · The procedure hands over the callerDraft generation received the logged-in user, ctx.person, as the lawyer. It had nothing else to offer.
3 · The generator invents an ownerIt builds case: { owner } from that caller, so the template’s lawyerName becomes the assistant.
4 · The name is saved twiceOnto the quote’s letter JSON, then copied into the agreement’s stored HTML the first time anyone reads it.

In plain English: the letter answered “who is your lawyer?” with “whoever is holding the mouse”.

The whole defect, before

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.

Why a draft became permanent

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.

The reported case

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.

2. How many letters are wrong

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.

Names someone on the owner’s team, which is this defect Names a previous owner, which is legitimate history Names nobody I could match, most likely hand edited
CauseSignedUnsignedRevokedTotal
This defect, names someone on the owner’s team340503393
Case was reassigned, letter names a previous owner131271159
Names nobody I could match to the firm, likely hand edited81420123
All letters flagged for review5521194675

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.

The severity is in who got named, not how many. Of the 340 signed letters, 325 named someone with no solicitor record; only 21 named a qualified colleague. A client told that a different solicitor at the firm is handling their matter has been given wrong paperwork. A client told that a non-solicitor is their lawyer is a different conversation, and not an engineering one.

393 is a floor, not a ceiling

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.

What the window does and does not cover

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.

3. The fix: ask the case, never the caller

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.

The procedure, after

.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.

What the resolver guarantees

  • The caller is never the answer. There is no code path that reads ctx.person.
  • The case beats the dropdown. If a case id is present the nominated owner is ignored, and the firm employee lookup is skipped entirely.
  • No cross-firm letters. A case or lawyer outside the caller’s firm is FORBIDDEN.
  • Missing records read as missing. Prisma’s P2025 becomes NOT_FOUND rather than a 500.
  • Every resolution is logged with the case, the resolved lawyer, and which of the two paths answered.

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.

4. How I proved the fix locally

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.

Path A · quote on an existing 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.

owner named

This is the exact path the support report came from.

Path B · new case wizard

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.

owner persisted

Verified in the database, not just on screen, because persistence is what made the wrong name permanent in the first place.

9 unit tests, all greenCase owner wins; nominated owner ignored when a case exists; cross-firm refused on both paths; missing case and missing employee both map to NOT_FOUND; unexpected database errors bubble up untouched; a request naming nobody is rejected before any query runs.
Solicitors are unaffectedA solicitor drafting on their own case resolves to themselves through the same code path, so the common case keeps working without a special case for it.
What it took to get a local assistant that could open a case

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:

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.

5. The queries, and what they return

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.

1 · Scale: how many letters do not name their owner
~seconds
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.

2 · Cause and fixability: split those 675 by why, and by signing state
~seconds
-- 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.

3 · Severity: was the person named a solicitor at all
the one that changes the conversation
-- 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.

4 · Before escalating: read ten of them, then get the concentration

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.

6. What happens next

Now
Ship the resolver. It stops the count growing, is verified on both flows, and carries no migration risk. Nothing else on this list blocks it.
V2 engineering
Next
Repair the 50 unsigned letters by replacing the wrong name in place, in both 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.
V2 engineering
Escalate
The 340 signed letters are not an engineering decision. Verify a sample, get the per-firm breakdown and the first date, and hand compliance the choice between leaving them, issuing a correction, or re-papering. 325 of them named a non-solicitor.
Compliance, with engineering evidence
Follow-up
Two adjacent gaps found on the way. If letter generation fails the wizard still lets you save an empty letter, and a missing incentive-service scheme member turns a 404 into a 500 instead of falling back to the firm fee plan. Both deserve their own tickets rather than riding along with this fix.
V2 engineering

The lesson worth keeping

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