Skip to content

Authoring Write Tools

Rule 3 of the Seven Normative Rules is the framework’s whole point: write tools never write. A write-intent [KernelFunction] proposes a mutation and stops — it returns a WriteProposal wrapping a fully-sworn Affidavit, and no SaveChanges() call ever appears inside it. This page covers building that proposal, the IFieldMapper<T> bridge back to your domain model, the IWriteExecutor that performs the write once the review flow has approved it — a human reviewer, or a Standing Order a human authored in advance — and the error-handling contract every plugin — read or write — must honor. See Tool Envelopes and Affidavits & Provenance for the full type reference, and Authoring Read Tools for the read-side counterpart.

The code on this page is the Semantic Kernel shape. [KernelFunction], kernelBuilder.Plugins.AddFromType<T>() and AffiantStartupValidator are Semantic Kernel’s and Affiant.SemanticKernel’s; the Microsoft Agent Framework and Microsoft.Extensions.AI adapters discover tools their own way (reflection over a type’s public instance methods, with [AffiantToolName] to override a name) and audit their own coverage boundary at wire-up instead. AffiantWireUpValidator is not their counterpart to AffiantStartupValidator: it is Affiant.Core’s own boot check that the review loop a declared write needs is registered at all, and AddAffiantCore() inserts it on every backend, Semantic Kernel included. What is the same on all three backends is everything the code hands the framework: the three-kind tool return serialized with .ToJsonString(), [AffiantWriteTool], Affidavit, WriteProposal, IFieldMapper<T> and IWriteExecutor. See Microsoft Agent Framework and Microsoft.Extensions.AI for those spellings.

The examples below continue the library-lending domain of Authoring Read Tools: a Book (BookId, Title, Author, Isbn, IsAvailable), a Patron (PatronId, Name), and a Loan (LoanId, BookId, PatronId, CheckedOutDate, DueDate, Status) recording a checkout, via a LibraryDbContext.

A write tool’s parameters carry the model’s reading of what the person wants, while the Affidavit it builds records, field by field, where each proposed value actually came from:

