Skip to content

Quickstart

At the end of this page you have a host running on your machine, a browser tab open on it, and an Evidence Card on that tab: a leave request an agent proposed, one row per field, each row naming where its value came from and how much confidence stands behind it. You approve it — or amend a field first, or reject it — and only then does a row appear in the database. About ten minutes.

There are two ways to get to that card, and everything from the card onwards is identical either way.

  • With a model key. Semantic Kernel talks to any OpenAI-compatible endpoint. Set OPENAI_API_KEY (and OPENAI_BASE_URL if it isn’t OpenAI’s own), type a sentence, and the model calls the write tool.
  • Without one. The host carries a development-only seam that files the same proposal with one curl. The affidavit is built by the same code and filed through the same review gate; the only step skipped is a model deciding to call the tool, and none of the review behaviour depends on that step.

Every C# block below is copied from samples/quickstart-host in the framework repository — this page as a program you can run; the shell and npm commands are this page’s own. Those blocks are trimmed to the lines under discussion and each one names the file it came from — bar three short blocks lifted from Program.cs — so you can open the whole thing whenever the trim hides something you want. If you would rather read it than type it, clone it and skip to step 10:

Terminal window
git clone https://github.com/Sakwala/affiant.git
ASPNETCORE_ENVIRONMENT=Development ASPNETCORE_URLS=http://localhost:5077 \
dotnet run --project affiant/samples/quickstart-host

The .NET 10 SDK, an ASP.NET Core project (dotnet new web), and five packages:

Terminal window
dotnet add package Affiant.Core --version 1.0.0-beta.3
dotnet add package Affiant.SemanticKernel --version 1.0.0-beta.3
dotnet add package Affiant.EntityFramework --version 1.0.0-beta.3
dotnet add package Affiant.Docket --version 1.0.0-beta.3
dotnet add package Affiant.Transport.SignalR --version 1.0.0-beta.3

1.0.0-beta.3 is the conformance release and the current version; --prerelease in place of the pin resolves to it. Pin all of them to the same version, explicitly. Each Affiant package depends on the others by a minimum version rather than an exact one (version="1.0.0-beta.3", not [1.0.0-beta.3]), so a mixed set is reachable: leaving Affiant.Core behind a newer adapter fails restore with NU1605, while leaving the adapter behind a newer Core restores and builds without a word.

All ten Affiant packages ship under one version — see Installation for the full set and what each is for. Nothing else is needed to compile what follows: Semantic Kernel and its OpenAI connector arrive transitively with Affiant.SemanticKernel, and EF Core’s SQLite provider with Affiant.EntityFramework.

The flow being built is the one in Why Affiant and Affidavits & Provenance: a request_leave tool that proposes a LeaveRequest rather than writing one, each field individually tagged with where it came from, plus an amend_leave tool that changes an existing row so the reviewer can see the values it would replace.

AddAffiantCore wires the tool descriptor registry, the ContextFabric, the policy evaluator, the ReviewGate, the UI guidance bridge and the deterministic pre-tool filters. AddAffiantSemanticKernel adds the Semantic Kernel adapter — the startup validator and the post-tool filter pair (TaskInferenceMergeFilter, ReviewGateFilter) that intercept a write tool’s result.

From Program.cs:

builder.Services.AddHttpContextAccessor();
builder.Services.AddAffiantCore();
builder.Services.AddAffiantSemanticKernel();

Neither call registers a database, a review transport, or an approval policy — separate adapter packages, added next. Note what AddAffiantCore does not register: no IApprovalPolicy. ApprovalPolicyEvaluator’s built-in fallback returns ReviewRequirement.ReviewerConfirmation whenever no policy answers, which is the always-ask-a-human default this walkthrough relies on. You don’t need Affiant.Policies/AddAffiantPolicies unless you want Standing Orders (auto-approval) or Referrals (escalation) — see Packages.

AddAffiantCore does register AffiantWireUpValidator, and at 1.0.0-beta.3 that validator refuses a host that cannot run the review loop it has declared — at startup, before any turn, naming the fix for each. No IStreamingTransport and no IDocketStore: a review with nowhere to go and no queue to sit in. No IReviewContextProvider, no ReviewGate or no IDecisionAuthorizationPolicy where any declared tool is write-capable: a review loop in which no proposal can be routed, filed or decided. No IPreviousValueSource where a declared tool is update-shaped: an update affidavit swears to what each field replaces, and only the host’s system of record knows that. amend_leave below is an update-shaped write tool, so this walkthrough owes the framework both of the last two — IPreviousValueSource in step 4, IDecisionAuthorizationPolicy in step 7.

The two are not owed on equal terms, and it is worth knowing which is which. AffiantCoreOptions.AcknowledgeMissingReviewWiring exists for a host deliberately running the read and inference half with no review loop. It never waives the review-wiring set — IReviewContextProvider, ReviewGate, IDecisionAuthorizationPolicy, and any policy that declares a risk ceiling with no scorer — because a host that has declared a write-capable tool is, by its own declaration, not that host. Everything outside that set, IPreviousValueSource included, is downgraded to a logged warning while the review-wiring set is empty; the framework’s own test pins that (PreviousValueSourceWireUpTests.TheRefusalIsAcknowledgeable_LikeEveryOtherMissingContract). This walkthrough sets no acknowledgment and registers both ports, so both refusals stand.

This walkthrough also skips AddAffiantSemanticKernel()’s companion call, AddAffiantInferenceOrchestration(). That call wires the pre-tool filters that let a write tool’s Affidavit honestly carry ProvenanceSource.Inferred for a field the tool call didn’t receive directly — powering the Inferred row of the determinism hierarchy in Affidavits & Provenance. It’s safe to skip only because every field below arrives either straight off the tool call’s own arguments or out of the host’s own database — nothing is left for the model to infer. Almost every real host needs at least one inferred field somewhere and should add the call. No startup validator catches its absence, and that is deliberate: a host that swears its own fields — which is exactly what step 4’s builder does — is a supported wiring, not a misconfiguration. What is caught is the consequence, at run time: a field nothing has sworn for is sworn ProvenanceSource.Empty at confidence 0, and a proposal in which nothing swears for anything is refused at the gate (substance-refused) rather than put in front of a reviewer.

The same call is also what registers ToolArgumentCaptureFilter, so its 1.0.0-beta.3 behaviour — recording a model’s tool argument as a value the model proposes, minting no tag for it, on the grounds that what the model wrote says nothing about where the value came from — does not run in this wiring either. A host that adds AddAffiantInferenceOrchestration() gets it, and gets the same Empty-at-0 outcome for a field neither a deterministic interceptor nor its inference port speaks for.

2. Register persistence and the review transport

Section titled “2. Register persistence and the review transport”

From Program.cs:

builder.Services.AddAffiantEntityFramework(o => o.UseSqlite("Data Source=affiant-quickstart.db"));
builder.Services.AddAffiantDocket(o => o.UseInMemory());
builder.Services.AddAffiantSignalR<ChatHub>();
// The sample's own domain database — separate from Affiant's.
builder.Services.AddDbContext<HrDbContext>(o => o.UseSqlite("Data Source=hr-quickstart.db"));

The order of the first two calls decides which IDocketStore wins. Affiant.Docket and Affiant.EntityFramework are peers, not a chain. AddAffiantEntityFramework(o => o.UseSqlite(...)) gives you AffiantDbContext, an IChatSessionStore, and — on this SQLite branch — a SqliteDocketStore registered as IDocketStore. The very next line, AddAffiantDocket(o => o.UseInMemory()), registers a second, competing IDocketStore, and because it is registered after the SQLite one it is the one every GetRequiredService<IDocketStore>() resolves — .NET’s container returns the last registration for a single-instance resolution. AddAffiantDocket is what registers the shipped expiry sweep (DocketExpiryService, registered by that call whichever store is selected), so a host that wants the sweep calls it either way; a host that would rather schedule ExpireDueAsync on its own cadence — a serverless deployment, a cron entry, a queue worker — leaves it out and drives expiry itself.

That combination is deliberate here: an in-memory Docket hands an approved Affidavit back as the same CLR objects the proposal built, rather than values that have round-tripped through JSON, which keeps the write executor in step 8 readable — while step 9 still gets a real AffiantDbContext to migrate. A host that needs review state to survive a restart drops the o => o.UseInMemory() argument and calls bare AddAffiantDocket(), letting the SQLite registration stand as the one and only IDocketStore — see Docket & Evidence Cards for that cleaner shape. That swap costs less at 1.0.0-beta.3 than it did. The round trip still happens, but AffidavitFieldValues — the store-boundary converter added in this release — reads a filed affidavit’s values, previous values and amendment maps back as CLR values rather than raw JsonElements. It converts by each field’s declared kind, so a date field’s stored text comes back a DateTimeOffset and a number field’s a decimal — not necessarily the same CLR type the projection put in. An executor that reads field.Value should say what it expects rather than assume the string it filed. Authoring Write Tools covers the shape either way.

AddAffiantSignalR<THub> requires a concrete hub deriving from AffiantHub; ChatHub is written in step 8. It also registers the IStreamingTransport that ReviewGate broadcasts Evidence Cards over. HrDbContext is the host’s own domain database — domain data and framework data never share a context.

ITaskInferenceStrategy is the contract a write tool’s domain declares so the framework knows which fields exist, what type each one is, what values are legal, and which are mandatory. One strategy serves both write tools here, because both propose the same entity’s fields. Two comments are trimmed below: the class-level <summary>, and all but the first sentence of EntityIdField’s — the fact that one carries, why a row id cannot travel on EntityRef.EntityId, is stated in prose at the end of step 4.

From Agent/LeaveTaskInferenceStrategy.cs:

