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(andOPENAI_BASE_URLif 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:
git clone https://github.com/Sakwala/affiant.gitASPNETCORE_ENVIRONMENT=Development ASPNETCORE_URLS=http://localhost:5077 \ dotnet run --project affiant/samples/quickstart-hostWhat you need
Section titled “What you need”The .NET 10 SDK, an ASP.NET Core project (dotnet new web), and five packages:
dotnet add package Affiant.Core --version 1.0.0-beta.3dotnet add package Affiant.SemanticKernel --version 1.0.0-beta.3dotnet add package Affiant.EntityFramework --version 1.0.0-beta.3dotnet add package Affiant.Docket --version 1.0.0-beta.3dotnet add package Affiant.Transport.SignalR --version 1.0.0-beta.31.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.
1. Register the framework
Section titled “1. Register the framework”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.
3. Declare the field schema
Section titled “3. Declare the field schema”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.
4. Project the affidavit
Section titled “4. Project the affidavit”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.EntityId— which row is being changed.AffidavitField.PreviousValueon 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.
What the record holds today
Section titled “What the record holds today”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.
The host’s own projection
Section titled “The host’s own projection”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.
5. Write the tools
Section titled “5. Write the tools”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.
6. Register the tools and the plugins
Section titled “6. Register the tools and the plugins”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.
7. Supply the review context
Section titled “7. Supply the review context”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.}Who may decide
Section titled “Who may decide”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>();9. Build and run
Section titled “9. Build and run”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.
10. See the card
Section titled “10. See the card”Run the host with the seam available and a fixed port:
ASPNETCORE_ENVIRONMENT=Development ASPNETCORE_URLS=http://localhost:5077 \ dotnet run --project samples/quickstart-hostOpen 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:
curl -X POST localhost:5077/api/dev/propose \ -H 'content-type: application/json' \ -d '{"sessionId":"<the session id the page shows>"}'What renders
Section titled “What renders”The card is one <affiant-evidence-card> element. Reading down it:
- A header naming the operation and the entity —
create(orupdate),LeaveRequest, and either the row’s id ornew— 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
Employeeblank, 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 kind —text,date,enumornumber, 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
nullor 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 —
UserStatedfor a value the caller stated,Externalfor one read from the record,Emptyfor 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 blankEmployeereads “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: at1.0.0-beta.3theExternaltag step 4’s projection mints setsEvidence: nulland carries anexternal-refBindingnamingHrDbandLeaveRequest/<id>instead. The card element this sample vendors (@affiant/evidence-card0.1.0-alpha.0) does not render abindingat all, and the free-text line it does look for it reads under a name the wire does not use: it renderstag.evidence, whereProvenanceTag.Evidenceserializes asnote. So neither half reaches the screen from this renderer at1.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, Personalunder an enum field, from the schema’sEnum.- 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.3it does —Affidavit.Createcomputes all three, andPopulatedConfidencecomes backnullonly when every proposed field readsEmpty. 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.
Where the decision lands
Section titled “Where the decision lands”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 Pending → Approved 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.
When it expires
Section titled “When it expires”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 Pending → Expired 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"}}'.
The element
Section titled “The element”<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:
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.
Where to go next
Section titled “Where to go next”- 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 theAffidavitrecord 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 readsPendingpast 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.