using System.ComponentModel;
using Affiant.Abstractions.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.SemanticKernel;
public class RequestLoanPlugin(IServiceScopeFactory scopeFactory, ILogger<RequestLoanPlugin> logger)
{
// One constant for both: the name the LLM calls, and the name the proposal carries.
// WriteProposal.ToolName is what derives the Docket entry id, what the row records, and
// what the coverage lookup is keyed on — a plugin that spells them differently files
// proposals under a tool name nothing else in the host uses.
public const string FunctionName = "request_loan";
private const int StandardLoanPeriodDays = 21;
private const int MaxActiveLoansPerPatron = 5;
[KernelFunction(FunctionName)]
[Description("Propose a loan of a book to a patron. Returns a WriteProposal for review " +
"before any record is created. Never writes directly.")]
public async Task<string> RequestLoanAsync(
[Description("The book's catalog ID.")] int bookId,
[Description("The patron's membership ID.")] int patronId,
CancellationToken cancellationToken = default)
{
try
{
// LibraryDbContext is Scoped; every SK plugin registered through
// kernelBuilder.Plugins.AddFromType<T>() is a root-cached singleton, so the DbContext
// is resolved from a fresh scope per invocation. Never inject it into the constructor.
using var scope = scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<LibraryDbContext>();
var book = await dbContext.Books.AsNoTracking()
.FirstOrDefaultAsync(b => b.BookId == bookId, cancellationToken);
if (book is null)
return new ToolError(FunctionName, DateTimeOffset.UtcNow, "BOOK_NOT_FOUND",
$"No book found with ID {bookId}.", false).ToJsonString();
if (!book.IsAvailable)
return new ToolError(FunctionName, DateTimeOffset.UtcNow, "BOOK_NOT_AVAILABLE",
$"'{book.Title}' is already on loan.", false).ToJsonString();
var patron = await dbContext.Patrons.AsNoTracking()
.FirstOrDefaultAsync(p => p.PatronId == patronId, cancellationToken);
if (patron is null)
return new ToolError(FunctionName, DateTimeOffset.UtcNow, "PATRON_NOT_FOUND",
$"No patron found with ID {patronId}.", false).ToJsonString();
var activeLoans = await dbContext.Loans.AsNoTracking().CountAsync(
l => l.PatronId == patronId && l.Status == LoanStatus.Active, cancellationToken);
// Due date is derived by deterministic business logic (date math), not stated by
// the user and not guessed by the LLM — a textbook Computed field.
var checkedOutDate = DateOnly.FromDateTime(DateTime.UtcNow.Date);
var dueDate = checkedOutDate.AddDays(StandardLoanPeriodDays);
var fields = new AffidavitField[]
{
// A tool call's arguments are the MODEL's, not the person's: these two were in
// the turn and the model passed them straight through, so they swear Conversation
// through FromInference. UserStated is an observation of a person's act, never the
// host vouching for a value the model handed it.
new("BookId", bookId.ToString(), null, ProvenanceChain.From(
ProvenanceTag.FromInference(InferenceSource.Conversation, "BookId"))),
new("PatronId", patronId.ToString(), null, ProvenanceChain.From(
ProvenanceTag.FromInference(InferenceSource.Conversation, "PatronId"))),
// Sworn, not just used: DueDate's rule names CheckedOutDate as an input, so the
// field has to be on the record for an auditor to resolve the input against it.
new("CheckedOutDate", checkedOutDate.ToString("yyyy-MM-dd"), null,
ProvenanceChain.From(new ProvenanceTag(
ProvenanceSource.Computed,
1.0f,
$"Computed: the date this proposal was made ({checkedOutDate})",
ConversationTurn: null,
Binding: new ProvenanceBinding.ComputationRef(
new ComputationRuleRef(
Rule: "loan.checked_out_date = proposal_date",
Inputs: []))))),
new("DueDate", dueDate.ToString("yyyy-MM-dd"), null,
ProvenanceChain.From(new ProvenanceTag(
ProvenanceSource.Computed,
1.0f,
$"Computed: checkout ({checkedOutDate}) + standard loan period ({StandardLoanPeriodDays}d)",
ConversationTurn: null,
Binding: new ProvenanceBinding.ComputationRef(
new ComputationRuleRef(
Rule: "loan.due_date = checked_out_date + standard_loan_period",
Inputs: ["CheckedOutDate"],
Constant: new ComputationConstantRef(
Source: $"Library lending policy §4: standard loan period is {StandardLoanPeriodDays} days",
VerifiedOn: "2026-09-05")))))),
};
string[] warnings = activeLoans >= MaxActiveLoansPerPatron
? [$"{patron.Name} already has {activeLoans} active loan(s), at or above the limit of {MaxActiveLoansPerPatron}."]
: [];
var affidavit = Affidavit.Create(
// The shape travels in operationType: Operation.IsUpdateShaped recognises
// "WriteUpdate" and bare "update" (case-insensitive) and reads every other verb —
// a host's own included — as create-shaped. entityId must agree with it: named on
// an update, null on a create.
operationType: "WriteCreate",
entityType: "Loan",
entityId: null,
fields: fields,
warnings: warnings,
requiresConfirmation: true);
// This is a WriteProposal — no database mutation happens here.
return new WriteProposal(FunctionName, DateTimeOffset.UtcNow, affidavit).ToJsonString();
}
catch (Exception ex) when (ex is TimeoutException or DbUpdateException)
{
logger.LogError(ex, "Database error in {ToolName}", FunctionName);
return new ToolError(FunctionName, DateTimeOffset.UtcNow, "DB_TIMEOUT",
"Database is temporarily unavailable. Please try again.", Retryable: true).ToJsonString();
}
}
}