public sealed class LeaveTaskInferenceStrategy : ITaskInferenceStrategy
{
/// <summary>The <c>ContextFabric</c> key and the <c>Affidavit.EntityType</c> for this domain.</summary>
public const string LeaveRequestEntity = "LeaveRequest";
/// <summary>
/// The <c>EntityRef.Fields</c> key the sample uses to carry the real database id of the row an
/// update targets.
/// </summary>
public const string EntityIdField = "EntityId";
public string EntityName => LeaveRequestEntity;
public double? MinimumConfidenceThreshold => 0.5;
public IReadOnlyList<TaskInferenceField> Fields { get; } =
[
new("Employee", "string", "Who the leave is for — the employee's full name.",
MaxLength: 200, Required: true),
new("StartDate", "string", "First day of leave (yyyy-MM-dd).",
Pattern: @"^\d{4}-\d{2}-\d{2}$", Required: true, Format: "date"),
new("EndDate", "string", "Last day of leave (yyyy-MM-dd), inclusive.",
Pattern: @"^\d{4}-\d{2}-\d{2}$", Required: true, Format: "date"),
new("LeaveType", "string", "Type of leave.",
Enum: ["Annual", "Sick", "Personal"], Required: true),
new("Days", "integer", "Working days this leave uses up."),
new("Reason", "string", "Why the leave is being requested.",
MaxLength: 1000, Required: true),
];
}

This list is load-bearing twice over. The projection in the next step iterates it in declared order, so it fixes the order a reviewer reads the fields in; and Required, Enum, Pattern and the JSON type become the isMandatory, allowedValues, pattern and kind the Evidence Card renders each row from. Nothing on the card is hardcoded to a field name — change this list and the card changes.

An IAffidavitProjection turns the accumulated state for one turn into the Affidavit a reviewer sees. The framework ships a default one, SchemaDrivenAffidavitProjection — but nothing in this walkthrough registers it. AddAffiantCore does not. The three adapter calls that do — AddAffiantInferenceOrchestration on Semantic Kernel, AddAffiantExtensionsAI on Microsoft.Extensions.AI, AddAffiantAgentFramework on the Microsoft Agent Framework — each register it only when no IAffidavitProjection is present yet, and step 1 skipped the SK one. Affiant.Core also exposes AddSchemaDrivenProjection<TStrategy>(), which binds one to a named strategy for a multi-write host; this walkthrough calls none of them. It registers its own projection in step 6 instead, with AddAffidavitProjection<LeaveAffidavitProjection>(), and that is the IAffidavitProjection the framework resolves here.

Two things the shipped projection used to set to null unconditionally are exactly the two an update turns on:

  • Affidavit.EntityIdwhich row is being changed.
  • AffidavitField.PreviousValue on every field — what that row says today.

Reading either one means reading the host’s own database, and a domain-agnostic framework type has no business knowing what a leave request is. At 1.0.0-beta.1 the consequence was that every affidavit the default projection built was create-shaped, and an update reached a reviewer looking exactly like a create: proposed values with nothing to compare them against. 1.0.0-beta.3 closes that by asking the host for the missing half rather than guessing it. SchemaDrivenAffidavitProjection now fills EntityId from the entityId argument IAffidavitProjection.Project gained, and each field’s PreviousValue from a registered IPreviousValueSource.

IPreviousValueSource is the host port that answers what does this record say now, asked on an update-shaped operation and on no other. More than one may be registered; they are consulted in registration order and the first non-null answer wins — null means “not mine, ask the next”, and an empty map is a real answer. It is not optional for this host: amend_leave declares Operation.WriteUpdate, and AffiantWireUpValidator refuses at startup when a registered write tool declares an update operation and no source is registered, naming the tools. From Review/HrPreviousValueSource.cs:

public sealed class HrPreviousValueSource(IServiceScopeFactory scopeFactory) : IPreviousValueSource
{
public async Task<IReadOnlyDictionary<string, object?>?> GetPreviousValuesAsync(
string entityType, string entityId, CancellationToken cancellationToken)
{
if (!string.Equals(entityType, LeaveTaskInferenceStrategy.LeaveRequestEntity, StringComparison.Ordinal))
return null;
if (!int.TryParse(entityId, NumberStyles.Integer, CultureInfo.InvariantCulture, out var id))
return null;
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<HrDbContext>();
var row = await db.LeaveRequests.AsNoTracking()
.FirstOrDefaultAsync(r => r.Id == id, cancellationToken);
if (row is null)
return null;
return new Dictionary<string, object?>(StringComparer.Ordinal)
{
["Employee"] = row.Employee,
["StartDate"] = row.StartDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
["EndDate"] = row.EndDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture),
["LeaveType"] = row.LeaveType,
["Days"] = row.Days,
["Reason"] = row.Reason,
};
}
}

A row the table does not hold is not an empty row. The answer is null — “nothing to project” — so the projection swears the fields without previous values rather than swearing that every one of them was blank. Returning an empty dictionary would be the second claim, and it would be false.

Both early null returns are the same statement made twice: an entity type this source does not own, and an id it cannot parse, are both “ask somebody else”, never “the record is empty”.

The keys are the strategy’s declared field names. SchemaDrivenAffidavitProjection looks a field’s previous value up by the name the strategy declared — Employee, StartDate, EndDate — against whatever dictionary the source hands back, and this one compares its keys with StringComparer.Ordinal, so a map keyed any other way matches nothing and every PreviousValue comes back null in silence: a map that arrived is a real answer, and the projection warns only when no source answered at all. That is this block’s one divergence from the file, declared here because this page copies its C# from the sample: the sample keys this map camelCase (employee, startDate), which its own projection never notices, because that projection reads the row itself rather than through the port — but a host that leaves the shipped projection in place, which is exactly the host the next section opens with, gets an update-shaped affidavit that names its row and swears no previous value on any field.

AddPreviousValueSource<TSource>() registers the source Scoped, precisely so it can take a Scoped dependency — a DbContext, a per-request client — the way a source that reads the host’s domain store normally would. This sample nonetheless injects an IServiceScopeFactory and opens its own scope, which is the sample’s choice, not something the registration forces. The projection below is the one registered as a singleton (AddAffidavitProjection<TProjection>()), and that is why it cannot hold a DbContext.

With a previous-value source registered, a host whose wiring does include the shipped SchemaDrivenAffidavitProjection — one that calls AddAffiantInferenceOrchestration() and registers no projection of its own — gets an update-shaped affidavit out of it without writing a projection at all. This sample is not that host: it skips that call, so there is no built-in projection to fall back on, and it supplies its own. That projection reads the row itself rather than through the port, tags an untouched field with a binding naming the record it was read from, and writes a confidence line into Warnings for the card to render. From Projection/LeaveAffidavitProjection.cs:

public sealed class LeaveAffidavitProjection(
LeaveTaskInferenceStrategy strategy,
IServiceScopeFactory scopeFactory) : IAffidavitProjection
{
private static readonly System.Globalization.CultureInfo Invariant =
System.Globalization.CultureInfo.InvariantCulture;
/// <summary>
/// The tag an update's unchanged field carries: the database stated this value, not the caller.
///
/// <para>
/// A tag naming an external system says <em>which</em> system and <em>which</em> record — an
/// <c>external-ref</c> binding, in INVARIANTS.md PV-2's terms — and carries it in the tag's
/// structured <c>Binding</c> rather than in free text.
/// </para>
/// </summary>
private static ProvenanceTag FromRecord(int recordId) => new(
ProvenanceSource.External,
Confidence: 0.95f,
Evidence: null,
ConversationTurn: null,
Binding: new ProvenanceBinding.ExternalRef(
new ExternalRecordRef("HrDb", $"LeaveRequest/{recordId.ToString(Invariant)}")));
public string EntityType => strategy.EntityName;
public Affidavit Project(
IContextFabric fabric,
string operationType,
IReadOnlyList<string> warnings,
string? entityId = null)
{
ArgumentNullException.ThrowIfNull(fabric);
ArgumentNullException.ThrowIfNull(warnings);
var entity = fabric.GetByKey(strategy.EntityName);
var targetId = ParseEntityId(entityId) ?? ReadEntityId(entity);
var existing = targetId is null ? null : LoadLeaveRequest(targetId.Value);
var fields = strategy.Fields
.Select(field => ProjectField(field, fabric, entity, existing))
.ToArray();
// The minimum over every proposed field, an unsourced one counting 0.0. A mean taken over
// only the fields that have a source would report 1.00 on the card below while a mandatory
// field has nothing behind it at all.
var aggregateConfidence = fields.Length == 0 ? 0f : fields.Min(FieldConfidence);
var allWarnings = warnings
.Concat(fields
.Where(f => f.IsMandatory && IsBlank(f.Value))
.Select(f => $"{f.Name} is required and has no value — a reviewer must supply one."))
.Append(ConfidenceNote(fields, aggregateConfidence))
.ToArray();
return Affidavit.Create(
operationType: operationType,
entityType: strategy.EntityName,
entityId: existing?.Id.ToString(Invariant),
fields: fields,
warnings: allWarnings,
requiresConfirmation: true) with
{
// As of 1.0.0-beta.3 this restates the framework's own rule rather than overriding it
// with a stricter one: Affidavit.Create's AggregateConfidence is already this same
// minimum, an unsourced field counting 0.0. The two companions stay as Affidavit.Create
// computed them, so the card's three numbers are still about the same field list.
AggregateConfidence = aggregateConfidence,
};
}
private AffidavitField ProjectField(
TaskInferenceField field,
IContextFabric fabric,
EntityRef? entity,
LeaveRequest? existing)
{
var previousValue = existing is null ? null : ReadFromRecord(existing, field.Name);
var proposedValue = entity is not null && entity.Fields.TryGetValue(field.Name, out var v)
? v
: null;
// An affidavit states the whole row as it would stand after the write, so a field the
// caller left alone still carries the record's current value — with the record, not the
// caller, named as its source.
var value = proposedValue ?? previousValue;
var chain = fabric.GetFieldChain(field.Name)
?? (existing is not null && previousValue is not null
? ProvenanceChain.From(FromRecord(existing.Id))
// Rule 7 (nothing is omitted): a field with no known provenance is tagged Empty,
// never dropped.
: ProvenanceChain.From(ProvenanceTag.Empty));
var (kind, allowedValues) = ClassifyKind(field);
return new AffidavitField(
Name: field.Name,
Value: value,
PreviousValue: previousValue,
Provenance: chain,
IsMandatory: field.Required,
Kind: kind,
AllowedValues: allowedValues,
Pattern: field.Pattern);
}
/// <summary>
/// One field's contribution to the aggregate: its current tag's confidence, or 0.0 when that
/// tag says the provenance is unknown. Written out rather than leaning on
/// <c>ProvenanceTag.Empty</c> already carrying 0 — the rule is about the source, not about
/// which tag instance a host happened to mint.
/// </summary>
private static float FieldConfidence(AffidavitField field) =>
field.Provenance.Current.Source == ProvenanceSource.Empty
? 0f
: field.Provenance.Current.Confidence;
// ConfidenceNote, ParseEntityId, ReadEntityId, LoadLeaveRequest, ReadFromRecord, IsBlank and
// ClassifyKind are in the file.
}

