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

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.

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.

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

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.

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.

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.