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 78 — a 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.
UpdateDraftwould otherwise be the bypass — it replaces the whole body, so an ungrounded amend could overwrite a grounded draft with invented text.- The two senders are grounded because guarding the reversible path and leaving the irreversible one
free to type a number from memory would rebuild the asymmetry above inside one class. They are
absent from the model's tool list under
Email:AgentSend=DraftOnlyregardless. PrepareMailingis grounded because the persona's rule is "cite a contact detail" for every mail; a tool that did not resolve one would show the reviewer a literal{{@…}}with a live Send button beside it.
🚨 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:
CandidatePattern— broad. Anything opening{{@is claimed, andHasReferencesasks this question, so nothing citation-shaped can take the no-citation path.CitationPattern— the grammar, anchored and applied to a whole candidate, so a candidate with trailing rubbish is rejected rather than partially matched.
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:
- 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#1059exists to preserve, on correspondence a human was about to send. - 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:
LooksLikeMarkupis extracted andAsHtmlBodyuses it, so grounding cannot drift from whatAsHtmlBodyactually does.- The verdict is taken on the body the model wrote, before substitution, and carried on
Grounded.BodyIsMarkupto every write site (AsHtmlBody,BuildMessage,BuildDraftPatcheach take an optional verdict; null means "decide as you always did", so no other caller changes). DraftGrounding.SubstituteHTML-encodes a value going into author-written markup and deliberately does not for prose — there the caller encodes the whole body afterwards, and encoding twice turns an ordinary&in a company name into&amp;. The subject is plain text on the wire and is never encoded.
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).
- The mesh edge is bridged exactly once, through
ObserveCompletion— never.ToTask(), which resumes its awaiter inline on the signalling thread. - Identity is re-seeded inside the
Defer, asMeshPlugindoes, so the read runs as the USER: a record the user may not read isNot foundto the draft as well, and a citation can never disclose a value its author could not have read. - The member is read off the node JSON, not through
ContentAs<T>. That is not the cast trap-door: the cited content type (Crm/Contact→ContactContent) is compiled in the mesh from a satellite package and this assembly deliberately does not reference it, so there is noTto pass — the same wire-shape reasoningDraftRecordsis built on. Lookup is case-insensitive (a model writesPhonewhere the wire saysphone) and dotted for a nested member.
It fails closed, and where it does not read at all
- A draft that cites nothing takes exactly the path it took before, with no mesh read: the
cheap
HasReferencescheck returns first. - An absent portal hub is a refusal, not a pass-through. A literal
{{@…}}must never reach a mail a human sends under their own name. This is the opposite of the draft record, which is deliberately best-effort because by then the draft already exists in Outlook. - A member that is an object or a list is reported as the wrong shape, not as "blank" — a refusal that misdescribes the record sends the reader to the wrong fix.
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:
- Traces to a record — the body cites
#phone; the number Graph receives is the record's, no{{survives, and the answer names the record so the assistant can say where the value came from. - Does not trace — the member is absent; the call is refused and Graph receives no request at all. Asserted on the mailbox's call list, because a refusal that still posted would be indistinguishable from a pass read off the answer string.
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