One divergence to declare, since this page says every C# block is copied from the sample: the comment above AggregateConfidence is this page’s, not the file’s. The sample still calls its own aggregate “stricter than the framework’s own”, which stopped being true when Affidavit.Create started computing the same minimum — so the block above states what the code does rather than reproducing a comment that no longer matches it. Everything else here is the file’s, with the class-level documentation and Project’s parameter documentation trimmed away and the seven members named in the closing comment left in the file.

Two pieces of that file’s own documentation have gone stale the same way, which is worth knowing before you open it. The class summary — trimmed above — describes the framework’s PopulatedConfidence as an average over the fields that have a source; and ConfidenceNote’s summary, on one of the members named in the closing comment, says the two companion numbers cannot travel on the Affidavit record at all. At 1.0.0-beta.3 AffidavitConfidence.Compute takes the minimum for both numbers, and the record carries all three.

Five things in there are worth naming.

Create and update differ by one input. Whether the operation names a row. Since 1.0.0-beta.3 Project takes that id as its fourth parameter, and the caller’s answer wins when there is one — only the caller knows which record the write is against; the fabric is read as a fallback for a caller that names none. When a row resolves, the projection loads it, stamps its id on the affidavit and gives every field a PreviousValue; when none does, both stay null exactly as they should for a create. The built-in projection refuses either mismatch — an update with no entity id, or a create that names one — rather than filing a record whose shape contradicts its own operation.

Which shape an OperationType string means is fixed by the protocol, not by the host. Operation.IsUpdateShaped recognises exactly two spellings, case-insensitively: "WriteUpdate" and the bare "update". Everything else — "WriteCreate", "WriteDelete", and any verb of a host’s own — is create-shaped, which is why the builder above spells its update operation "update" rather than something domain-flavoured. Spell it any other way on an update and it is a create: the shipped SchemaDrivenAffidavitProjection throws ArgumentException for an entity id passed with a create-shaped operation, no PreviousValue is filled, and the proposal travels as a create in both canonical forms: ProposedOperation.From runs the same test to write "kind": "create" into the entry-id material — {tenantId, conversationId, toolName, operation, args}, and supersedes when there is one — and CanonicalSerializer.ToDocument writes "operationType": "create" into the Affidavit’s own canonical document, the content hash an execution grant binds to. A host verb like "ApproveLeave" travels beside the shape — in the tool name, in the descriptor, and in the HostOperation slot the Evidence Card envelope carries for exactly that, so a reviewer surface can head the card with the term a person recognises — never instead of it. Nothing in the framework fills that slot: EvidenceCardRequest.For and EvidenceCardRequestFactory.CreateAsync take it as an optional argument the gate passes none for, so a host that wants its own verb on the card sets it. It stays out of the canonical form either way — renaming a verb has not changed the evidence.

The args slot of that material is null on Semantic Kernel. ReviewGateFilter runs at the completion stage, and AffiantAutoFunctionInvocationBridge builds its request with an empty argument dictionary deliberately — its own comment says completion-stage filters key off the result, the function identity and termination rather than the arguments, and SK’s context can throw when asked for them — so the filter attaches nothing and the slot derives as a JSON null, the property present with no value. The rest of the material is the tenant, the session, the tool name and the operation — its kind, entity type, entity id and field names — so two request_leave calls in one session derive the same entry id even when the model passed different values, and the gate answers the second as an idempotent replay of the first rather than filing a second card. The Microsoft Agent Framework and Microsoft.Extensions.AI seams pass the call’s arguments through, so they fill the slot.

A tag points at something. ProvenanceTag carries a Binding — one of five kinds (UtteranceSpan, ReviewerAct, FormInput, ExternalRef, ComputationRef), each with its own Ref shape — and it is what an auditor looks at to re-check a value. A field read off the record binds external-ref naming HrDb and LeaveRequest/<id>; before 1.0.0-beta.3 those two facts had nowhere structured to live and this sample wrote them as free text into Evidence, which is why the tag above now sets Evidence: null. ProvenanceTag.IsBound and ProvenanceTag.RequiresBinding(source) say whether a tag points at anything and whether its grade ought to; at equal confidence and equal grade, a bound tag displaces an unbound one.

A field the caller said nothing about is still proposed. An affidavit describes the whole row as it would stand after the write, not a patch — so an untouched field carries the record’s current value with an External tag naming the database as its source. That is Rule 7 in practice: nothing is omitted, and nothing claims a user said something the database said. A field with neither a caller value nor a record value is tagged ProvenanceTag.Empty — stated as unsourced, never dropped.

The aggregate is the minimum, not a mean — and it has two companions on the record. AggregateConfidence here is the lowest confidence on the card, with an unsourced field counting 0.0 — so the number is 0.0 exactly when some proposed field has unknown provenance, and a blank mandatory Employee takes the whole card to 0.00. 1.0.0-beta.3 makes that the framework’s own definition: the shipped SchemaDrivenAffidavitProjection computed the arithmetic mean over the non-Empty fields, which let a ten-field record with nine unknown fields and one at 1.0 report a perfect 1.0, and it now takes the same minimum over every proposed field’s current tag, an Empty field counting 0 whatever its tag says. Beside it the Affidavit record now carries PopulatedConfidence (float?) — the minimum over the fields that are populated, null rather than 0 when none is, because “there is nothing populated to be confident about” is a different statement from “the populated fields are worthless” — and EmptyFieldCount (int), how many proposed fields read Empty. AffidavitConfidence.Compute(fields) is the one implementation of all three, Affidavit.Create(...) builds a record with them computed and affidavit.WithFields(...) recomputes them, which is why the projection calls the factory rather than the positional constructor and then overrides only the aggregate. ConfidenceNote states the same three facts as a sentence appended to Warnings — the aggregate, the minimum across the populated fields, and how many fields have no source at all — which is where the card’s warnings list renders them in words.

Lifetime. AddAffidavitProjection<T>() registers the projection as a singleton, so this type never injects a DbContext — that would be a captive scoped dependency. It opens a scope per projection instead, and Project is synchronous by contract, so the read uses EF’s synchronous API rather than blocking on an async one.

The one place stated values become an Affidavit is a small builder both write tools and the development seam go through, so a card filed by a live model turn and a card filed by the seam cannot drift. From Agent/LeaveProposalBuilder.cs:

public sealed class LeaveProposalBuilder(IEnumerable<IAffidavitProjection> projections)
{
public const string CreateOperation = "create";
public const string UpdateOperation = "update";
private IAffidavitProjection Projection =>
projections.FirstOrDefault(p => p.EntityType == LeaveTaskInferenceStrategy.LeaveRequestEntity)
?? throw new InvalidOperationException(/* … name the missing registration … */);
public Affidavit BuildCreate(IReadOnlyDictionary<string, string> statedFields) =>
Build(CreateOperation, statedFields, leaveRequestId: null);
public Affidavit BuildUpdate(int leaveRequestId, IReadOnlyDictionary<string, string> statedFields) =>
Build(UpdateOperation, statedFields, leaveRequestId);
private Affidavit Build(
string operationType,
IReadOnlyDictionary<string, string> statedFields,
int? leaveRequestId)
{
ArgumentNullException.ThrowIfNull(statedFields);
var fabric = new ContextFabric();
var entityFields = new Dictionary<string, object>(StringComparer.Ordinal);
foreach (var (name, value) in statedFields)
entityFields[name] = value;
if (leaveRequestId is { } id)
entityFields[LeaveTaskInferenceStrategy.EntityIdField] = id;
fabric.Upsert(new EntityRef(
EntityType: LeaveTaskInferenceStrategy.LeaveRequestEntity,
// The fabric keys entities by EntityId and every projection looks this domain up by the
// strategy's entity name, so the name is the key. The real row id travels as a field —
// see LeaveTaskInferenceStrategy.EntityIdField.
EntityId: LeaveTaskInferenceStrategy.LeaveRequestEntity,
DisplayName: "Leave request",
Fields: entityFields));
// Every value here came straight off the caller's own arguments, so every tag is UserStated
// and binds to the control the person typed into (PV-3). A field the caller said nothing
// about gets no chain at all, and the projection decides between the record's current value
// and ProvenanceTag.Empty.
foreach (var name in statedFields.Keys)
{
fabric.SetFieldChain(name, ProvenanceChain.From(
ProvenanceTag.FromUser(name, new ProvenanceBinding.FormInput(new FormInputRef(name)))));
}
return Projection.Project(
fabric,
operationType,
[],
leaveRequestId?.ToString(System.Globalization.CultureInfo.InvariantCulture));
}
}

The grade in that block describes the seam’s path, not the model’s. The builder swears every stated field UserStated at confidence 1.0 with a form-input binding, and its comment reads that binding as the control a person typed into. That is what happens when the development seam posts the values by hand; it is not what happens on the path this page leads with, where the model wrote the arguments out of the conversation and nobody typed into anything. The rulebook draws the line at the act rather than at the value: form-input is the form field a person typed into, and UserStated is 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. The honest grade for a value the model took out of the turn is Conversation, minted by ProvenanceTag.FromInference(InferenceSource.Conversation, …) with an utterance-span binding when the inference port supplies offsets, or Inferred when the model reasoned its way to it. That factory cannot name UserStated at all: its first argument is an InferenceSource, and the enum has exactly the two members. And a model’s write-tool arguments are not provenance to begin with — as step 1 says, ToolArgumentCaptureFilter records one as a value the model proposes and mints no tag for it, so in a host that leaves the grading to the framework the field swears Empty at 0 until a deterministic interceptor or the inference port speaks for it. Copy this builder for a host whose values really are typed into a form; on the model path, grade what the model wrote for what it is.