Why the scope factory, and not the DbContext itself. kernelBuilder.Plugins.AddFromType<T>() — the registration that puts a plugin type in front of the model — registers the KernelPlugin as a DI singleton and builds the plugin instance once, from the root provider; that registration path has no per-invocation plugin lifetime. Constructor-injecting a Scoped service such as a DbContext into a plugin registered that way is therefore always a captive dependency, not a risk that depends on anything else in the wiring. Where it bites is Kernel construction rather than the first tool call: resolving the Kernel constructs the singleton KernelPlugins and with them the plugin instance, and the plugin’s Scoped constructor dependency is resolved from the root provider there. Under ServiceProviderOptions.ValidateScopes (on by default in ASP.NET Core’s Development environment) that resolution throws InvalidOperationException: Cannot resolve scoped service … from root provider, and it throws at startup, because AddAffiantSemanticKernel’s AffiantStartupValidator resolves the Kernel in StartAsync before any turn runs. That validator injects an IServiceScopeFactory and asks a scope it creates, not the root — and the throw still lands, because the singleton KernelPlugin and the plugin instance inside it are built by the root provider whichever scope asks for the Kernel. With validation off nothing throws and the plugin holds the root container’s DbContext — never any request scope’s — for the process lifetime, shared unsynchronized across every concurrent conversation. A per-scope plugin instance is reachable, but not from the builder: a host that constructs the plugin itself with KernelPluginFactory.CreateFromType<T>(name, provider) from a scope’s provider and adds it to a Kernel it builds per scope gets one, and owns that kernel’s lifetime in exchange. Resolving per invocation through IServiceScopeFactory, as above, is the pattern for the ordinary registration; direct constructor injection of a Scoped dependency is the anti-pattern, worth calling out in review like any other captive-dependency bug.

A tool call’s arguments are the model’s, not the person’s. bookId and patronId are what the model wrote into the call after reading the conversation, so the honest grade for them is Conversation: ProvenanceTag.FromInference(InferenceSource.Conversation, …), whose default confidence is 0.6 and whose note reads Literally present in the turn: {field} — the field name the call passed is appended to the sentence. Where the model reasoned to a value rather than reading it off the turn — a title resolved to a catalog id, say — InferenceSource.Inferred is the grade (note Inferred from the turn: {field}), and those are the only two an inference may claim. UserStated is documented as an observation of a person’s act — an utterance span, a form input, a reviewer’s amendment or prefill — never the host vouching for a value it produced itself, and FromTool is ruled out here too: its own remarks say it is for a value extracted from a deterministic tool result, not for the arguments a model passes to a write tool. Swear UserStated only where a person really supplied the value, and then say where: as of 1.0.0-beta.3, ProvenanceTag.FromUser requires a second argument naming the artifact the claim rests on — a ProvenanceBinding.UtteranceSpan, a ProvenanceBinding.FormInput or a ProvenanceBinding.ReviewerAct (see Affidavits & Provenance). null is accepted there for a caller with genuinely nothing to point at, and an unbound UserStated tag is recorded exactly as claimed — but it is the weakest form of the strongest grade, and a policy is entitled to refuse to rest on it. CheckedOutDate and DueDate have no dedicated factory: Computed and External values are constructed through ProvenanceTag’s own constructor. Evidence — the third positional argument, note on the wire — is what a reviewer reads on the Evidence Card to understand why, not just what; the card element vendored into the quickstart sample at 1.0.0-beta.3 still reads tag.evidence and so renders nothing for it, and a client written against the v0.1 wire reads tag.note.

A Computed tag should be bound. ProvenanceTag.RequiresBinding returns true for the three grades above ConversationUserStated, External and Computed — because each claims an artifact outside the conversation, and a claim with no pointer at that artifact is not checkable. For a computed value the binding is a ProvenanceBinding.ComputationRef wrapping a ComputationRuleRef, which names the re-runnable rule, the field names it consumed in order, and optionally the constant it applied and when that constant was last verified. That is why the DueDate tag above carries one where the two conversation-graded tags carry none: RequiresBinding is false at and below Conversation because the turn is itself the artifact, while a date-math rule is an artifact outside the conversation and has to be pointed at. Inputs holds field names, so every name in it should be a field the Affidavit actually proposes — that is why CheckedOutDate is sworn as its own field rather than kept as a local: the rule names it, and an auditor re-running the rule has to be able to resolve that input against the record. RequestLoanPlugin never calls SaveChangesAsync — Rule 3 means it doesn’t need to.

