Grounding a contact detail in a mail

A user asked the Executive Assistant to draft an update about a phone call with a contact at a client. The draft gave that contact's phone number as +41 76 234 56 78a number that exists nowhere. The correct number was on his Crm/Contact record in the mesh the whole time, and the draft was one Send away from the client under the user's own name (Systemorph/MeshWeaver.Plugins#2149, from Systemorph/MeshWeaver.Feedback#2).

The asymmetry, which is the reason this is a platform defect

Two paths write mail, and they carried structurally different guarantees against the same class of error.

On the mailing path a placeholder with no value is a typed outcome — MailingProblemKind.UnresolvedPlaceholder — and MailingMerge.Validate's own parameter documentation says plainly that "a mail with any is never sent". The author of a template cannot write the value at all: {{Name}} is filled from the recipient line. So a wrong value there is a wrong input, never an invention, and the refusal cannot be talked past or depend on a model choosing well.

On the draft path the model typed the whole body. The only comparable protection was one line of prose in Agent/ExecutiveAssistant:

Never fabricate that you sent mail or booked a meeting — if you didn't call the tool, it didn't happen.

That forbids fabricating an action. It says nothing about fabricating a fact in the body, and it was the only fabrication clause in the file.

What is enforced: provenance DECLARED, then VERIFIED

A subject or body may carry a citation of the record a detail comes from, and every mail-writing tool resolves it:

He is reachable on {{@PearlTechnology/ThomasMueller#phone}} — I have attached the notes.

DraftGrounding reads that node and substitutes what the record holds. The model supplies the reference; the value never comes from the model, so a cited detail cannot be fabricated. An unresolvable citation — an invented path, a member the record does not carry, a blank member, a malformed citation — is a typed DraftProblem and refuses the whole call: nothing drafted, nothing amended, nothing sent.

Grounded surfaces: DraftMail, DraftReply, UpdateDraft, PrepareMailing, SendMail, ReplyToMail.

🚨 Why a detector over phone-shaped text was REJECTED

The obvious design — scan the body for phone-shaped or mail-shaped literals and ask whether each traces to a record — is not implementable here, and shipping it would have been worse than shipping nothing.

The distinguishing question is whether a value traces to a record or to the transcript. A number the user typed in the conversation is legitimate and traces to no record. From inside a mail tool the transcript is unreachable:

what would be needed what is actually there
per-round state on the plugin ExecutiveAssistantPlugin is AddSingleton<IAgentPlugin> in the mesh-root container, handed the portal hub — one instance for every user, thread and round
the round's context only IAgentChat.ExecutionContext, reachable solely through a plugin's constructor at agent-build time (how AgentFilesPlugin gets its thread); IAgentChat is in no service collection and DI-resolved plugins are never given it
an ambient none — no AsyncLocal, no per-round scope, no AIFunctionArguments context, no ChatOptions.AdditionalProperties. ChatClientAgentFactory says why one would not work anyway: "AsyncLocal doesn't flow through the AI framework's streaming + tool invocation"
the messages ThreadExecution.LoadFullConversationHistoryFromMesh is internal, visible only to test assemblies

So "the user told me this number" and "I inferred this number" are indistinguishable there. A detector would have fired on ordinary drafts, and a guard that fires on legitimate use gets switched off, which is worse than no guard.

The rule that is enforced is the one that can never be wrong: a value said to have come from a record really did come from that record. An uncited literal is not blessed by this and is not claimed to be. That makes being right possible and verified, not mandatory — a deliberate partial fix, with #2149 left open on the remainder.

What would close it is a framework change, not a cleverer regex: give DI-resolved plugins the chat the way ResolvePluginTools gives it to the built-ins, or publish the round context at AccessContextAIFunction.InvokeCoreAsync — the one seam every tool of every plugin passes through, and which already writes one ambient (the user's AccessContext).

🚨 The detector for citations must be BROADER than the grammar

The first implementation used one regex as both the parser and the "does this text carry a citation" gate. That made a typo strictly worse than no citation: a citation-shaped string that failed the grammar matched nothing, so the text took the no-citation path, was never refused, and reached the mailbox verbatim in a mail a human sends. Measured on that version, both of these went out as literal text:

{{@P/M#phone}          one closing brace
{{@P/M#phone#work}}    two '#' — the grammar takes one

The shape that is fail-closed is two patterns:

A candidate that fails the grammar is a MalformedReference whose detail distinguishes "never closed with }}" from "the text between the braces is not path#field", because those need different fixes.

Never narrow the detector to the grammar. The generalisation: when one predicate decides both "is this mine?" and "is this valid?", every invalid instance silently becomes not-mine — and not-mine is the unguarded path.

The @ is what claims a string, which is why this is non-breaking: a mailing template's {{Name}} is not a candidate and is never touched. The two placeholder languages compose rather than collide, because MailingMerge's key pattern admits neither @, / nor #.

🚨 A record's own text must never reach the wire as markup

AsHtmlBody decides prose-versus-markup by looking for a tag opener (<[a-zA-Z/!]) and passes markup through untouched. Substituting a record value before that decision gave a CRM field two powers it must not have:

  1. It could flip the rendering mode. A contact note holding <b> made an otherwise-prose body match the predicate, so the whole mail took the raw-HTML pass-through — losing the paragraph breaks that #1059 exists to preserve, on correspondence a human was about to send.
  2. It became live markup, so record data decided what the recipient's client rendered.

The fix is MailingMerge.MergeHtml's rule, plus the mode decision:

How the cited node is read

Through MeshOperations.Get, which is the one surface that already reads a model-supplied path safely: it normalises the path noise a model adds, bounds itself, and answers Not found instead of leaving a point read to storm the owning hub (the trap CqrsAndContentAccess describes).

It fails closed, and where it does not read at all

Two things that are easy to get wrong when changing this

A tool description has a provider cap. 1024 characters is the common one, and a tool over it is rejected for the whole round — so appending the full grounding rule to every tool description would have taken the mailbox offline rather than made it safer. PrepareMailing measured 1125. Hence two forms: a short GroundingHint on tool descriptions, and the full GroundingDescription on the subject/body parameters, which is what a model reads while composing and which is far from the cap. NoToolDescription_OutgrowsTheProviderCap measures every tool in both send modes.

A test that reads the wire must parse it, not string-match it. Kiota escapes + as +, so Contains("+41 79 …") fails on a perfectly correct draft. Read the value out of the parsed JSON. A reply's text is comment and a draft's is body.content, so an assertion about the value should not depend on which model carries it.

The controls

Two arms, one on each side of the change, both executed against a real mesh with the Graph mailbox scripted at the HTTP boundary:

With the grounding gate replaced by a pass-through (the pre-fix behaviour) the second arm answers DRAFT SAVED — NOT SENT. A draft to … is in the user's Drafts folder and files its record — the incident, reproduced. That is the measurement that makes the fix a fix and not a hope.

Related: Where a draft is filed · Governed mail · The Executive Assistant's credential reads

Reconnecting…
The connection to the server was interrupted. Trying to restore it…
Trying again…
The connection could not be restored. Reloading the page…
The server was updated. Reloading the page to pick up the latest version.