ProvenanceTag.FromUser requires a binding. The single-argument overload is gone at 1.0.0-beta.3; the second argument is the artifact the claim rests on — a ProvenanceBinding.FormInput, UtteranceSpan or ReviewerAct — or binding: null where there is genuinely nothing to point at. An unbound UserStated tag is still recorded exactly as claimed; it is the weakest form of the strongest grade, and a policy is entitled to refuse to rest on it.

The row id travelling as a named field rather than on EntityRef.EntityId is not decoration: the Context Fabric keys entities by EntityId, and every projection — the framework’s default included — looks an entity up by the strategy’s entity name. Putting a row id there would make the entity unfindable. The builder also hands the id to Project as its fourth argument, which is the one the projection prefers; the fabric field is the fallback for a caller that names none.

The builder takes a fresh ContextFabric per proposal rather than the one the framework registers, because every value on this card comes straight off the tool call’s own arguments; a host using deferred inference would build from that instance instead. The framework registers it Scoped, so its fabric lives one turn scope: nothing in the framework carries what an extractor put there into the next turn, and nothing writes it to a store either — a host that wants it to survive saves it itself through IDocketStore.SaveContextAsync. Reading it back is half-shipped on Semantic Kernel: SessionRehydrator.RehydrateAsync loads a stored ConversationContext through LoadContextAsync on reconnect and hands it back to the caller, which is not the same as refilling a fabric. This sample never gets that far — its hub calls the base AffiantHub.RehydrateSessionAsync, which rejoins the session group and replays the transcript and nothing else. Taking a fresh fabric here also keeps the builder free of scoped dependencies, which matters in step 6.

A write-intent tool is a [KernelFunction] marked with [AffiantWriteTool], returning a WriteProposal that wraps an Affidavit.

From Agent/RequestLeavePlugin.cs:

public sealed class RequestLeavePlugin(LeaveProposalBuilder proposals)
{
public const string FunctionName = "request_leave";
[KernelFunction(FunctionName)]
[AffiantWriteTool("WriteCreate", LeaveTaskInferenceStrategy.LeaveRequestEntity, typeof(LeaveTaskInferenceStrategy))]
[Description("Propose a new leave request. Returns a proposal for a human to review; never writes to the database.")]
public Task<string> RequestLeaveAsync(
[Description("The employee's full name.")] string employee,
[Description("First day of leave, as yyyy-MM-dd.")] string startDate,
[Description("Last day of leave, inclusive, as yyyy-MM-dd.")] string endDate,
[Description("Annual, Sick, or Personal.")] string leaveType,
[Description("Working days this leave uses up.")] int days,
[Description("Why the leave is being requested.")] string reason)
{
var affidavit = proposals.BuildCreate(new Dictionary<string, string>(StringComparer.Ordinal)
{
["Employee"] = employee,
["StartDate"] = startDate,
["EndDate"] = endDate,
["LeaveType"] = leaveType,
["Days"] = days.ToString(System.Globalization.CultureInfo.InvariantCulture),
["Reason"] = reason,
});
return Task.FromResult(
new WriteProposal(FunctionName, DateTimeOffset.UtcNow, affidavit).ToJsonString());
}
}

The update tool is the same shape — the same builder, the same strategy — with two arguments instead of six, one of them the row’s id, and a ToolError for an id no row carries. From Agent/AmendLeavePlugin.cs:

public sealed class AmendLeavePlugin(LeaveProposalBuilder proposals)
{
public const string FunctionName = "amend_leave";
[KernelFunction(FunctionName)]
[AffiantWriteTool("WriteUpdate", LeaveTaskInferenceStrategy.LeaveRequestEntity, typeof(LeaveTaskInferenceStrategy))]
[Description("Propose a change to the end date of an existing leave request. Returns a proposal for a human to review; never writes to the database.")]
public Task<string> AmendLeaveAsync(
[Description("The id of the leave request to change.")] int leaveRequestId,
[Description("The new last day of leave, inclusive, as yyyy-MM-dd.")] string endDate)
{
var affidavit = proposals.BuildUpdate(
leaveRequestId,
new Dictionary<string, string>(StringComparer.Ordinal) { ["EndDate"] = endDate });
if (affidavit.EntityId is null)
{
return Task.FromResult(new ToolError(
ToolName: FunctionName,
Timestamp: DateTimeOffset.UtcNow,
Code: "leave_request_not_found",
Message: $"No leave request exists with id {leaveRequestId}. List the requests first and use an id from that list.",
Retryable: false).ToJsonString());
}
return Task.FromResult(
new WriteProposal(FunctionName, DateTimeOffset.UtcNow, affidavit).ToJsonString());
}
}

Neither plugin has a DbContext between them. Rule 3 of the Seven Normative Rules — write tools never write — means they don’t need one. The only code that writes to the leave-request table is the executor in step 8, and it runs only after the review flow has approved — a human reviewer here, because this host registers no Standing Order policy and every proposal falls through to ReviewerConfirmation.

For the model to amend a row it has to learn the row’s id, so the host also has a read tool. It matters here for two reasons beyond the domain: it is Rule 2 — dual-audience returns — next to a write tool, and it is the reason step 6 registers a read descriptor. From Agent/LeaveLookupPlugin.cs:

public sealed class LeaveLookupPlugin(IServiceScopeFactory scopeFactory)
{
public const string FunctionName = "list_leave_requests";
[KernelFunction(FunctionName)]
[Description("List the leave requests already recorded, with their ids, so a request can be referred to by id.")]
public async Task<string> ListLeaveRequestsAsync(CancellationToken cancellationToken)
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<HrDbContext>();
var requests = await db.LeaveRequests
.AsNoTracking()
.OrderByDescending(r => r.Id)
.Take(20)
.ToListAsync(cancellationToken);
var markdown = new StringBuilder();
// … the file builds one markdown row per request here, for the model to quote …
var entities = requests
.Select(r => new EntityRef(
EntityType: LeaveTaskInferenceStrategy.LeaveRequestEntity,
EntityId: r.Id.ToString(CultureInfo.InvariantCulture),
DisplayName: $"{r.Employee}, {r.LeaveType} {r.StartDate:yyyy-MM-dd} to {r.EndDate:yyyy-MM-dd}",
Fields: new Dictionary<string, object>(StringComparer.Ordinal)
{
// … the file lists one entry per field the next turn might need …
}))
.ToArray();
return new ReadResult(
ToolName: FunctionName,
Timestamp: DateTimeOffset.UtcNow,
Summary: $"{requests.Count} leave request(s).",
Markdown: markdown.ToString(),
Entities: entities).ToJsonString();
}
}

It takes an IServiceScopeFactory rather than a DbContext for a reason that catches every host once: the plugin registration below instantiates plugins once, from the root provider. That registration path has no per-invocation plugin lifetime, so constructor-injecting a scoped service — a DbContext, the scoped IContextFabric — into a plugin registered that way is always a captive dependency.

The mechanism is worth stating exactly, because the failure does not appear where a reader will look for it. Plugins.AddFromType<T>() registers a factory-backed singleton KernelPlugin and never registers T in the container at all, so ValidateOnBuild cannot see inside it and builder.Build() succeeds. The failure is InvalidOperationException: Cannot resolve scoped service '…' from root provider, thrown at the first Kernel resolution — the moment the plugin instance is constructed and its scoped dependency asked of the root, and from inside a scope as well as from the root, because a singleton is built by the root provider whichever scope asks for it. Step 1’s AddAffiantSemanticKernel forces that resolution at boot: AffiantStartupValidator is an IHostedService that injects an IServiceScopeFactory and resolves Kernel from a scope it creates in StartAsync — which does not save the plugin, for the reason just given. Only scope validation turns it into a throw — it is on by default in the Development environment; with it off, one DbContext instance is silently shared by every conversation the process handles. A scope per call is the cost of avoiding that, and it is the right lifetime for a read anyway. It is also why LeaveProposalBuilder in step 4 has no scoped dependency and is registered as a singleton below. A read tool’s full contract is in Authoring Read Tools.

AddAffiantTool<TStrategy> registers the strategy in DI and a matching AffiantToolDescriptor in the registry, atomically. From Program.cs:

builder.Services.AddAffiantTool<LeaveTaskInferenceStrategy>(
functionName: RequestLeavePlugin.FunctionName,
operation: Operation.WriteCreate,
entityType: LeaveTaskInferenceStrategy.LeaveRequestEntity,
pluginName: nameof(RequestLeavePlugin));
builder.Services.AddAffiantTool<LeaveTaskInferenceStrategy>(
functionName: AmendLeavePlugin.FunctionName,
operation: Operation.WriteUpdate,
entityType: LeaveTaskInferenceStrategy.LeaveRequestEntity,
pluginName: nameof(AmendLeavePlugin));
builder.Services.AddAffiantReadTool(
functionName: LeaveLookupPlugin.FunctionName,
entityType: LeaveTaskInferenceStrategy.LeaveRequestEntity,
pluginName: nameof(LeaveLookupPlugin));
builder.Services.AddAffidavitProjection<LeaveAffidavitProjection>();
// The two host ports the framework refuses to start without once a write-capable tool is declared:
// what the record holds today (AF-3), and who may decide (AZ-2). Both are questions only the host
// can answer, and the startup refusal is what stops either being discovered mid-conversation.
builder.Services.AddPreviousValueSource<HrPreviousValueSource>();
builder.Services.AddDecisionAuthorization<QuickstartDecisionAuthorization>();
builder.Services.AddSingleton<LeaveProposalBuilder>();
// Registering plugin types with the kernel is ordinary Semantic Kernel, not Affiant. The chat
// completion connector below is added on the same builder and is outside Affiant's scope.
var kernelBuilder = builder.Services.AddKernel();
kernelBuilder.Plugins.AddFromType<RequestLeavePlugin>();
kernelBuilder.Plugins.AddFromType<AmendLeavePlugin>();
kernelBuilder.Plugins.AddFromType<LeaveLookupPlugin>();