A running host still has to declare the tool: a descriptor in the framework’s registry naming the operation, the entity type and an ITaskInferenceStrategy — required even for a hand-built Affidavit like this one. Declare nothing and AffiantStartupValidator — the Semantic Kernel adapter’s own boot check — throws AffiantStartupException for every [KernelFunction] the registry has never heard of. There are two ways to declare one. services.AddAffiantTool<TStrategy>(functionName, operation, entityType, pluginName) registers the strategy and the descriptor in one call — that is how the quickstart host declares both its write tools. [AffiantWriteTool(operation, entityType, typeof(TStrategy))] on the method is the declarative form, read by the Semantic Kernel plugin walker (AddAffiantPluginsFromType<T>() and AddAffiantPluginsFromAssembly) and by the MAF and M.E.AI tool catalogs as they build their descriptors. Plain kernelBuilder.Plugins.AddFromType<T>() — the registration this page’s block uses — does not read the attribute, so on that path the explicit AddAffiantTool call is what makes a write a write. See Quickstart for that wiring in full. This page stays focused on the Affidavit, IFieldMapper<T>, and IWriteExecutor side.

Source Meaning Default confidence
UserStated The user explicitly stated this value. 1.0 via ProvenanceTag.FromUser
External Fetched from an authoritative external system. no factory — construct directly
Computed Derived by deterministic business logic. no factory — construct directly
Conversation Mentioned in a tool result, or literally present in the turn. 0.9 via ProvenanceTag.FromTool; 0.6 via FromInference(InferenceSource.Conversation, …)
Inferred LLM-inferred from conversational signal. 0.6 via FromInference(InferenceSource.Inferred, …)
Default System default or fallback. 0.3 via ProvenanceTag.FromDefault
Empty Provenance unknown — tag explicitly, never omit. 0.0 via ProvenanceTag.Empty

See Affidavits & Provenance for the full determinism hierarchy and confidence-tie merge rule, and for IsMandatory — one of four optional AffidavitField parameters (Kind, AllowedValues and Pattern are the others) that this page’s four-argument new(...) calls all leave at their defaults. What IsMandatory does is block a Standing Order: when a mandatory field’s tag in force is Empty, StandingOrderGuardrails.Apply degrades the verdict to ReviewerConfirmation with the blocked reason mandatory-field-empty. It has another effect, on the amendment fold: a cleared mandatory field stays on the record tagged Empty, where a cleared optional one leaves the field list altogether (below). It does not force a value in, and it does not stop a human reviewer approving.

The framework operates on a generic Affidavitstring field names, object? values. Your domain model is strongly typed. IFieldMapper<T> bridges the two directions:

