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 a human
approves it, 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 examples below continue the library-lending domain from that page: a Book (BookId,
Title, IsAvailable), a Patron (PatronId, Name), and a Loan (LoanId, BookId,
PatronId, CheckedOutDate, DueDate, Status) recording a checkout, via a LibraryDbContext.
Worked example: proposing a loan
Section titled “Worked example: proposing a loan”A write tool’s parameters express user intent, 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.SemanticKernel;
public class RequestLoanPlugin(LibraryDbContext dbContext, ILogger<RequestLoanPlugin> logger){ private const int StandardLoanPeriodDays = 21; private const int MaxActiveLoansPerPatron = 5;
[KernelFunction("request_loan")] [Description("Propose a loan of a book to a patron. Returns a WriteProposal for user " + "confirmation 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) { const string toolName = "RequestLoan";
try { var book = await dbContext.Books.AsNoTracking() .FirstOrDefaultAsync(b => b.BookId == bookId, cancellationToken); if (book is null) return new ToolError(toolName, DateTimeOffset.UtcNow, "BOOK_NOT_FOUND", $"No book found with ID {bookId}.", false).ToJsonString();
if (!book.IsAvailable) return new ToolError(toolName, 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(toolName, 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[] { new("BookId", bookId.ToString(), null, ProvenanceChain.From(ProvenanceTag.FromUser("BookId"))), new("PatronId", patronId.ToString(), null, ProvenanceChain.From(ProvenanceTag.FromUser("PatronId"))), new("DueDate", dueDate.ToString("yyyy-MM-dd"), null, ProvenanceChain.From(new ProvenanceTag(ProvenanceSource.Computed, 1.0f, $"Computed: checkout ({checkedOutDate}) + standard loan period ({StandardLoanPeriodDays}d)", null))), };
string[] warnings = activeLoans >= MaxActiveLoansPerPatron ? [$"{patron.Name} already has {activeLoans} active loan(s), at or above the limit of {MaxActiveLoansPerPatron}."] : [];
var affidavit = new Affidavit( OperationType: "create", EntityType: "Loan", EntityId: null, // null = create; non-null = update Fields: fields, AggregateConfidence: 1.0f, Warnings: warnings, RequiresConfirmation: true);
// This is a WriteProposal — no database mutation happens here. return new WriteProposal(toolName, DateTimeOffset.UtcNow, affidavit).ToJsonString(); } catch (Exception ex) when (ex is TimeoutException or DbUpdateException) { return new ToolError(toolName, DateTimeOffset.UtcNow, "DB_TIMEOUT", "Database is temporarily unavailable. Please try again.", Retryable: true).ToJsonString(); } }}BookId and PatronId are tagged ProvenanceTag.FromUser — they came straight from the tool’s
arguments. DueDate has no dedicated factory: Computed and External values are constructed
directly, as new ProvenanceTag(ProvenanceSource.Computed, 1.0f, evidence, null), where
Evidence is what a reviewer reads on the Evidence Card
to understand why, not just what. RequestLoanPlugin never calls SaveChangesAsync — Rule 3
means it doesn’t need to.
A running host also needs [AffiantWriteTool(operation, entityType, typeof(TStrategy))] plus a
matching ITaskInferenceStrategy registered via AddAffiantTool<TStrategy> — required even for
a hand-built Affidavit like this one. Skip it and AffiantStartupValidator throws
AffiantStartupException at boot; see Quickstart for that wiring in full.
This page stays focused on the Affidavit, IFieldMapper<T>, and IWriteExecutor side.
ProvenanceSource at a glance
Section titled “ProvenanceSource at a glance”| 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, not directly stated. | 0.9 via ProvenanceTag.FromTool |
Inferred |
LLM-inferred from conversational signal. | 0.6 via ProvenanceTag.FromInference |
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, the optional AffidavitField flag
this page’s examples leave at its default.
Field mapping: IFieldMapper<T>
Section titled “Field mapping: IFieldMapper<T>”The framework operates on a generic Affidavit — string 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.Models;
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); var fieldDict = affidavit.Fields.ToDictionary(f => f.Name);
foreach (var required in new[] { "BookId", "PatronId", "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}");
// Domain invariant — this is the right layer for domain-level validation. if (dueDate < DateOnly.FromDateTime(DateTime.UtcNow.Date)) throw new ArgumentException("DueDate cannot be in the past");
return new Loan { BookId = bookId, PatronId = patronId, CheckedOutDate = DateOnly.FromDateTime(DateTime.UtcNow.Date), DueDate = dueDate, Status = LoanStatus.Active, }; }
// MapToAffidavit: domain model → Affidavit, for read tools and audit — the reverse // direction, tagged UserStated since it reflects a committed record, not a proposal. public Affidavit MapToAffidavit(Loan entity, string operationType) { ArgumentNullException.ThrowIfNull(entity); var fields = new AffidavitField[] { new("BookId", entity.BookId.ToString(), null, ProvenanceChain.From(ProvenanceTag.FromUser("BookId"))), new("PatronId", entity.PatronId.ToString(), null, ProvenanceChain.From(ProvenanceTag.FromUser("PatronId"))), new("DueDate", entity.DueDate.ToString("yyyy-MM-dd"), null, ProvenanceChain.From(ProvenanceTag.FromUser("DueDate"))), }; return new Affidavit(operationType, "Loan", entity.LoanId == 0 ? null : entity.LoanId.ToString(), fields, AggregateConfidence: 1.0f, 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.
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>();
Write execution: IWriteExecutor
Section titled “Write execution: IWriteExecutor”IWriteExecutor is the one place in the entire system a mutation is allowed to happen:
public interface IWriteExecutor{ Task<string?> ExecuteAsync(Affidavit affidavit, Dictionary<string, object>? amendments, CancellationToken ct);}Nothing in Affiant.Core or Affiant.SemanticKernel calls it for you — after a host observes an
approved review outcome, the host’s own code looks up the DocketEntry and calls ExecuteAsync
with its Affidavit and any reviewer Amendments. 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;
public class LibraryWriteExecutor( LibraryDbContext dbContext, ILoanFieldMapper loanMapper, ILogger<LibraryWriteExecutor> logger) : IWriteExecutor{ public async Task<string?> ExecuteAsync( Affidavit affidavit, Dictionary<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, Dictionary<string, object>? amendments, CancellationToken ct) { // 1. Map the approved Affidavit → domain model via the registered IFieldMapper<T>. var mapped = loanMapper.MapFromAffidavit(affidavit);
// 2. Apply any reviewer amendments (a field the reviewer corrected on approval). var dueDate = ResolveDueDate(mapped.DueDate, amendments);
// 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 = dueDate, Status = LoanStatus.Active, }; dbContext.Loans.Add(loan);
// 4. SaveChanges happens ONLY here — never in the plugin, never in the field mapper. await dbContext.SaveChangesAsync(ct); logger.LogInformation("Loan created: book={BookId} patron={PatronId}", book.BookId, mapped.PatronId); return loan.LoanId.ToString(); }
private static DateOnly ResolveDueDate(DateOnly fromMapper, Dictionary<string, object>? amendments) => amendments?.TryGetValue("DueDate", out var val) == true && DateOnly.TryParse(val?.ToString(), out var amended) ? amended : fromMapper;}services.AddScoped<ILoanFieldMapper, LoanFieldMapper>();services.AddScoped<IWriteExecutor, LibraryWriteExecutor>();ExecuteAsync is documented to raise on failure rather than return a sentinel — the host code
calling it does not retry, unlike a [KernelFunction], where every failure comes 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:
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: // "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}'")}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.
Error handling
Section titled “Error handling”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 raw exception message or stack trace bool Retryable // Whether the framework should retry once, after a backoff) : ToolEnvelope(ToolName, Timestamp);// Lookup failure — non-retryable.if (book is null) return new ToolError(toolName, 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(toolName, DateTimeOffset.UtcNow, "BOOK_NOT_AVAILABLE", $"'{book.Title}' is already on loan.", Retryable: false).ToJsonString();
// Transient database failure — retryable.catch (Exception ex) when (ex is TimeoutException or DbUpdateException){ logger.LogError(ex, "Database error in {ToolName}", toolName); return new ToolError(toolName, 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 |
| Framework retries once after backoff | Framework asks the LLM to handle the error |
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, validation
exceptions to a non-retryable VALIDATION_FAILED, anything else to UNKNOWN — retrying once
before giving up. 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.
Where this fits
Section titled “Where this fits”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.