Pass pluginName. Semantic Kernel reports a function under its plugin, and the startup validator looks a descriptor up by function name and plugin name — a descriptor registered without one is not found, and the host refuses to start.

Every [KernelFunction] the kernel exposes needs a descriptor. That includes read tools: AddAffiantReadTool is what declares one by hand, and a [KernelFunction] with no descriptor at all is a startup failure, not a tolerated gap. Declaring them one at a time is not the only route — AddAffiantPluginsFromType<T>() and AddAffiantPluginsFromAssembly(...) walk a type’s [KernelFunction] methods and register a descriptor for each, a write descriptor where [AffiantWriteTool] is present and an Operation.ReadQuery one everywhere else; the plugin itself still goes on the kernel the ordinary way. The sample declares each descriptor by hand, which is what the block above shows. At boot, AffiantStartupValidator (registered by AddAffiantSemanticKernel) checks that every function has a matching descriptor and that every descriptor’s ITaskInferenceStrategy resolves from IServiceProvider; either failure throws AffiantStartupException naming the exact function or strategy at fault, so a missed registration fails loudly at startup rather than silently at the first tool call.

AddAffidavitProjection<LeaveAffidavitProjection>() is what makes step 4’s projection the one the framework and the builder resolve for this entity type. It registers the projection as a singleton, which is why LeaveProposalBuilder — resolved into plugins built from the root provider — is a singleton too. AddPreviousValueSource<HrPreviousValueSource>() and AddDecisionAuthorization<QuickstartDecisionAuthorization>() are the two ports step 1 said this host owes the framework; both are checked at startup, and the host does not run without them.

The model is optional, and the host says so rather than failing quietly:

var openAiKey = builder.Configuration["OPENAI_API_KEY"];
var openAiModel = builder.Configuration["OPENAI_MODEL"] ?? "gpt-4o-mini";
var openAiBaseUrl = builder.Configuration["OPENAI_BASE_URL"];
if (!string.IsNullOrWhiteSpace(openAiKey))
{
if (!string.IsNullOrWhiteSpace(openAiBaseUrl))
kernelBuilder.AddOpenAIChatCompletion(openAiModel, new Uri(openAiBaseUrl), openAiKey);
else
kernelBuilder.AddOpenAIChatCompletion(openAiModel, openAiKey);
}

The chat completion connector is added on the same builder and is outside Affiant’s scope — swap it for Azure OpenAI, Google or Ollama without touching anything above.

ReviewGateFilter fires after every auto-invoked function. If the result deserializes as a WriteProposal, it asks a host-registered IReviewContextProvider to build a ReviewContext — session, tenant, user, reviewer, and the Affidavit itself — and only then files the review.

The filter fails closed at 1.0.0-beta.3. Three branches used to return quietly at debug-log level — no IReviewContextProvider registered, no review context available for this call, no ReviewGate registered — leaving the raw proposal as the tool’s visible result, so the model was free to report an unfiled, unreviewed write as done. All three are now refusals carrying wireup-invalid, and a tool the registry declares write-capable that returns something other than a WriteProposal is refused too, rather than skipped — with one hole worth knowing about: the filter reads the result as a string and returns immediately when that string is null or empty, before it consults the registry, so a declared write tool that hands back nothing at all passes through unrefused. Two of the three are refused earlier still, at startup, by step 1’s wire-up validator; the third — a registered provider that returns no context for one particular call — only a live request can know, which is where the filter catches it. This is the failure mode for exactly the call sites that most need the gate: a queue consumer, a cron trigger, a background job.

A chat turn arrives as a hub invocation with no HttpContext. A SignalR hub method runs on an already-established connection, so IHttpContextAccessor.HttpContext — the API a provider like the one below reads — is null for the whole turn, including when the filter asks who is proposing this write. (Hub.Context.GetHttpContext() would still hand back the handshake’s context; this host does not use it, because the identity it needs is the turn’s, not the connection’s.) A provider that reads only the HTTP request returns null for every model-proposed write, and every one of them is now refused. The identity has to come from the turn instead, so the hub sets it and the provider reads it. From Review/ChatTurnContext.cs:

public sealed class ChatTurnContext
{
/// <summary>The session (and SignalR group) the turn belongs to. Empty until the hub sets it.</summary>
public string SessionId { get; set; } = string.Empty;
/// <summary>Who is holding the conversation. This sample has no sign-in; see <see cref="HttpReviewContextProvider"/>.</summary>
public string UserId { get; set; } = string.Empty;
/// <summary>True once the hub has populated this instance for a turn.</summary>
public bool IsSet => !string.IsNullOrEmpty(SessionId);
}

From Review/HttpReviewContextProvider.cs:

public sealed class HttpReviewContextProvider(
IHttpContextAccessor httpContextAccessor,
ChatTurnContext turnContext) : IReviewContextProvider
{
/// <summary>The stand-in identity this sample files every review under, having no sign-in.</summary>
public const string DemoUserId = "quickstart-reviewer";
private static readonly JsonSerializerOptions JsonOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
};
public ReviewContext? BuildReviewContext(WriteProposal proposal)
{
ArgumentNullException.ThrowIfNull(proposal);
var affidavit = ReadAffidavit(proposal);
if (affidavit is null)
return null;
var sessionId = ResolveSessionId();
if (string.IsNullOrEmpty(sessionId))
return null;
var userId = string.IsNullOrEmpty(turnContext.UserId) ? DemoUserId : turnContext.UserId;
return new ReviewContext(
SessionId: sessionId,
TenantId: "default",
UserId: userId,
ReviewerUserId: userId,
Affidavit: affidavit);
}
private string? ResolveSessionId()
{
if (turnContext.IsSet)
return turnContext.SessionId;
var http = httpContextAccessor.HttpContext;
var header = http?.Request.Headers["X-Session-Id"].ToString();
return string.IsNullOrWhiteSpace(header) ? null : header;
}
private static Affidavit? ReadAffidavit(WriteProposal proposal) => proposal.Envelope switch
{
Affidavit affidavit => affidavit,
JsonElement json => json.Deserialize<Affidavit>(JsonOptions),
_ => null,
};
}

WriteProposal.Envelope is declared object, and on the filter path it arrives as a JsonElement. The tool returned JSON; by the time ReviewGateFilter has deserialized it, the envelope is a JsonElement, not an Affidavit — and ToJsonString() wrote it under the framework’s one set of JSON conventions (AffiantJson.SerializerOptions: camelCase names, enums as strings in each schema’s own casing, instants and money through their converters, nulls written), so deserializing needs at least the same naming policy back. The plain camelCase options above round-trip anyway because the enums that matter carry their converters as attributes on the type — ProvenanceSource is [JsonConverter(typeof(JsonStringEnumConverter))], ProvenanceBinding has its own — but a host that adds an enum of its own to a payload should configure its options with AffiantJson.Configure rather than rely on that. The Affidavit arm is for a caller that hands the object over in-process rather than through JSON; the development seam in step 9 is not one of them — it builds its own ReviewContext inline and calls ReviewGate.FileForReviewAsync directly, so it never reaches this provider at all.

Returning null is how a host says this caller has no identity to file a review under; the framework then refuses the call rather than inventing a reviewer, and the refusal is what the tool returns. This sample has no sign-in and falls back to a fixed demo id — a real host reads it from the authenticated principal and returns null for an unauthenticated caller.

Read that file’s own class summary — trimmed from the block above — as history rather than as description. It states the 1.0.0-beta.1 behaviour twice: that without this registration “the framework’s review filter logs a debug line and skips the write silently”, and that on a null return “the framework then skips filing rather than inventing a reviewer”. Both are refusals carrying wireup-invalid at 1.0.0-beta.3, as the paragraphs above this block say.

Both types are scoped, not singletons: SignalR creates one DI scope per hub invocation, so one ChatTurnContext serves one turn, and a per-turn identity cannot be a singleton’s dependency.

builder.Services.AddScoped<ChatTurnContext>();
builder.Services.AddScoped<IReviewContextProvider, HttpReviewContextProvider>();
builder.Services.AddSingleton<IRouteRegistry, LeaveRouteRegistry>();

That third line is not optional either, and its absence is easy to misread. AddAffiantCore registers the framework’s UI guidance bridge — Rule 6: the framework knows an element exists because the UI layer registered it, never because something inspected the DOM — as a singleton, and that bridge takes an IRouteRegistry. ASP.NET Core validates the whole service collection at build time in the Development environment (ValidateOnBuild covers every registered descriptor, not only the singletons), so a host that registers none does not start there, throwing before the first request and naming this interface. The framework ships no implementation, because the map is the host’s. The smallest honest one is a dictionary. From Review/LeaveRouteRegistry.cs:

public sealed class LeaveRouteRegistry : IRouteRegistry
{
private readonly ConcurrentDictionary<string, GuidableElement> _elements = new(StringComparer.Ordinal);
public LeaveRouteRegistry()
{
Register(new GuidableElement("chat-input", "textarea",
new Dictionary<string, object> { ["route"] = "/" }));
Register(new GuidableElement("evidence-card", "region",
new Dictionary<string, object> { ["route"] = "/" }));
}
// Register, GetElementsForRoute, GetAllElements and GetElementById are in the file.
}

IDecisionAuthorizationPolicy is the same idea asked at the other end of the loop: the review context says who is proposing a write; this says whether a given principal may decide a given Docket entry. It is mandatory wherever a write-capable tool is declared. A host that registers none falls to DenyAllDecisionAuthorization, which refuses every decision rather than admitting every decision — obviously broken, and broken in the direction that cannot approve a write nobody was entitled to approve — and the wire-up validator refuses the host at startup, so nobody runs on the deny-all by accident.