public interface IFieldMapper<T>
{
T MapFromAffidavit(Affidavit affidavit);
Affidavit MapToAffidavit(T entity, string operationType);
}
using Affiant.Abstractions.Interfaces;
using Affiant.Abstractions.Models;
using Microsoft.Extensions.Logging;
public interface ILoanFieldMapper : IFieldMapper<Loan> { }
public class LoanFieldMapper(ILogger<LoanFieldMapper> logger) : ILoanFieldMapper
{
// MapFromAffidavit: Affidavit (framework type) → domain model, used by IWriteExecutor.
public Loan MapFromAffidavit(Affidavit affidavit)
{
ArgumentNullException.ThrowIfNull(affidavit);
logger.LogDebug("Mapping a {OperationType} affidavit with {FieldCount} sworn field(s)",
affidavit.OperationType, affidavit.Fields.Length);
var fieldDict = affidavit.Fields.ToDictionary(f => f.Name);
foreach (var required in new[] { "BookId", "PatronId", "CheckedOutDate", "DueDate" })
if (!fieldDict.ContainsKey(required))
throw new InvalidOperationException($"Affidavit missing required field '{required}'");
// AffidavitField.Value is object?, so cast/parse explicitly with TryParse variants.
if (!int.TryParse(fieldDict["BookId"].Value?.ToString(), out var bookId))
throw new FormatException($"Cannot parse BookId: {fieldDict["BookId"].Value}");
if (!int.TryParse(fieldDict["PatronId"].Value?.ToString(), out var patronId))
throw new FormatException($"Cannot parse PatronId: {fieldDict["PatronId"].Value}");
if (!DateOnly.TryParse(fieldDict["DueDate"].Value?.ToString(), out var dueDate))
throw new FormatException($"Cannot parse DueDate: {fieldDict["DueDate"].Value}");
// Read from the sworn record, never re-read from the clock: DueDate's ComputationRuleRef
// binds it to the checkout date the proposal computed, so a commit that substituted
// today's date would break the rule the reviewer approved.
if (!DateOnly.TryParse(fieldDict["CheckedOutDate"].Value?.ToString(), out var checkedOutDate))
throw new FormatException($"Cannot parse CheckedOutDate: {fieldDict["CheckedOutDate"].Value}");
// Domain invariant — this is the right layer for domain-level validation.
if (dueDate < checkedOutDate)
throw new ArgumentException("DueDate cannot precede CheckedOutDate");
return new Loan
{
BookId = bookId, PatronId = patronId,
CheckedOutDate = checkedOutDate,
DueDate = dueDate, Status = LoanStatus.Active,
};
}
// MapToAffidavit: domain model → Affidavit, for read tools and audit — the reverse
// direction. Every value here was read out of the host's own store, so every field swears
// External bound to the record it was read from. UserStated is an observation of a person's
// act — an utterance, a form input, a reviewer's amendment — never the host vouching for a
// value it produced itself.
public Affidavit MapToAffidavit(Loan entity, string operationType)
{
ArgumentNullException.ThrowIfNull(entity);
var storedRecord = new ProvenanceBinding.ExternalRef(new ExternalRecordRef(
System: "LibraryDb",
RecordId: $"Loan/{entity.LoanId}",
FetchedAt: DateTimeOffset.UtcNow));
ProvenanceChain ReadFromRecord(string field) => ProvenanceChain.From(new ProvenanceTag(
ProvenanceSource.External,
0.95f,
$"Read from the stored loan record: {field}",
ConversationTurn: null,
Binding: storedRecord));
// The same four fields MapFromAffidavit requires: a mapper has to be able to read back
// what it wrote, and CheckedOutDate is a field the DueDate rule names as an input.
var fields = new AffidavitField[]
{
new("BookId", entity.BookId.ToString(), null, ReadFromRecord("BookId")),
new("PatronId", entity.PatronId.ToString(), null, ReadFromRecord("PatronId")),
new("CheckedOutDate", entity.CheckedOutDate.ToString("yyyy-MM-dd"), null,
ReadFromRecord("CheckedOutDate")),
new("DueDate", entity.DueDate.ToString("yyyy-MM-dd"), null, ReadFromRecord("DueDate")),
};
// The entity id follows the operation's shape, never the other way round: named on an
// update-shaped operationType, null on anything else. Operation.IsUpdateShaped is the
// framework's own test, so a host verb stays a host verb and the shape stays the protocol's.
return Affidavit.Create(operationType, "Loan",
Operation.IsUpdateShaped(operationType) ? entity.LoanId.ToString() : null,
fields, warnings: [], requiresConfirmation: false);
}
}

Field names in Affidavit.Fields must exactly match the string keys MapFromAffidavit looks up — they’re case-sensitive, and a mismatch surfaces as InvalidOperationException at commit time, not compile time. A shared constant or enum for field names avoids this class of error entirely. The two directions have to agree on the set as well as the spelling: MapToAffidavit swears the four fields MapFromAffidavit requires, so the mapper can consume its own output.

The reverse direction is External, not UserStated. ProvenanceSource.External is the grade for a value fetched from an authoritative system — an API lookup, a database read — while ProvenanceSource.UserStated is documented as an observation of a person’s act, never the host vouching for a value it produced itself. A value read back out of the host’s own store is therefore External, and it has exactly what RequiresBinding asks of a grade above Conversation: a ProvenanceBinding.ExternalRef naming the system, the record id and when it was read. The confidence is the producer’s own claim — External has no factory to supply a default — and the 0.95 above follows the quickstart sample’s projection, which swears a value read from the host’s database at that number.

MapToAffidavit’s operationType carries the protocol’s shape as well as the host’s own verb. Operation.IsUpdateShaped reads only "WriteUpdate" and bare "update" — case-insensitively — as update-shaped; every other string, a host verb like "RenewLoan" included, is create-shaped. The schema-driven projection refuses a mismatch in either direction with an ArgumentException: an entity id passed with a create-shaped operation, or an update-shaped one with no entity id.

Marker interfaces are optional. ILoanFieldMapper adds nothing but a name — useful only so a constructor injecting several mappers reads clearly. If an executor accepts IFieldMapper<T> directly, skip the marker interface and register against the generic type: services.AddScoped<IFieldMapper<Reservation>, ReservationFieldMapper>();

