Context Fabric
Affiant’s own filter pipeline gives it a place to sit between the LLM and a tool call — before
it runs, after it returns, or both. The seam is the framework’s backend-neutral
IToolInvocationFilter, run by ToolInvocationPipeline in Affiant.Core.Services, with the
Semantic Kernel, Microsoft Agent Framework and Microsoft.Extensions.AI bridges over it.
ContextFabric is what those filters read from and write to instead of the conversation
transcript. It is the framework’s single place to ask “what have the tool calls so far established,
and how sure of it are we?” — a question that neither a raw chat history nor a prompt string can
answer deterministically. Nothing puts the fabric in front of the model: its contents feed the
filters and the projection, never the prompt.
ContextFabric: what it holds
Section titled “ContextFabric: what it holds”ContextFabric is a sealed class in Affiant.Core.Services that tracks two things: entities
(as EntityRef, covered in Tool Envelopes) and, separately, a
ProvenanceChain per field key — the audit trail behind a single field’s current value, from
Affidavits & Provenance. The listing below is the type’s
shape, not code to paste — member bodies are omitted.
public sealed class ContextFabric : IContextFabric{ public void Upsert(EntityRef entityRef); public EntityRef? GetByKey(string entityKey); public Dictionary<string, EntityRef> Snapshot(); public void MergeFrom(IEnumerable<EntityRef> entityRefs); public void Clear();
public ProvenanceChain? GetFieldChain(string fieldKey); public void SetFieldChain(string fieldKey, ProvenanceChain chain);}Upsert is a merge, not a replace: if an EntityRef with the same EntityId already exists,
the incoming call’s Fields overlay the existing ones key-by-key — fields not present in the
new call are preserved — and DisplayName takes the incoming value, while EntityType keeps
the incumbent’s, so upserting the same EntityId under a different type silently keeps the
old type. This is what lets a customer fetched by one read tool accumulate additional fields
from a second, unrelated read tool without either tool needing to know about the other, for as
long as both calls resolve the same fabric instance. The registration is Scoped (below), and
nothing in the framework carries one instance’s contents into another; which instance a tool call
resolves follows the provider its backend’s bridge hands the pipeline — which
Interception Backends sets out per backend — or a fresh scope
of the pipeline’s own, where the bridge hands none.
GetFieldChain / SetFieldChain are the field-level counterpart, keyed by field name rather
than entity ID. They exist specifically to support the confidence-based merge rule that
TaskInferenceStep runs — covered below — by giving it one place, shared by every filter in the
turn, to read the current ProvenanceChain for a field before deciding whether a new candidate
value should win.
IContextFabric — the interface ContextFabric implements — lives in
Affiant.Abstractions.Interfaces, separate from the concrete class in Affiant.Core:
public interface IContextFabric{ ProvenanceChain? GetFieldChain(string fieldName); void SetFieldChain(string fieldName, ProvenanceChain chain); EntityRef? GetByKey(string key); void Upsert(EntityRef entity);}The split matters for the package DAG: Affiant.Abstractions has zero dependencies on other
Affiant packages, so an Abstractions-level interface like IDeterministicFieldSource — the
[Obsolete] field source the projection ladder still tries, described under Rule 7 in
Seven Normative Rules — can take an IContextFabric parameter
without Abstractions needing to depend on Core, where the concrete ContextFabric class lives.
See Packages for the full dependency graph.
The lifetime is Scoped, and the framework states that as a rule rather than enforcing it.
AddAffiantCore() registers ContextFabric with TryAddScoped, and aliases IContextFabric to
the same scoped instance. A Scoped registration on its own settles nothing about how far one fabric
reaches: ToolInvocationPipeline resolves its filters — and with them the scoped fabric — from the
ambient provider the backend’s bridge hands it, and opens a scope of its own only when it is handed
none, so how wide that provider is decides how wide the fabric is.
Interception Backends sets out the provider each bridge
supplies. The source is explicit that hosts must not re-register it as a singleton — and nothing
checks. Both registrations are TryAdd — the concrete class as well as the IContextFabric alias
— so a host that calls services.AddSingleton<ContextFabric>() before AddAffiantCore() keeps its
own registration, and the alias resolves to it: one fabric for the whole process, silently, reaching
every filter, the extractor base and the inference step alike. No validator objects. That is why the
framework words the lifetime as a prohibition rather than as a default.
What that lifetime rule is not is a substitution seam. Both registrations stepping aside does let a
host put its own IContextFabric implementation in front of the filters that take the interface —
but ContextExtractor and TaskInferenceStep constructor-inject the concrete ContextFabric,
which never reaches those two, and ContextFabric is sealed, so there is no subclass to register in
its place either. At 1.0.0-beta.3 a host that needs different fabric behaviour has no supported
seam for it; what a host can change is the instance and its lifetime, and the rule above says not
to.
Nothing in the framework rehydrates a fabric. The single session-rehydration service that
ships — SessionRehydrator in Affiant.SemanticKernel.Services — rebuilds a reconnecting
session’s chat history, its ConversationContext and its Docket entries, and never the fabric.
That rehydrator’s IDocketStore.LoadContextAsync call is the only consumer
call in the framework — the store implementations forward the member, nothing else asks for it —
and it hands what it loads back to its caller; the matching SaveContextAsync is called by nothing
in the framework at all. So writing that state is the host’s own work, and so is
re-upserting it into a later turn’s fabric — and a ConversationContext is a session id and a
dictionary of EntityRefs, so a field’s ProvenanceChain has no place in it either way. The chains
that outlive the fabric are the ones already sworn into a filed Affidavit: the Docket row stores
them beside it.
ContextExtractor: filling the fabric from read tools
Section titled “ContextExtractor: filling the fabric from read tools”ContextExtractor is an abstract base class in Affiant.Core.Filters that hosts subclass once
per read tool (or per closely related group of read tools) whose results the rest of the turn
should be able to read — a later tool call’s filters, or an IFieldResolver at projection time,
which is handed the fabric itself. The listing below is the type’s shape, not code to paste —
member bodies are omitted:
public abstract class ContextExtractor : IToolInvocationFilter{ protected readonly ContextFabric ContextFabric; protected readonly ILogger Logger;
protected ContextExtractor(ContextFabric contextFabric, ILogger logger);
public Task OnToolInvocationAsync( ToolInvocationContext context, Func<ToolInvocationContext, Task> next, CancellationToken cancellationToken = default);
protected abstract bool MatchesTool(string toolName); protected abstract Task ExtractAsync(ReadResult result, ToolInvocationContext context); protected void EmitEntity(EntityRef entityRef);}The base class implements IToolInvocationFilter — the framework’s own backend-neutral filter
interface in Affiant.Abstractions.Interfaces, not a Semantic Kernel type, so the same extractor
runs unchanged on all three backends. It does the undifferentiated work: it awaits next(context)
to let the wrapped tool run first, then checks MatchesTool(context.FunctionName), deserializes the
result as a ToolEnvelope, and — only if it comes back as a ReadResult with a non-empty Entities
array — calls the subclass’s ExtractAsync. A failing override never discards the tool’s genuine
result: the base catches every non-cancellation exception, logs it, and emits an
affiant.extractor.failed event, leaving context.Result exactly as the tool produced it. A
subclass’s job is just the domain-specific two lines:
using Affiant.Abstractions.Models;using Affiant.Core.Filters;using Affiant.Core.Services;using Microsoft.Extensions.Logging;
public class CustomerSearchExtractor(ContextFabric contextFabric, ILogger<CustomerSearchExtractor> logger) : ContextExtractor(contextFabric, logger){ protected override bool MatchesTool(string toolName) => toolName.Equals("SearchCustomers", StringComparison.OrdinalIgnoreCase);
protected override Task ExtractAsync(ReadResult result, ToolInvocationContext context) { foreach (var entity in result.Entities) EmitEntity(entity); return Task.CompletedTask; }}EmitEntity calls ContextFabric.Upsert and logs at debug level; a subclass never touches
ContextFabric.Upsert directly or parses JSON itself. Register the subclass against the neutral
filter interface: services.AddScoped<IToolInvocationFilter, CustomerSearchExtractor>().
Not every read tool needs one. A tool with no meaningful entities to remember — “what’s today’s
date?” — returns an empty Entities array and there is nothing for an extractor to do.
TaskInferenceStep: merging inferred values deterministically
Section titled “TaskInferenceStep: merging inferred values deterministically”Where ContextExtractor fills the fabric from a read tool’s structured results,
TaskInferenceStep merges inferred field values — produced by an LLM given a schema — into
the same fabric, using the confidence hierarchy from
Affidavits & Provenance to decide whether a candidate
value should overwrite what’s already there. The listing below is the type’s shape, not code to
paste — member bodies are omitted:
public sealed class TaskInferenceStep{ public TaskInferenceStep( ContextFabric contextFabric, ILogger<TaskInferenceStep> logger, TimeProvider? timeProvider = null);
public Task<TaskInferenceResult> ExecuteAsync( ITaskInferenceStrategy strategy, JsonElement llmStructuredOutput, CancellationToken cancellationToken = default);
// Plus one static helper: ResolveByConfidence(a, b) returns whichever of two tags // wins the merge, which is the same comparison ProvenanceTag.Beats makes. public static ProvenanceTag ResolveByConfidence(ProvenanceTag a, ProvenanceTag b);}The strategy parameter — an ITaskInferenceStrategy — is a host-authored schema for one write
tool. It is registered in DI: AddAffiantTool<TStrategy>() adds it with
TryAddSingleton<TStrategy>(). What the step does not do is constructor-inject it. Each of the two
filters that drive the step looks the strategy’s type up in the tool’s AffiantToolDescriptor,
resolves that type from the invocation’s own service provider, and hands the instance in for the
merge — which is what lets one host run several write tools, each with its own schema, through one
step:
public interface ITaskInferenceStrategy{ string EntityName { get; } IReadOnlyList<TaskInferenceField> Fields { get; } double? MinimumConfidenceThreshold { get; }}
public record TaskInferenceField( string Name, string JsonType, string Description, int? MaxLength = null, string? Pattern = null, IReadOnlyList<string>? Enum = null, bool Required = false, string? Format = null, bool Projected = true);Fields declares the shape the framework asks the LLM to produce as structured output, but not
every parameter on the record reaches the model. All three shipped inference ports build the same
prompt, and it carries a field’s Name, Description, JsonType, its Enum (as “one of: …”)
and its Pattern — nothing else. Required is not part of the ask: it is a card-gating flag the
projection copies onto the projected AffidavitField as IsMandatory. MaxLength reaches
nothing at all — at 1.0.0-beta.3 no code in the framework reads it, so a host that sets one gets
no prompt text, no validation and no effect on the card. Two further parameters are additive:
Format is an explicit semantic hint ("date") the shipped projection uses to derive an
AffidavitField.Kind where JsonType alone is ambiguous, and Projected (default true) says
whether the field reaches the Evidence Card at all — set it false to declare an extraction
field, one the LLM is still asked for and that is still merged into the fabric, but that surfaces
to IFieldResolver implementations as an ExtractionFact instead of becoming a card field.
Declaring a field Projected: false and Required: true is rejected when the projection is
constructed: a fact that never becomes a card field cannot gate the card. EntityName is the key
TaskInferenceStep upserts merged values under in the fabric.
ExecuteAsync expects llmStructuredOutput to be a JSON object where each property matches a
declared field name and carries a "value" and a "confidence" (a float, or a string parsed
as one). Four malformed shapes are skipped in silence before any of the steps below and appear
nowhere in the result’s MergedFields, so a field that vanished this way leaves no trace to debug
from: a property carrying no "value", or no "confidence"; a "value" that is not a JSON scalar
(an object, an array, or JSON null); an empty string as the value; and a "confidence" string
that will not parse as a float. Two malformed shapes do not skip quietly, and both throw where the
step reads a property as a string: a "confidence" that is an object, an array, true or false,
and a "presence" that is neither a string nor JSON null — a number, true, false, an object
or an array. The presence read comes a little later than the confidence one, after the threshold
check, so a field below the threshold never reaches it. Either throw abandons the merge for every
field after it, and leaves every field before it half-merged: their chains were already written to
the fabric one at a time inside the loop, but the single Upsert that carries the winning values
onto the EntityRef sits after the loop and never runs, so this run’s winning values never reach
the entity. Whichever filter drove the step catches it — TaskInferenceMergeFilter records it as
affiant.extractor.failed, and TaskInferenceRunner on the pre-tool path as inference.failed,
returning an empty result — so the turn continues with those fields unmerged. A JSON null
confidence reads as null rather than throwing, and skips like the four above. For each remaining
field present in both the schema and the LLM’s response:
- If
MinimumConfidenceThresholdis set and the candidate’s confidence falls below it, the field is skipped — recorded as not merged, with a reason, but otherwise ignored. - A
ProvenanceTagis built viaProvenanceTag.FromInference(InferenceSource source, string fieldName, float confidence = 0.6f, ProvenanceBinding? binding = null, DateTimeOffset? at = null). The leadingInferenceSourcedecides the grade: the step passesConversationwhen the port reported the value as literally present in the turn ("presence": "literal") andInferredotherwise — a port that does not say has not claimed the value was there to read. Theatargument comes from the step’s own injected clock, neverDateTimeOffset.UtcNow, and thebindingargument is the step’s too: when the port names anutteranceSpanfor the field, the step builds aProvenanceBinding.UtteranceSpanfrom itsstart— mandatory, so a span object with no readable integerstartyields no binding at all — itsendor itslength, falling back to the reported value’s own character length when it names neither, and a SHA-256 digest of the reported value; a port that names no span produces an unbound tag. Neither key is in reach of a shipped port: the three of them build the same prompt, and it asks for a"value"and a"confidence"and for nothing else, so with any of thempresenceis always absent, the grade is alwaysInferred, and the tag is always unbound. AConversationgrade and an inference-minted binding need a host-writtenIInferenceCompletionPortthat reports them — inside the framework repository the compliance harness’s fixture ports are the only thing that does — and step 3’s third clause is reachable from the inference path only through such a port. - The candidate is compared against whatever
ProvenanceChainContextFabric.GetFieldChainalready holds for that field name. If nothing exists yet, the candidate wins outright. If a chain exists, the candidate is put toProvenanceTag.Beatsagainst the chain’sCurrenttag — the framework’s one implementation of the comparison, whichProvenanceChain.Mergeand the schema-driven projection call too — and it has three clauses, not two: higher confidence wins; on a tie, the source with the lowerProvenanceSourceenum ordinal wins (more deterministic beats less deterministic); and on equal confidence and equal source, a tag carrying aProvenanceBindingdisplaces one carrying none, because a value an auditor can go and re-check is more than the same value as an unbound literal. An exact tie on all three — same confidence, same grade, and both bound or both unbound — leaves the incumbent in force: it was there first and the challenger brings nothing new. The losing tag is never discarded either way: it’s preserved inPrior. ContextFabric.SetFieldChainrecords the (possibly updated) chain regardless of which side won, so the fabric always reflects the fullest picture of what’s been proposed for that field, not just what’s currently winning.- Only if the candidate won does its value get upserted into the fabric’s
EntityRefforstrategy.EntityName.
This is the concrete mechanism behind the merge rule described in Affidavits & Provenance: when a task-inference step produces an LLM-inferred value for a field the fabric already holds from a higher-confidence source, the higher-confidence value wins, and the loser is preserved in the chain rather than discarded.
Which tag can be on the incumbent side of that comparison is decided by what put a
ProvenanceChain into the fabric for that field, and at 1.0.0-beta.3 that is one of two things: an
earlier TaskInferenceStep run in the same fabric scope, or a direct SetFieldChain call — host
code’s, or the compliance harness’s as it seeds a conformance case. The quickstart sample’s
proposal builder makes the direct call: for every stated field it is handed it mints a UserStated
tag at confidence 1.0, bound to the form input, through ProvenanceTag.FromUser — a grade that
fits a value a person typed into a control, and an over-grade for one a model wrote from the
conversation, whose honest grade is Conversation or Inferred. Those tags never meet a merge,
though, and the sample is explicit about why: the builder writes them into a ContextFabric it
constructs for that one proposal and then drops, not into the instance the framework registers,
because every value on its card comes straight off the tool call’s own arguments and there is
nothing accumulating to read. That sample also never calls AddAffiantInferenceOrchestration and
registers no IInferenceCompletionPort, so no pre-tool inference runs in it at all.
The contest the merge rule is written for is the one between two runs against the same fabric: a
field an earlier run merged at confidence 0.9 holds against a later candidate the port reported
at 0.6, on confidence alone, and a chain a host seeded with its own SetFieldChain call is
weighed the same way. Neither loser is thrown away, and neither outcome needs a prompt instruction
like “don’t overwrite a value you already know”: the comparison is arithmetic on two records, and
there is nothing for the LLM to get right or wrong about it.
A ContextExtractor is not one of those two. It upserts EntityRefs, and an EntityRef carries
no provenance at all, so an extractor mints no tag and writes no chain — ProvenanceTag.FromTool
exists for a host that wants to swear to a tool-sourced value itself, and nothing in the framework
calls it. That has a visible consequence at projection time: a field an extractor alone supplied
has an entity value but no chain, so unless an IFieldResolver or a legacy
IDeterministicFieldSource speaks for it, the schema-driven projection swears it
ProvenanceTag.Empty at confidence 0 and files it with a null value rather than carrying the
value behind a tag that says nothing is known about where it came from.
Affiant.Core.Filters.TaskInferenceMergeFilter is the ICompletionStageFilter (itself an
IToolInvocationFilter) that wires TaskInferenceStep into the pipeline automatically: it fires
after each auto-invoked function, checks whether the tool has a registered InferenceStrategy (via
its AffiantToolDescriptor in IAffiantToolRegistry), and — only for tools that do — forwards the
JSON result to TaskInferenceStep.ExecuteAsync. Read tools and any function without a
registered write descriptor are skipped without error.
Inference also runs before a write tool executes, and that pre-tool variant is neither Semantic
Kernel-specific nor in an adapter package: it is InferenceTriggerFilter in Affiant.Core.Filters,
which asks each registered IInferenceTrigger whether to run, resolves the tool’s
ITaskInferenceStrategy — its type from IAffiantToolRegistry, the instance from the invocation’s
own service provider — keeps a once-per-conversation-function-turn idempotency record in the fabric
itself, and forwards through TaskInferenceRunner in Affiant.Core.Services to the same
TaskInferenceStep.ExecuteAsync merge. All three backends register it as a Scoped
IToolInvocationFilter: Semantic Kernel through AddAffiantInferenceOrchestration, which is where
that package splits the registrations AddAffiantAgentFramework and AddAffiantExtensionsAI each
make in one call.
DeterministicShortCircuit: bypassing the tool body entirely
Section titled “DeterministicShortCircuit: bypassing the tool body entirely”ContextExtractor and TaskInferenceMergeFilter both let the wrapped function run and act on
its result afterward. DeterministicShortCircuit — in Affiant.Core.Services, not the
Affiant.Core.Filters namespace its role suggests — is the one piece of the pipeline positioned
to prevent the wrapped function from running at all:
public sealed class DeterministicShortCircuit(IEnumerable<IIntentInterceptor> interceptors) : IToolInvocationFilter{ public async Task OnToolInvocationAsync( ToolInvocationContext context, Func<ToolInvocationContext, Task> next, CancellationToken cancellationToken = default) { IReadOnlyDictionary<string, object?> args = context.Arguments .ToDictionary(kvp => kvp.Key, kvp => kvp.Value);
foreach (var interceptor in interceptors) { if (await interceptor.MatchesAsync(args, cancellationToken).ConfigureAwait(false)) { context.Result = await interceptor.HandleAsync(args, cancellationToken).ConfigureAwait(false); return; // next(context) is never called — the wrapped tool body never runs } }
await next(context).ConfigureAwait(false); }}IIntentInterceptor is the extension point a host implements:
public interface IIntentInterceptor{ Task<bool> MatchesAsync(IReadOnlyDictionary<string, object?> arguments, CancellationToken cancellationToken = default); Task<object?> HandleAsync(IReadOnlyDictionary<string, object?> arguments, CancellationToken cancellationToken = default);}DeterministicShortCircuit iterates every registered IIntentInterceptor, in registration
order, and asks each one whether the current tool call’s arguments match a condition it owns.
The first interceptor to answer true gets to produce the result directly via HandleAsync —
and the loop stops there; no other interceptor is consulted, and the function the LLM actually
asked to invoke never executes. This is for high-failure-cost intents a host can resolve from
arguments alone, deterministically, without needing a live tool call — the interceptor, not the
tool body or the model, is the source of truth for that specific case.
Rule 4: filters over prompts for determinism
Section titled “Rule 4: filters over prompts for determinism”All three pieces on this page exist because of the fourth of the framework’s Seven Normative Rules: context extraction, task inference, and review gating happen in the framework’s own invocation filters, never in prompt engineering. Prompts request tool calls; filters process the results deterministically. The anti-pattern the rule rules out is an instruction appended to a system prompt like “after calling the tool, extract the customer’s email from the result” — an instruction whose success depends on the model faithfully following it, varies across model providers and even across calls to the same model, and leaves no code path to audit when it silently doesn’t happen.
ContextExtractor, TaskInferenceStep, and DeterministicShortCircuit are the filter-side
alternative to each of the three things a prompt-based approach would otherwise ask a model to
self-report: which entities came out of a read result, which field values should win when two
sources disagree, and which tool calls are deterministic enough to skip the model’s involvement
entirely. None of the three depends on the model volunteering the right behavior — they run as
ordinary C# in the invocation pipeline whether the underlying provider is faithful about
instructions or not.
Where this fits
Section titled “Where this fits”Once fields have been extracted and merged into ContextFabric, something still has to turn
fabric state into the Affidavit a WriteProposal carries — that’s IAffidavitProjection
(default implementation SchemaDrivenAffidavitProjection in Affiant.Core.Services), which
reads each Projected field the active ITaskInferenceStrategy declares and tries three sources
in turn: a registered IFieldResolver first, then the [Obsolete] IDeterministicFieldSource for
that field name, then the fabric’s own ProvenanceChain — and, per Rule 7, tags anything none of
the three resolves as ProvenanceTag.Empty rather than omitting it. Only Projected fields reach
that ladder: the extraction fields above (Projected: false) are excluded from Affidavit.Fields
altogether and collected into ExtractionFacts, which IFieldResolver implementations can read
and nothing else sees. See Affidavits & Provenance for
Rule 7 and Tool Envelopes for what the resulting Affidavit is
wrapped in on its way out of a write tool.