The framework does the parts a host should not have to get right and delegates the one only a host can answer. In order, before any transition: an unresolved principal is refused with decision-unauthorized before the Docket is read; an entry outside the caller’s tenant is entry-not-found — never forbidden, because telling a caller that an id it may not touch exists is the leak the check is for — and the row’s own tenant is compared by the framework rather than trusted from the store, so a store with a scope bug does not make the gate fall open. Then the host’s port. From Review/QuickstartDecisionAuthorization.cs:

public sealed class QuickstartDecisionAuthorization : IDecisionAuthorizationPolicy
{
public Task<bool> MayDecideAsync(
Principal principal, DocketEntry entry, CancellationToken cancellationToken = default)
{
ArgumentNullException.ThrowIfNull(principal);
return Task.FromResult(principal is Principal.Member member
&& string.Equals(member.Id, HttpReviewContextProvider.DemoUserId, StringComparison.Ordinal));
}
}

This sample authenticates nobody and has exactly one reviewer, so the rule is the demo reviewer, and nobody else — a machine caller included: a Principal.Service is refused here rather than allowed to approve a write in a person’s name. A real host reads its own roles or ownership. What it must not do is return true when it could not decide: false and a throw both refuse, because a check that fell over has not said yes.

If a host already guards its own decision entry point by comparing the reviewer id, that guard comes out. The framework refuses an unresolved principal before it reads the Docket and compares the row’s tenant itself; a host-side check that repeats either is dead code that can only drift from the framework’s. What is left for the host is the one question the framework has no opinion about — whether this person, in a tenant that already matched, is one of the people entitled to decide this row.

8. Take the decision and execute the write

Section titled “8. Take the decision and execute the write”

With everything above registered, a chat turn that invokes request_leave flows like this. Semantic Kernel auto-invokes the function, which returns a WriteProposal as JSON. ReviewGateFilter deserializes it, calls BuildReviewContext, and passes the result to ReviewGate.FileForReviewAsync, which runs its shared core in the order the rules fix — a substance check first (nothing to swear to is refused before anything else runs), an idempotent replay lookup, then the approval policy (falling through to ReviewerConfirmation, per step 1), then the deadline stamped from what the chain returned — before it files the DocketEntry on the IDocketStore and broadcasts an EvidenceCardRequest — carrying the entry’s DocketId, the Affidavit and a RequiredBy deadline — to the session’s SignalR group. It then returns. It does not block. The model’s turn ends there.

The reviewer’s decision comes back later, on a separate hub call. That separation is structural, not stylistic: SignalR allows one invocation per client at a time by default, so a design where the tool call waited inside the framework would put the call carrying the decision in a queue behind the call waiting for it. A blocking review mode needs a decision channel separate from the blocked connection, and 1.0.0-beta.3 does not ship one — see the roadmap.

From Hubs/ChatHub.cs:

public sealed class ChatHub(
IChatSessionStore chatSessionStore,
IStreamingTransport transport,
ReviewGate reviewGate,
IDocketStore docketStore,
IWriteExecutor writeExecutor,
ChatTurnContext turnContext,
Kernel kernel,
ILogger<ChatHub> logger) : AffiantHub(chatSessionStore, transport)
{
private const string TenantId = "default";
/// <summary>
/// Who is acting on this connection, from which tenant and in which conversation (AZ-2).
///
/// <para>
/// Built at the call site from the connection's own identity, never resolved from ambient
/// state. This sample authenticates nobody, so the demo reviewer is the principal; a real host
/// reads it from <c>Context.User</c> and refuses the call when identity does not resolve —
/// which the framework then answers <c>decision-unauthorized</c> before it reads the Docket.
/// </para>
/// </summary>
private DecisionContext Deciding() => new(
new Principal.Member(HttpReviewContextProvider.DemoUserId),
TenantId,
ConversationId: Context.ConnectionId,
Channel: "chat");
public async Task<DecisionAck> ApproveEntry(Guid entryId, Dictionary<string, object?>? amendments)
{
var (outcome, _) = await reviewGate.HandleDecisionAsync(
entryId, ApprovalDecision.Approved, Deciding(), amendments, Context.ConnectionAborted);
if (outcome is not ReviewOutcome.Approved)
return DecisionAck.From(entryId, outcome);
var entry = await docketStore.GetDocketEntryAsync(entryId, Context.ConnectionAborted);
if (entry is { Status: ReviewStatus.Approved })
{
var recordId = await writeExecutor.ExecuteAsync(
entry.Envelope, entry.Amendments, Context.ConnectionAborted);
logger.LogInformation(
"Approved DocketEntry {EntryId} wrote leave request {RecordId}", entryId, recordId);
}
return DecisionAck.From(entryId, outcome);
}
/// <summary>Delivers a reviewer's rejection. No write happens on this path, ever.</summary>
public async Task<DecisionAck> RejectEntry(Guid entryId)
{
var (outcome, _) = await reviewGate.HandleDecisionAsync(
entryId, ApprovalDecision.Rejected, Deciding(), amendments: null, Context.ConnectionAborted);
return DecisionAck.From(entryId, outcome);
}
/// <summary>
/// Files a fresh review for an entry that expired unreviewed. The framework mints a new entry
/// cloning the expired one's affidavit and broadcasts its card carrying whatever the first
/// reviewer had already amended, so the second reviewer sees the work that was done before the
/// window lapsed.
/// </summary>
public async Task<DecisionAck> ResubmitEntry(Guid entryId)
{
var filing = await reviewGate.ResubmitAsync(entryId, Deciding(), Context.ConnectionAborted);
// … map ReviewFilingResult to the ack the client renders …
}
// ApproveEntry's own <summary> is trimmed above; RehydrateSession (join a session, replay its
// transcript, re-broadcast its pending cards) and SendMessage (run one model turn) are in the
// file, along with ResubmitEntry's elided body. So are DecisionAck, its DecisionAck.From
// factory, SessionJoined and AgentMessagePayload: they are this sample's own records, declared
// below the class in the same file, not framework types.
}

This hub passes entry.Envelope, and its executor folds the amendments itself. The framework’s own guidance is the other way round: call ExecuteAsync with entry.AmendedAffidavit ?? entry.Envelope and let AffidavitAmendments.Apply — the one implementation of what an accepted correction does to the record — be what folded them, so a corrected value is never shown under the machine’s pre-correction confidence. The block above is the sample as it stands; see Review Gate & Write Executors for the recommended shape.

The elided DecisionAck.From is worth reading before copying it: it still carries a ReviewOutcome.Expired arm, and HandleDecisionAsync no longer returns that outcome on any path, so a late decision falls to the factory’s _ => arm and acks the client pending. What replaced it is under When it expires. ApproveEntry’s own summary, trimmed from the block above, is stale for the same reason — it says a decision arriving after the entry’s deadline “is answered expired”. The rest of that summary still holds: nothing is written, and amendments the late decision carried are preserved on the entry.

AffiantHub takes an IChatSessionStore and an IStreamingTransport; both come from the registrations in step 2.

DecisionContext is the third positional argument, and there is no overload without it. It carries the principal, the tenant, the conversation, the channel and the reviewer’s reason — passed at the call site, never resolved from ambient state, and with no unattributed variant to fall back on. Principal.Member is a human-verified session; Principal.Service is a machine caller, which may name the person it speaks for and the relay assertion that carried them. The one overload 1.0.0-beta.1 shipped, (entryId, decision, amendments, ct), is gone at 1.0.0-beta.3, and no replacement omits the principal or the tenant — deliberately: an overload that defaulted either would be the fail-open the change exists to close. ResubmitAsync takes one too, and runs the same checks — a caller that could not have decided an entry cannot re-open it either.

What DecisionContext deliberately does not carry is the act’s instant. The moment a decision is dated to is the one the gate observed on its own TimeProvider, so a caller cannot date its own agreement, and a row cannot be back-dated inside its deadline by the caller whose lateness the deadline is about.

Nothing in the framework calls IWriteExecutor for you. ReviewGateFilter files the review and logs the outcome; ReviewGate.HandleDecisionAsync settles the entry and hands back a ReviewOutcome. The hub method above is the host-owned integration point where an approved Affidavit actually reaches the database, and reading the entry back before executing is what makes the write conditional on the framework’s own answer rather than on a click. See Review Gate & Write Executors for why the boundary is drawn there.

1.0.0-beta.3 adds the other half of that loop: ReviewGate.MarkExecutedAsync(entryId, outcome, detail, context), a once-only guarded report of what the host’s executor actually did, which moves the row’s Execution axis off Unexecuted and is refused with execution-already-recorded on a second report. The status stays Approved either way — the approval happened, and a failed write does not undo it. This sample does not call it; a host that wants the Docket to record whether an approved write landed does.

The executor itself is the only code in the host that changes a leave request. From Execution/LeaveWriteExecutor.cs:

public sealed class LeaveWriteExecutor(HrDbContext db) : IWriteExecutor
{
public async Task<string?> ExecuteAsync(
Affidavit affidavit,
IReadOnlyDictionary<string, object?>? amendments,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(affidavit);
if (affidavit.EntityType != LeaveTaskInferenceStrategy.LeaveRequestEntity)
{
throw new NotSupportedException(
$"No executor for entity type '{affidavit.EntityType}'.");
}
var record = await ResolveRecordAsync(affidavit, ct);
record.Employee = ReadField(affidavit, amendments, "Employee") ?? record.Employee;
record.StartDate = ParseDate(ReadField(affidavit, amendments, "StartDate"), record.StartDate);
record.EndDate = ParseDate(ReadField(affidavit, amendments, "EndDate"), record.EndDate);
record.LeaveType = ReadField(affidavit, amendments, "LeaveType") ?? record.LeaveType;
record.Days = ParseInt(ReadField(affidavit, amendments, "Days"), record.Days);
record.Reason = ReadField(affidavit, amendments, "Reason") ?? record.Reason;
// SaveChanges happens ONLY here — never in a write tool, never in the projection.
await db.SaveChangesAsync(ct);
return record.Id.ToString(CultureInfo.InvariantCulture);
}
/// <summary>
/// A reviewer's amendment wins over the sworn value; an amendment present with a <c>null</c>
/// value clears the field, which this sample expresses as an empty string. A field the
/// reviewer did not touch falls back to the affidavit's own value.
/// </summary>
private static string? ReadField(
Affidavit affidavit,
IReadOnlyDictionary<string, object?>? amendments,
string name)
{
if (amendments is not null && amendments.TryGetValue(name, out var amended))
return amended?.ToString() ?? string.Empty;
var field = affidavit.Fields.FirstOrDefault(f => f.Name == name);
return field?.Value?.ToString();
}
// ResolveRecordAsync (new row on a create, load by EntityId on an update), ParseDate and
// ParseInt are in the file.
}