IWriteExecutor is the one place an approved Affidavit becomes a real mutation in the host’s own system of record — a domain write. The framework’s own stores do write their own rows (Affiant.EntityFramework saves Docket entries and chat sessions); what no framework code does is write your domain data. Two writes sit outside that guarantee, and the framework says so rather than implying a coverage it does not have: a host that calls ExecuteAsync itself without going through ReviewGate, and a tool that opens its own connection and writes inside its own body — the gate’s filter runs after the tool body, because that is the only seam that sees the tool’s result. A pre-body seam does exist — InferenceTriggerFilter and DeterministicShortCircuit run there, before the body — but no seam at any position can see a write the body performed inside itself, so no filter and no wire-up check catches that one. What the framework does guarantee is that such a tool cannot commit through it: the gate never calls a write tool’s own execute, and a tool the registry declares write-capable that hands back something other than a proposal is refused rather than waved past — a null or empty result excepted, which the gate’s filter returns on before it ever consults the registry, so that one passes through unrefused. The interface is one method:

public interface IWriteExecutor
{
Task<string?> ExecuteAsync(
Affidavit affidavit, IReadOnlyDictionary<string, object?>? amendments, CancellationToken ct);
}

Nothing in the framework calls it for you, on any backend — after a host observes an approved review outcome, the host’s own code looks up the DocketEntry and calls ExecuteAsync with entry.AmendedAffidavit ?? entry.Envelope and entry.Amendments. AmendedAffidavit is the record with the corrections already folded in: the reviewer’s values, a tag appended on top of each amended field’s chain, and all three confidence numbers recomputed. The appended tag is UserStated at confidence 1 where the reviewer set a value and Empty at confidence 0 where they cleared one — a cleared field cannot have confidence in a value it no longer has. Both of those tags carry the same ReviewerAct binding naming the entry and the instant. A cleared optional field is the exception on Apply: it leaves the field list altogether, before any tag is minted, so the reviewer’s act on it shows up in entry.Amendments rather than on the record’s chains. An executor reads that record straight and must not fold the amendments a second time. AffidavitAmendments.Apply is the one implementation of that fold, and an executor that redoes it gets a different answer from the one the reviewer approved. What entry.Amendments is for is seeing what changed: an audit line, a diff in a log. Its values are nullable and that is load-bearing there: a key present with a null value means the reviewer cleared that field, which is not the same as the key being absent. See Review Gate & Write Executors for that hand-off in full, and Docket & Evidence Cards for where Amendments comes from.

using Affiant.Abstractions.Interfaces;
using Affiant.Abstractions.Models;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
public class LibraryWriteExecutor(
LibraryDbContext dbContext,
ILoanFieldMapper loanMapper,
ILogger<LibraryWriteExecutor> logger) : IWriteExecutor
{
public async Task<string?> ExecuteAsync(
Affidavit affidavit, IReadOnlyDictionary<string, object?>? amendments, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(affidavit);
// Route to the correct domain handler by EntityType. Add a case here for each
// new writable entity — see "Adding a new entity type" below.
return affidavit.EntityType switch
{
"Loan" => await ExecuteLoanAsync(affidavit, amendments, ct),
_ => throw new NotImplementedException($"No executor for entity type '{affidavit.EntityType}'")
};
}
private async Task<string?> ExecuteLoanAsync(
Affidavit affidavit, IReadOnlyDictionary<string, object?>? amendments, CancellationToken ct)
{
// 1. Map the approved Affidavit → domain model via the registered IFieldMapper<T>.
var mapped = loanMapper.MapFromAffidavit(affidavit);
// 2. NOT here: the amendments are already folded in. The caller passed
// entry.AmendedAffidavit ?? entry.Envelope, and AffidavitAmendments.Apply produced
// that record — values, provenance and all three confidence numbers. Folding them a
// second time would give a different answer from the one the reviewer approved.
// `amendments` is here for a host that needs to SEE what changed (an audit line, a
// diff in a log), never to re-apply it.
// 3. Re-validate business invariants at commit time — the proposal may be stale.
var book = await dbContext.Books.FirstOrDefaultAsync(b => b.BookId == mapped.BookId, ct)
?? throw new InvalidOperationException($"Book {mapped.BookId} not found");
if (!book.IsAvailable)
throw new InvalidOperationException($"Book {mapped.BookId} is no longer available");
book.IsAvailable = false;
var loan = new Loan
{
BookId = book.BookId, PatronId = mapped.PatronId,
CheckedOutDate = mapped.CheckedOutDate, DueDate = mapped.DueDate, Status = LoanStatus.Active,
};
dbContext.Loans.Add(loan);
// 4. The domain write happens ONLY here — never in the plugin, never in the field mapper.
await dbContext.SaveChangesAsync(ct);
logger.LogInformation(
"Loan created: book={BookId} patron={PatronId} ({DueDate})",
book.BookId, mapped.PatronId, DescribeAmendment("DueDate", amendments));
return loan.LoanId.ToString();
}
// Reporting only — never used to change what gets written. A key present with a null value
// is a CLEAR; a key that is absent means the reviewer left the field alone. Keeping the two
// apart is what makes an audit line honest.
private static string DescribeAmendment(
string field, IReadOnlyDictionary<string, object?>? amendments) =>
amendments is null || !amendments.TryGetValue(field, out var val)
? $"{field}: unchanged"
: val is null ? $"{field}: cleared by the reviewer"
: $"{field}: set to '{val}' by the reviewer";
}
services.AddScoped<ILoanFieldMapper, LoanFieldMapper>();
services.AddScoped<IWriteExecutor, LibraryWriteExecutor>();

