Skip to content

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.

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 your Kernel (or, on the other two backends, any method reflected into an AffiantToolCatalog), 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 Kernel as plugins, the standard Semantic Kernel pattern for consuming MCP tools. Once imported, an MCP-backed tool is an ordinary KernelFunction as far as the Kernel is 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’s Kernel is 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 service AddAffiantSemanticKernel registers, walks every plugin on the Kernel and throws AffiantStartupException at start-up for any [KernelFunction] the tool registry has never heard of, so each imported function needs a services.AddAffiantTool<TStrategy>(...) or services.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 a WriteProposal, 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 of 1.0.0-beta.3. On its own model surface there is exactly one — CoverageCategory.HostedMcp, wire spelling hosted-mcp, declared with that spelling on the enum in Affiant.Abstractions, written out by hand in Affiant.Core (ToolCoverage.Spell, which spells it and never parses it), and both spelled and parsed by hand in Affiant.EntityFramework and the compliance harness, which also reads a hostedMcp flag 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 ordinary AIFunction, the hosted-tool audit passes it because it is one, and the adapter wraps it like any other.

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

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:

  1. 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.
  2. 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 Kernel as an ordinary plugin instead of consuming the provider’s hosted version of it. The moment it’s invoked by your Kernel, it’s back inside Affiant’s interception surface.
  3. 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.

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

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.