Skip to content

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

  1. If MinimumConfidenceThreshold is set and the candidate’s confidence falls below it, the field is skipped — recorded as not merged, with a reason, but otherwise ignored.
  2. A ProvenanceTag is built via ProvenanceTag.FromInference(InferenceSource source, string fieldName, float confidence = 0.6f, ProvenanceBinding? binding = null, DateTimeOffset? at = null). The leading InferenceSource decides the grade: the step passes Conversation when the port reported the value as literally present in the turn ("presence": "literal") and Inferred otherwise — a port that does not say has not claimed the value was there to read. The at argument comes from the step’s own injected clock, never DateTimeOffset.UtcNow, and the binding argument is the step’s too: when the port names an utteranceSpan for the field, the step builds a ProvenanceBinding.UtteranceSpan from its start — mandatory, so a span object with no readable integer start yields no binding at all — its end or its length, 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 them presence is always absent, the grade is always Inferred, and the tag is always unbound. A Conversation grade and an inference-minted binding need a host-written IInferenceCompletionPort that 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.
  3. The candidate is compared against whatever ProvenanceChain ContextFabric.GetFieldChain already holds for that field name. If nothing exists yet, the candidate wins outright. If a chain exists, the candidate is put to ProvenanceTag.Beats against the chain’s Current tag — the framework’s one implementation of the comparison, which ProvenanceChain.Merge and the schema-driven projection call too — and it has three clauses, not two: higher confidence wins; on a tie, the source with the lower ProvenanceSource enum ordinal wins (more deterministic beats less deterministic); and on equal confidence and equal source, a tag carrying a ProvenanceBinding displaces 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 in Prior.
  4. ContextFabric.SetFieldChain records 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.
  5. Only if the candidate won does its value get upserted into the fabric’s EntityRef for strategy.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.

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.