ExecuteAsync is documented to raise on failure rather than return a sentinel — nothing in the framework calls it, so nothing in the framework retries it, unlike a [KernelFunction], whose failures come back as a ToolError instead (below).

Adding a new entity type to an existing IWriteExecutor

Section titled “Adding a new entity type to an existing IWriteExecutor”

Say the library host later adds Reservation (reserving a currently unavailable book) to the same executor. Appending a required constructor parameter breaks tests that construct LibraryWriteExecutor directly — the fix is a backward-compatible overload alongside the new dependency, plus one more switch case. The block below shows only the members that change — the primary constructor, the compatibility overload and ExecuteAsync; ExecuteLoanAsync, ExecuteReservationAsync and DescribeAmendment are elided, as is ReservationFieldMapperLoanFieldMapper’s counterpart for the new entity, which the compatibility overload constructs and the registration below names — and the using list is the one above plus Microsoft.Extensions.Logging.Abstractions for NullLogger<T>:

public class LibraryWriteExecutor(
LibraryDbContext dbContext,
ILoanFieldMapper loanMapper,
IFieldMapper<Reservation> reservationMapper, // ← new dependency
ILogger<LibraryWriteExecutor> logger) : IWriteExecutor
{
// Tests that call new LibraryWriteExecutor(db, loanMapper, logger) still compile.
public LibraryWriteExecutor(LibraryDbContext dbContext, ILoanFieldMapper loanMapper, ILogger<LibraryWriteExecutor> logger)
: this(dbContext, loanMapper, new ReservationFieldMapper(NullLogger<ReservationFieldMapper>.Instance), logger) { }
// ExecuteAsync's switch grows one case:
public async Task<string?> ExecuteAsync(
Affidavit affidavit, IReadOnlyDictionary<string, object?>? amendments, CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(affidavit);
return affidavit.EntityType switch
{
"Loan" => await ExecuteLoanAsync(affidavit, amendments, ct),
"Reservation" => await ExecuteReservationAsync(affidavit, amendments, ct), // ← new
_ => throw new NotImplementedException(
$"Write executor does not support entity type '{affidavit.EntityType}'")
};
}
// ExecuteLoanAsync is unchanged from the block above; ExecuteReservationAsync is its
// counterpart for the new entity — map, re-validate, save. Neither folds the amendments:
// the caller already passed the folded record.
}

Register the new mapper — the executor itself is already registered against IWriteExecutor: services.AddScoped<IFieldMapper<Reservation>, ReservationFieldMapper>();

After adding Reservation’s DbSet<T>, generate a migration: dotnet ef migrations add AddReservation --project <your-host-project>. Review it before applying — EF’s model snapshot accumulates every pending change, so it may carry drift from an earlier, uncommitted edit; don’t hand-edit the generated designer files.

Every plugin — read or write — must catch its own exceptions and return a ToolError, never let one propagate to the LLM:

