The Honest Boundary
Affiant’s whole premise is that every AI-proposed write is sworn: a durable
Affidavit with field-level provenance, held on the
Docket until the review flow approves it — a person, or a
Standing Order a person authored in advance. That promise only holds for writes Affiant can
actually see. This page states the boundary of what Affiant sees precisely, explains why the
boundary is where it is, and tells you what to do about the part it can’t reach.
What Affiant intercepts
Section titled “What Affiant intercepts”Affiant intercepts locally-invoked tool calls — function calls that execute as code running
inside your own process, addressed and invoked by whichever backend object your host application
constructed: a Semantic Kernel Kernel, a Microsoft Agent Framework AIAgent, or a
Microsoft.Extensions.AI ChatOptions/IChatClient pairing. See
Interception Backends for how the three attach; this page’s
prose mostly speaks in Semantic Kernel’s vocabulary (Kernel, KernelFunction) since it is the
backend the framework shipped first — the Agent Framework and Microsoft.Extensions.AI adapters
joined the repository later, on 2026-07-05 and 2026-08-20, and all three are first-class — but the
boundary described here is identical on all three. What differs is the enforcement: the
hosted-tool audit that refuses at wire-up ships in the two adapter packages,
Affiant.AgentFramework and Affiant.Extensions.AI. Semantic Kernel has no equivalent — on that
backend the boundary is documented rather than structural.
Concretely, that means:
- Any
[KernelFunction]-decorated method in a plugin registered on yourKernel(or, on the other two backends, any method reflected into anAffiantToolCatalog), whether marked with[AffiantWriteTool]or left as a plain read tool. - Locally-invoked MCP tools — tools from an MCP (Model Context Protocol) server that you’ve
imported into your
Kernelas plugins, the standard Semantic Kernel pattern for consuming MCP tools. Once imported, an MCP-backed tool is an ordinaryKernelFunctionas far as theKernelis concerned. Nothing in Affiant’s interception layer needs to know, or care, that a function’s implementation happens to proxy to an MCP server running on your machine or your network — only that your process’sKernelis the one invoking it. That symmetry is why a locally-invoked MCP tool’s writes can be sworn at all: nothing on Affiant’s interception path is written for MCP. It is not free, though. Interception is automatic; the swearing needs a declaration, and on Semantic Kernel an MCP-imported function that has none is refused —AffiantStartupValidator, the hosted serviceAddAffiantSemanticKernelregisters, walks every plugin on theKerneland throwsAffiantStartupExceptionat start-up for any[KernelFunction]the tool registry has never heard of, so each imported function needs aservices.AddAffiantTool<TStrategy>(...)orservices.AddAffiantReadTool(...)of its own. On the other two backends nothing refuses at start-up, and an undeclared function is intercepted with its inference half skipped — the inference trigger the framework ships finds no descriptor for it and does not fire. The review gate is not registry-driven the same way: it deserializes every tool result and files anything that comes back aWriteProposal, whether the registry knows the tool or not, consulting the registry only to refuse a declared write tool whose result is not a proposal. An MCP-imported function that was never written to emit an Affiant envelope returns ordinary text, so in practice there is no proposal and no Affidavit. The framework does carry MCP-specific names as of1.0.0-beta.3. On its own model surface there is exactly one —CoverageCategory.HostedMcp, wire spellinghosted-mcp, declared with that spelling on the enum inAffiant.Abstractions, written out by hand inAffiant.Core(ToolCoverage.Spell, which spells it and never parses it), and both spelled and parsed by hand inAffiant.EntityFrameworkand the compliance harness, which also reads ahostedMcpflag off a conformance fixture and turns it back into that same category — but all of it exists to record the hosted case described below, never to intercept the local one. The interception half holds identically on the other two backends: a locally-invoked MCP tool arrives as an ordinaryAIFunction, the hosted-tool audit passes it because it is one, and the adapter wraps it like any other.
What Affiant cannot reach
Section titled “What Affiant cannot reach”Affiant cannot see hosted / server-side tool execution — tool calls that a model provider’s own runtime executes remotely, without ever handing control back to your process to make the call.
This covers hosted MCP tools (an MCP server the provider runs and invokes on your behalf, as distinct from one you run and invoke yourself), code interpreter, web search, and other provider-hosted toolboxes exposed by a given model API. If a write happens inside one of those, Affiant never sees the function name, the arguments, or the result — the call and its execution live entirely inside infrastructure Affiant’s host process never touches.
A concrete example: suppose your agent has access to a provider-hosted code-interpreter tool that
can, in principle, call out to an API and persist a result somewhere. If a user’s request causes
the model to route a mutation through that hosted tool rather than through one of your own
[KernelFunction] plugins, nothing about that mutation ever reaches your Kernel — no
IFunctionInvocationFilter fires, no Affidavit is produced, no Evidence Card is shown to a
reviewer. The write either already happened, or it didn’t; Affiant has no visibility into which,
and no opportunity to gate it either way.
There is a second write the review gate does not stand in front of, and it is nearer to home: a
tool Affiant does intercept whose body opens its own connection and writes before it returns.
The gate runs after the tool body, because that is the only seam that sees the result. A position
before the body does exist — Affiant runs its own inference trigger at exactly that point, ahead
of the call — but nothing there can act on what the tool returned, so a write performed inside the
body has already happened by the time the gate sees the result. The framework names this itself
rather than implying a coverage it does not have — its review-gate filter and the framework
specification both call it the honest boundary, protocol rule GT-6 — and what it does guarantee
instead is narrower and exact: such a tool cannot commit through Affiant. The gate never calls a
write tool’s own execute step, no public API lets a tool commit through the framework, and a tool
the registry declares write-capable that hands back anything other than a WriteProposal is
refused rather than passed through — its result is replaced by a ToolError carrying
wireup-invalid, so the model is never told an unfiled write was done. That last refusal has one
hole worth knowing about: the gate filter reads the tool’s result as a string and returns before it
consults the registry when that string is null or empty, so a declared write tool that hands back
nothing at all — a void- or Task-returning method, a function returning null — passes through
unrefused.
Why this is architecturally true, not a missing feature
Section titled “Why this is architecturally true, not a missing feature”Affiant’s interception seam is its own, not any one backend’s. The filter contract is
IToolInvocationFilter in Affiant.Abstractions.Interfaces, and ToolInvocationPipeline in
Affiant.Core.Services runs the registered filters in canonical order; each backend attaches a
bridge that translates its native invocation context into the neutral one and reads the result
back. Semantic Kernel gets two bridge positions, because SK has two:
AffiantFunctionInvocationBridge sits at SK’s IFunctionInvocationFilter (every function
invocation, including a manual kernel.InvokeAsync) and AffiantAutoFunctionInvocationBridge at
IAutoFunctionInvocationFilter (the completion stage of SK’s automatic tool-calling loop, where
the review gate runs). MAF exposes one function-calling seam, and
AffiantFunctionInvocationMiddleware is it. On M.E.AI the bridge is
AffiantDelegatingAIFunction, which wraps each AIFunction the host hands over. Every one of
them fires only when the object
your host wired it onto — that Kernel, that AIAgent, that IChatClient pipeline — is the
thing invoking the function.
Semantic Kernel has one degraded variant of that seam, and it is Affiant’s own rather than
something SK supplies ready-made: Affiant.SemanticKernel.Connectors.ManualToolInvoker runs the
completion-stage segment explicitly for a provider without SK’s auto-invocation loop, so a
manually-invoked write tool’s WriteProposal still reaches the gate. No connector Affiant ships
takes that path — all five IConnectorCapabilities implementations report
SupportsAutoFunctionInvocationFilter => true.
A hosted tool never enters that pipeline, because it was never invoked through the object the bridge is attached to at all. The model provider’s server decided to call the tool, executed it in its own runtime, and returned only the final result to your process — often folded into the same response that would otherwise contain a function-call request. There is no hook, in Affiant’s pipeline or in any of the three backends’ own, for a call that never passed through your process, because every one of those pipelines is defined as a set of filters on an object your process constructed. Affiant cannot swear to a write it structurally never observes.
This is not particular to Semantic Kernel, either — and the other two backends do not draw the
line twice more, independently: they draw it once, at a type they share. Microsoft’s own
successor framework, Microsoft Agent Framework (MAF), attaches its function-invocation middleware
through the Use extension method on AIAgentBuilder —
Microsoft.Agents.AI.FunctionInvocationDelegatingAgentBuilderExtensions.Use, not one of
AIAgentBuilder’s own four Use overloads, none of which takes a function-invocation callback —
whose callback takes a Microsoft.Extensions.AI.FunctionInvocationContext,
Microsoft.Extensions.AI’s (M.E.AI’s) type, not MAF’s. Microsoft.Agents.AI 1.13.0’s own API
documentation for that extension method says the agent or the pipeline wrapping it must include
an M.E.AI FunctionInvokingChatClient, or the wrapped agent throws when it is invoked.
FunctionInvokingChatClient in turn invokes AIFunctions — the ones on ChatOptions.Tools, and
any the host has put on the client’s own AdditionalTools collection. Its published remarks state
the nearest case in so many words: a requested function that is an AIFunctionDeclaration but not
an AIFunction it will not attempt to invoke, passing that call back out to the caller instead.
M.E.AI’s hosted-tool markers (HostedWebSearchTool, HostedCodeInterpreterTool,
HostedMcpServerTool, HostedFileSearchTool and similar) sit further out still. They are not
AIFunctionDeclarations that happen to lack a body — every one of them derives straight from
AITool, the base that AIFunctionDeclaration itself derives from — so there is no client-side
invocation to wrap, and that is the test both of Affiant’s adapter audits apply:
tool is AIFunction, never AIFunctionDeclaration. A hosted marker fails that test, and each
audit refuses it at wire-up unless the host acknowledges it by name. Only one of those markers
carries an approval surface of any kind, and it is the provider’s, enforced server-side:
HostedMcpServerTool.ApprovalMode. Hosted web search, code interpreter and file search expose
none at all. Semantic Kernel’s own filter pipeline draws the same line on its own terms. See
Using Affiant with Microsoft Agent Framework and
Microsoft.Extensions.AI for how each backend attaches. The
boundary is a property of how hosted-tool execution is architected industry-wide, not a gap
specific to Affiant’s current adapter. Two of Affiant’s three adapters enforce it structurally,
refusing at wire-up time by default rather than staying silent about an uncovered hosted tool —
the audit runs inside each WithAffiant entry point, before the bridge is constructed. On
Semantic Kernel the boundary is stated here and in the guides, and no start-up check looks for a
hosted tool: AffiantStartupValidator asks two different questions — whether every
[KernelFunction] on the Kernel is a registered tool descriptor, and whether every registered
descriptor’s inference strategy resolves from the container.
Where the boundary shows up on a Docket row
Section titled “Where the boundary shows up on a Docket row”Two separate mechanisms make a coverage gap visible at 1.0.0-beta.3, and they are not wired to
each other. Only the second one puts anything on a Docket row, and neither of them lets Affiant
see a hosted tool.
The wire-up audit — which already refused at 1.0.0-beta.1. Both the Affiant.AgentFramework
and the Affiant.Extensions.AI audit walk the tool list at WithAffiant time and refuse every
tool that is not an AIFunction, without asking whether it is write-capable, since a tool the
pipeline never sees is one whose writes it could not gate either way. What is new in beta.3 is the
shape of that refusal, not the fact of it: it now goes through ToolCoverage.Refuse, which emits
one coverage.refused telemetry event per tool and then throws AffiantCoverageException
carrying the protocol’s coverage-refused code. That is a breaking change for a host that used to
catch the plain InvalidOperationException beta.1 threw here — AffiantCoverageException derives
from AffiantRefusalException, which derives from Exception, so the old catch no longer
matches. The audit has an escape hatch, and it records nothing beyond a logged warning and an
acknowledgment span: a tool named in AcknowledgeUncoveredTools (either adapter), or the whole
agent under AllowUnauditableAgent (MAF only, for an agent shape whose tool list cannot be
enumerated at all), is warned about and allowed through — no refusal, no coverage.refused event,
and nothing that touches ToolCoverage or the Docket. Acknowledging a hosted tool does not make
its writes appear on a Docket row; it lets them keep happening where Affiant cannot see them.
The Docket marker — which the host has to switch on. ToolCoverage is a type the host
registers itself (services.AddSingleton<ToolCoverage>()); neither AddAffiantCore nor either
adapter registers one, and no framework code declares a host’s tools for it — the single
DeclareUncovered call site across the ten packages is in Affiant.Testing.ComplianceHarness,
which builds its own ToolCoverage from a conformance fixture’s uncovered list. A host that
declares nothing sees no change. When it is registered and the host has called
DeclareUncovered(toolName, category), a proposal arriving from that tool is still filed:
ReviewGate writes the entry, leaves it ReviewStatus.Pending, and marks it blocked with
BlockedMarker.CoverageRefused(Category, ToolName) — never auto-approved whatever the policy
said, and no decision on it ever accepted (one comes back ReviewOutcome.Refused with
decision-not-pending, the marker’s own code in its Detail). The row and the Evidence Card both
carry a sentence saying why. Category is whichever one the host passed: NoExecute (a
write-capable tool with no execute step for the gate to replace), ProviderExecuted (the model
provider runs the tool on its own side), or HostedMcp (a hosted MCP server-side write) — the
three shapes of write-capable tool CoverageCategory spells, the ones the framework says a gate
cannot stand in front of. They are not a list of every way a mutation can escape review: the write
a tool performs inside its own body, above, is one no category names.
What that second mechanism is for is the write a host knows about and knows the gate cannot stand in front of — a capture arriving from a channel the framework does not sit behind. It is not a way to see a hosted tool: a genuinely provider-executed tool produces no proposal for the gate to file, which is this whole page’s point. The reason a declared tool’s entry is filed at all, rather than dropped, is the same reason: a write that happened outside the gate’s reach should say so on the record, not vanish.
Guidance for architects
Section titled “Guidance for architects”If a value must be sworn — if you need to know, and prove, exactly where a written field came
from before it reaches your database — route that write through a locally-invoked tool.
That’s the only path Affiant’s filters can see, and it’s the path
Rule 3 (write tools never write directly; they produce a
WriteProposal for review) and Rule 7 (every Affidavit field
carries provenance, no exceptions) are built to govern.
Concretely, that leaves you with three honest options when a capability you’d otherwise want is only available as a hosted tool:
- Keep the hosted-tool path read-only. Let the provider’s hosted web search or code interpreter inform the model’s reasoning, but never let it be the thing that writes to your database. Route the actual mutation through a locally-invoked write tool that captures the fields with provenance, even if the values were suggested by something the hosted tool surfaced.
- Wrap the capability behind a local proxy. If you need code-interpreter-like execution or a
specific MCP server’s tools, run the equivalent locally and expose it to your
Kernelas an ordinary plugin instead of consuming the provider’s hosted version of it. The moment it’s invoked by yourKernel, it’s back inside Affiant’s interception surface. - If a hosted write path is unavoidable, treat it as explicitly unsworn. Don’t let a hosted-tool write reach the same downstream systems as reviewed writes without a separate, clearly-labeled reviewed path — a hosted write and a sworn write should never look the same to whoever reads the audit trail afterward.
The point of naming this boundary plainly is that a framework which claims coverage it doesn’t have is worse than no framework at all — an Evidence Card that looks complete but was silently bypassed by a hosted tool is exactly the kind of hollow assurance Affiant exists to prevent.
What Affiant does not claim
Section titled “What Affiant does not claim”Affiant swears to the field. Every value an agent proposes carries where it came from and how confident the proposer was, and before the host writes, a decision is recorded — by a person, or by a Standing Order the host declared, on the record either way. It does not sign or hash-chain the log: the Docket is a durable record of proposals and decisions, not a tamper-evident ledger — if you need cryptographic integrity over the history, keep the Docket in a store that provides it. (A signed, portable export is on the roadmap; it will sign a document, not chain the log.)
Where approval lives
Section titled “Where approval lives”Approval lives in the Docket, not in the conversation. A write is executed only after its Docket
entry is Approved — a durable row the host reads, never a message replayed from a client’s
history, a chat transcript or a framework checkpoint. As of 1.0.0-beta.3 — the conformance
release, which also makes the .NET packages pass the Affiant protocol’s
shared fixture suite — the row records not only the decision but who or what made it: an
Attestation naming a person, a person acting through a relay, or the Standing Order policy that
auto-approved with nobody present. Before this release the row recorded the decision but not its
attestation; see Docket & Evidence Cards
for what the attestation record carries and how the framework enforces that a machine caller can
never mint one in a person’s name.