The amendments parameter is IReadOnlyDictionary<string, object?>?, and the nullable value is load-bearing: a key present with a null value means the reviewer cleared that field, which is a different instruction from the key being absent, which means leave it alone. ReadField keeps the two apart.

That file’s class summary, trimmed above, closes on advice the framework has since taken over: it says a host with its own audit trail would append a UserStated provenance tag per amended field itself. IWriteExecutor’s own documentation now says the opposite — do not hand-roll the amendment fold: AffidavitAmendments.Apply appends the reviewer’s own tag on top of each amended field’s chain — UserStated at 1 for a set, Empty at 0 for a clear, both carrying a reviewer-act binding — and the gate hands the folded record back on ReviewOutcome.Approved.AmendedAffidavit.

Reading affidavit.Fields[i].Value as a plain value works here because the Docket is UseInMemory() (step 2) — it is still the literal "yyyy-MM-dd" string the projection set, which is what ReadField’s .ToString() and DateOnly.TryParse are written against. On a database-backed Docket the EF stores read values, previous values and amendment maps back through AffidavitFieldValues, which converts by declared kind: a date field returns a DateTimeOffset, whose ToString() is not the shape this executor parses. An executor meant to run on both should handle the typed value rather than round-tripping through a string. Authoring Write Tools covers IFieldMapper<T> and routing more than one entity type through one executor.

builder.Services.AddScoped<IWriteExecutor, LeaveWriteExecutor>();

Every builder.Services call from steps 1–8 runs before builder.Build(). From Program.cs:

var app = builder.Build();
using (var scope = app.Services.CreateScope())
{
var affiantDb = scope.ServiceProvider.GetRequiredService<AffiantDbContext>();
await affiantDb.MigrateAffiantSchemaAsync(app.Logger);
var hrDb = scope.ServiceProvider.GetRequiredService<HrDbContext>();
await HrDbContext.SeedAsync(hrDb);
}
app.UseDefaultFiles();
app.UseStaticFiles();
// The employee list the reviewer's picker reads — a live read endpoint, so the value a reviewer
// puts on the card comes from the system of record rather than from typing.
app.MapGet("/api/employees", async (HrDbContext db, CancellationToken ct) =>
await db.Employees.AsNoTracking().OrderBy(e => e.Name)
.Select(e => new { e.Id, e.Name, e.Department })
.ToListAsync(ct));
// The leave requests actually written. A reviewer's decision is only believable if you can see
// what it did, so the page and the regression deck both read this.
app.MapGet("/api/leave-requests", async (HrDbContext db, string? search, CancellationToken ct) =>
{
// … filter by `search`, project, and return the rows …
});
app.MapDevSeamEndpoints();
app.MapAffiantSignalR<ChatHub>();
app.Run();

On SQLite, MigrateAffiantSchemaAsync calls EnsureCreatedAsync — no migration history, fine for local development; on PostgreSQL it runs the packaged EF migrations. MapAffiantSignalR<ChatHub>() maps the hub at /hubs/affiant by default.

MapDevSeamEndpoints() is the sample’s own — the way to see a card with no model key. Two routes, POST /api/dev/propose and GET /api/dev/docket/{id}, behind one gate: env.IsDevelopment() and DevSeam:Enabled in configuration, re-read per request, with a plain 404 otherwise — indistinguishable from an entry that does not exist. DevSeam:Enabled is set only in appsettings.Development.json. The seam builds its affidavit with the same LeaveProposalBuilder and the same projection a real tool call uses and files it through the real ReviewGate; policy evaluation, docket entry and card broadcast all happen. The one step it skips is the model deciding to call a write tool. It is a development seam and nothing else: it files writes with no credential, which is exactly why it is gated to local development. Read DevSeam/DevSeamEndpoints.cs before copying the idea into anything of your own — and read two of its comments as history rather than as description. They state the framework as it stood at 1.0.0-beta.1. The gate no longer stamps one host-wide deadline ahead of the policy chain: it runs the chain first and takes the deadline from the verdict’s own time-to-live, falling back to the host-wide default only when the verdict names none. And the shipped docket stores no longer wait for the sweep to call an entry expired: a row past its deadline reads Expired on every read, whether or not a sweep has run. The seam’s second ReviewGate still does what its own comment says it does — the same type on the same stores and transport, with a shortened default clock.

Run the host with the seam available and a fixed port:

Terminal window
ASPNETCORE_ENVIRONMENT=Development ASPNETCORE_URLS=http://localhost:5077 \
dotnet run --project samples/quickstart-host

Open http://localhost:5077. The page joins a session over SignalR and waits. Then, either:

With a model key — set OPENAI_API_KEY before dotnet run and type into the chat box:

Two weeks off in November for Amara Silva, family visit.

The model calls request_leave, the tool returns a proposal instead of writing, the framework files it and ends the turn, and the card appears.

Without one — the page publishes the session it joined as data-session-id on the element with data-testid="transcript", and remembers it in localStorage under affiant:sessionId. A proposal filed into any other session is broadcast to a group nobody is listening to, so pass that id:

Terminal window
curl -X POST localhost:5077/api/dev/propose \
-H 'content-type: application/json' \
-d '{"sessionId":"<the session id the page shows>"}'

The card is one <affiant-evidence-card> element. Reading down it:

  • A header naming the operation and the entity — create (or update), LeaveRequest, and either the row’s id or new — and Required by, the entry’s deadline in your local time.
  • A warnings list, when the affidavit carries warnings. The seam’s canned proposal deliberately leaves Employee blank, so you get “Employee is required and has no value — a reviewer must supply one.”
  • One row per field, in the order the schema declared them: Employee, StartDate, EndDate, LeaveType, Days, Reason. Each row carries the field name, a required marker when it is mandatory, and its kindtext, date, enum or number, derived from the schema, which is how a reviewer UI ends up driven by the affidavit rather than by a hand-written form.
  • The proposed value, labelled Proposed. A null or absent value renders as a visible empty, never as a blank space.
  • The previous value, labelled Previously, on an update — the row as the database holds it today, beside the row as proposed. On a create there is no previous value and the label is absent.
  • A provenance badge naming the source — UserStated for a value the caller stated, External for one read from the record, Empty for one with nothing behind it — and a confidence meter with the number beside it to two decimals. A field with no source, or no confidence, or neither, is flagged with a plain sentence saying which — the blank Employee reads “No source and no confidence — nothing stands behind this value.” Which system and which record a value was read from is no longer free text under the badge: at 1.0.0-beta.3 the External tag step 4’s projection mints sets Evidence: null and carries an external-ref Binding naming HrDb and LeaveRequest/<id> instead. The card element this sample vendors (@affiant/evidence-card 0.1.0-alpha.0) does not render a binding at all, and the free-text line it does look for it reads under a name the wire does not use: it renders tag.evidence, where ProvenanceTag.Evidence serializes as note. So neither half reaches the screen from this renderer at 1.0.0-beta.3 — a card here shows no note under any field, whatever a tag carries. The facts are on the wire and in the Docket row either way.
  • One of: Annual, Sick, Personal under an enum field, from the schema’s Enum.
  • An Amend box on every field, placeholdered leave blank to accept (or the allowed values, for an enum).
  • A footer with three totals — the aggregate confidence as a meter, then the populated fields confidence as a second meter and the empty fields count — and then Approve and Reject. The two companions render only when the affidavit carries a number for them, which at 1.0.0-beta.3 it does — Affidavit.Create computes all three, and PopulatedConfidence comes back null only when every proposed field reads Empty. Type into any Amend box and Approve relabels itself Approve with amendments, so the button always says what pressing it would send.

Around the card, the host’s own page adds what a reviewer’s workflow needs and the element deliberately does not decide: a status badge with seven labels (Pending, Expiring soon, Submitting…, Approved, Rejected, Expired, Referred), an Employee picker fed from GET /api/employees so a name comes from the system of record rather than from typing, a Resubmit button, and a table of the rows actually written. The picker writes through the card’s own amendment input rather than around it, so a picked value is an amendment like any other. Approve is held disabled while a mandatory field is empty — that is this host’s rule, not the framework’s, which flags the field and leaves a human reviewer free to approve anyway. The one approval the framework itself withholds over an empty mandatory field is an automatic one: a Standing Order verdict is degraded to ReviewerConfirmation, blocked reason mandatory-field-empty, whenever a mandatory field’s tag in force reads Empty. That is why the seam leaves Employee blank: it is the fastest way to see an unsourced field, and what a reviewer has to do about it.

The aggregate confidence in the footer reads 0.00 while Employee is blank, because step 4’s projection takes the minimum across every proposed field with an unsourced field counting zero. That is the framework’s rule as well as this host’s since 1.0.0-beta.3: the shipped SchemaDrivenAffidavitProjection averaged the fields that had a source, so a host on the default could show a high total beside a field that read Empty, and it now takes the same minimum. The two numbers that stand beside it — PopulatedConfidence, the lowest confidence across the fields that are populated, and EmptyFieldCount, how many are not — are properties of the Affidavit record as of this release, computed by Affidavit.Create. Step 4’s projection also states all three in the warnings line, which is where the card renders them in words.