public sealed record ToolError(
string ToolName,
DateTimeOffset Timestamp,
string Code, // Machine-readable, e.g. "BOOK_NOT_AVAILABLE"
string Message, // Human-readable, never a stack trace
bool Retryable // Whether a second attempt could succeed — see the note below the table
) : ToolEnvelope(ToolName, Timestamp);

That is the whole record — five members, no more. The classification pattern is the body of RequestLoanAsync above, seen on its own: the same FunctionName constant, the same logger, the same parameters, with everything after the book lookup and its two checks folded into a private BuildLoanProposalAsync — the one identifier below that is not already the plugin’s — so the shape of the classification stands clear of the proposal it builds.

try
{
using var scope = scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<LibraryDbContext>();
var book = await dbContext.Books.AsNoTracking()
.FirstOrDefaultAsync(b => b.BookId == bookId, cancellationToken);
// Lookup failure — non-retryable.
if (book is null)
return new ToolError(FunctionName, DateTimeOffset.UtcNow,
"BOOK_NOT_FOUND", $"No book found with ID {bookId}.", Retryable: false).ToJsonString();
// Business-rule validation — non-retryable.
if (!book.IsAvailable)
return new ToolError(FunctionName, DateTimeOffset.UtcNow,
"BOOK_NOT_AVAILABLE", $"'{book.Title}' is already on loan.", Retryable: false).ToJsonString();
// … the rest of the body, shown in full in the RequestLoanPlugin block above: the patron
// lookup, the AffidavitField[], the Affidavit.Create call and the WriteProposal return …
return await BuildLoanProposalAsync(dbContext, book, patronId, cancellationToken);
}
// Transient database failure — retryable.
catch (Exception ex) when (ex is TimeoutException or DbUpdateException)
{
logger.LogError(ex, "Database error in {ToolName}", FunctionName);
return new ToolError(FunctionName, DateTimeOffset.UtcNow, "DB_TIMEOUT",
"Database is temporarily unavailable. Please try again.", Retryable: true).ToJsonString();
}
Retryable: true Retryable: false
Transient: DB timeout, connection drop, rate limit Permanent: not found, validation error, business rule violation
ToolErrorFilter retries the tool body once for the failures it classifies itself The error goes back for the LLM to handle

The flag on a ToolError your own code returns is a statement, not a trigger: that result travels straight back to the model, and no framework code re-invokes the tool on the strength of it — the tracing filter copies the flag onto the affiant.tool_error event it adds to the span, and acts on nothing. The framework’s one retry is ToolErrorFilter’s, on the exceptions it classifies itself.

As a safety net, AddAffiantCore() also registers ToolErrorFilter, which wraps every plugin invocation and converts an uncaught exception into a ToolError automatically — TimeoutException and EF Core’s DbUpdateException map to a retryable DB_TIMEOUT; an HttpRequestException carrying a 503 ServiceUnavailable maps to a retryable UPSTREAM_UNAVAILABLE; ArgumentException and InvalidOperationException map to a non-retryable VALIDATION_FAILED; everything else falls to a non-retryable UNKNOWN. A retryable classification re-invokes the tool body once, immediately — the filter has no delay and no backoff of any kind, so a host that needs one adds it inside its own tool — and only where the filter’s own next() is the tool body. At Semantic Kernel’s completion-stage seam, where next() is SK’s auto-invocation continuation rather than the tool, retrying would genuinely execute the tool twice, so the ToolError is surfaced there with no retry. That backstop exists so a forgotten catch doesn’t leak a stack trace into the LLM’s context; it is not a substitute for naming the errors you already know can happen.

Anti-patterns worth naming: throwing from a plugin instead of returning ToolError; a generic "Error" message with no Code; swallowing an exception silently; and returning ToolError for an operation that partially succeeded, when the message should say exactly what did and didn’t happen.

WriteProposal and Affidavit are covered in full in Tool Envelopes and Affidavits & Provenance. What happens after a write tool returns one — ReviewGate, approval policies, the Evidence Card a reviewer sees — is covered in Review Gate & Write Executors and Docket & Evidence Cards. Once IWriteExecutor is wired up, The Compliance Harness covers proving your provenance is substantive, not just correctly shaped.