1.0.0-beta.3 also closed the gap this card used to sit on top of. Until this release the framework recorded what was approved, not who approved it: the reviewer’s id was on the DocketEntry from the moment it was filed, and nothing said who pressed Approve. Nothing recorded it anywhere, so a host with an audit requirement that asks who, specifically, approved this had nowhere to look. A DocketEntry now carries an Attestation ({ By, At, EntryId }), written by the decision itself, and it says how the claim was made as well as by whom. A Principal.Member attests member. A Principal.Service carrying both an asserted member and a relay assertion attests member-via-relay, naming the person and the relay — the record must not read as though the person signed in directly. A Principal.Service with neither is refused: a machine cannot agree to a write in a person’s name. A Standing Order’s approval is attested too, standing-order naming the policy and the version it fired under, in the same operation that files the entry approved, so there is no window in which an approved write has no attribution. The rule is structural: every attestor kind’s constructor is private, and the only member attestation a host can produce is Attestor.Member.Of’s, whose parameter type is Principal.Member — the public Attestor.For reaches it only by handing a member principal to that same factory, and there is no overload, no optional parameter and no with expression by which a machine caller gets one. DocketEntry.ReviewerUserId, which can only name one id, is deprecated in its favour.

This sample’s UI does not surface any of it. The attestation is on the row by the time ChatHub.ApproveEntry returns; the status badge, the record table and the Evidence Card element all show what was approved, and none of them yet shows who. A portable, signed export for an auditor remains a roadmap item.

Press Approve and the page calls ChatHub.ApproveEntry with the docket id and whatever you typed. The hub builds a DecisionContext from the connection’s own identity and hands it to ReviewGate.HandleDecisionAsync, which since 1.0.0-beta.3 runs one decision core and hands nothing off before it finishes: the principal; the tenant-scoped row, where a row in another tenant is entry-not-found; the host’s authorization port from step 7; the state and blocked checks; the attestation. DocketEntry.Status moves PendingApproved under a guard, so a double-click affects zero rows the second time; the hub reads the entry back, and only then does LeaveWriteExecutor run. A row appears in the table at the bottom of the page. That table is the proof: an approved card with no row would mean the write port never ran.

Amend a field first and it is the same path. The amendments travel with the decision, the framework persists them on the entry, and this sample’s executor applies them over the sworn value — the reviewer’s word outranks the model’s. The gate now folds them into an amended Affidavit that travels beside the proposal and comes back as ReviewOutcome.Approved.AmendedAffidavit: DocketEntry.Envelope still holds the record the reviewer was shown, the amended field’s current tag is UserStated carrying a reviewer-act binding naming the decision — appended on top of the chain, so the machine’s pre-correction tag is preserved beneath it — and the three confidence numbers are recomputed, so a card can no longer show a corrected value under a number that was never about that value. A host that stamped reviewer provenance by hand should read that record instead of minting its own.

Press Reject and the entry goes to Rejected. No executor call, no row, ever.

Terminal state on the page always comes from the server’s answer, never from the click. That matters because of the deadline.

An unreviewed entry is state, not a timeout. Each entry gets a time to live — 30 minutes by default — and DocketExpiryService sweeps every 30 seconds. Two minutes before the deadline the framework broadcasts DocketExpiring and the badge reads Expiring soon; at the deadline it broadcasts DocketExpired, the badge reads Expired, the card goes read-only, and Resubmit appears.

A decision that arrives after the deadline writes nothing and comes back ReviewOutcome.Refused with Code decision-expired — not ReviewOutcome.Expired, which HandleDecisionAsync no longer returns on any path. Because the act names who made it, the reviewer’s edits are kept on the entry, written to PreservedAmendments rather than to Amendments — what an approval accepted and what a refused caller typed are different facts — and Detail reads amendments-preserved when that happened. The gate also persists the expiry the row already reads as, through a guarded PendingExpired transition, and re-broadcasts only when its own write is the one that moved the row — so a repeat late decision changes nothing and notifies nobody.

Every other way a decision can fail to land answers Refused too, each with its own code: an entry that does not exist or sits in another tenant is entry-not-found, one already decided is decision-not-pending, one that lost the transition race is decision-lost-race, and one carrying a blocked marker is decision-not-pending with the marker’s own reason in Detail. At 1.0.0-beta.1 all of these — the late decision included — reported ReviewOutcome.Expired, except a lost race, which reported the winner’s outcome; blocked markers did not exist at all. A host that branched on Expired replaces those arms with one Refused arm and reads Code; an Expired arm kept for a late decision is now dead code. ReviewOutcome.Expired still exists, and two paths still mint it, neither of them a decision. One is the timeout inside FileReviewAsync — the obsolete blocking-review call this walkthrough’s wiring never makes: ReviewGateFilter files with FileForReviewAsync and the hub in step 8 decides with HandleDecisionAsync. The other is the idempotent replay inside FileForReviewAsync, which answers a re-filed proposal with the status the existing entry already carries, Expired among them — FileReviewAsync reaches that replay only by calling it. The deadline comparison is inclusive, too: a decision arriving at exactly ExpiresAt is late, where at 1.0.0-beta.1 only one arriving strictly after it was.

Resubmit calls back into the hub, and ReviewGate.ResubmitAsync mints a fresh entry cloning the expired one’s affidavit, prefilled from the superseded row’s preserved amendments, and writes the lineage on both rows; its card opens with a note — “Resubmission — this review expired once and a reviewer had already amended:” — listing each field and the value it was amended to. Work done before the window lapsed is not lost, and the second reviewer can see it.

To watch the whole lifecycle in under a minute rather than half an hour, give the seam a short clock: -d '{"sessionId":"…","ttlSeconds":45}'. To see an update-shaped card — the entity id and every field’s current value under Previously — name a row: -d '{"sessionId":"…","entityId":1}'. That row has to exist: the seed creates the four employees the picker reads and no leave requests at all, so id 1 is the row your first approved create wrote, and an id no row carries answers 400 naming it. An update states only what you put in overrides; every other field is read off that row and tagged External, so add one to see the contrast: -d '{"sessionId":"…","entityId":1,"overrides":{"EndDate":"2026-12-25"}}'.

<affiant-evidence-card> is a dependency-free custom element with an open shadow root: no Lit, no React, no runtime dependency. Hand it an EvidenceCardRequest on its request property (or point its src at a URL) and it renders the affidavit; it emits affiant-decision with { docketId, decision, amendments } when a reviewer acts, and the event bubbles and is composed, so a host can listen on any ancestor. Every value is written with textContent, never innerHTML — the values were proposed by an agent, and a card that rendered them as markup would be a hole. Four custom properties (--affiant-card-bg, --affiant-card-fg, --affiant-accent, --affiant-warn) are the whole theming surface. Nothing fails silently: a src fetch that errors renders a visible “Could not load the evidence: …” state carrying the reason, a null request renders an explicit empty state, and a field whose value is null or undefined renders the word empty rather than a blank cell. The two ARIA roles it sets are meter, on each confidence bar, and note, on a resubmission’s prior-amendment list.

The sample vendors the element’s built files under wwwroot/affiant-evidence-card/, with a VERSION file beside them naming the package version and the commit they were built from, so the page runs from a clone with no package manager involved. A host that has a build step installs the package instead:

Terminal window
npm install @affiant/evidence-card

@affiant/evidence-card is on npm at 0.1.0-alpha.0, published on 2026-09-06 under the alpha dist-tag, with SLSA provenance, so that command resolves. The sample vendors that same 0.1.0-alpha.0, built from Sakwala/affiant-ts. Check VERSION in the sample for what version is current today.

The page around it is wwwroot/index.html and wwwroot/app.js — no framework, no build step, and 353 lines of JavaScript. It listens for the five client methods the framework broadcasts — ConfirmAction carries an Evidence Card; DocketExpiring and DocketExpired are the expiry lifecycle; SystemNotification is how the host says the model is not configured; and ReceiveToken, the wire name of TransportEvent.AgentMessage, carries the assistant’s own text, which this sample’s hub sends from SendMessage. One behaviour in it is worth copying: the framework re-broadcasts pending entries’ cards as the sweep works through them, and again on reconnect — at-least-once, because a broadcast to a session with nobody connected still reports success. A client must treat a repeat for a card it already shows as idempotent, and for this page that means leaving it alone: setting request again re-renders, and re-rendering discards any amendment the reviewer has typed and not yet submitted. A reviewer who paused mid-edit for thirty seconds would silently lose their work. See Transport & Wire Contract for the payload shapes.

  • The sample’s README — how to run it, the development seam in full, and which parts of the reviewer’s page belong to the card element and which to the host. Its conformance notes are written against 1.0.0-beta.1, and the four gaps they record are closed at this release: the default projection’s mean, the two companion confidence numbers having nowhere on the Affidavit record to live, the deadline stamped before the policy chain, and expiry not computed on read.
  • The review-lifecycle deck — eight browser specs, no model key, run against a host you started yourself. Seven pass at 1.0.0-beta.3: approve round trip, reject round trip, typed inputs driven by field metadata, the employee picker fed from a live endpoint, the mandatory-field gate, the expiry lifecycle, and a re-broadcast card absorbed rather than re-rendered. The eighth — a late decision, whose amendments survive expiry and prefill the resubmission — does not: before clicking it asserts that the store still reads Pending past the deadline, which stopped being true when the docket stores began projecting expiry onto every read, so the spec stops at that pre-condition. The behaviour itself is the one described under When it expires; the spec covering it is written against the older store.
  • Try it live — two first-party host applications running in public, on the other two interception backends, with the same card.
  • Affidavits & Provenance and Tool Envelopes for the full type shapes this page only used; Docket & Evidence Cards and Review Gate & Write Executors for the state machine behind steps 8 and 10; Authoring Write Tools for IFieldMapper<T>, multiple entity types and error handling; The Compliance Harness for proving a write strategy’s provenance is substantive rather than merely well-shaped.
  • The rulebook — the normative specification the implementation is measured against, INVARIANTS.md. Where this page and that document disagree, the document is the standard and the difference is a known gap, stated rather than hidden.