The Seven Normative Rules
Everything else in this documentation — Affidavits & Provenance, Tool Envelopes, the Context Fabric, the Docket, the Review Gate — is an implementation of these seven rules. They are not a style guide. They are the constraints that make the framework’s central claim true: that every field an AI proposes to write carries a sworn, checkable record of where it came from, and that a write a declared tool proposes reaches the host’s own system of record only after that record has been approved through the review flow — by a reviewer who has seen it, or by a Standing Order a human authored in advance and the gate’s own guardrails let through. The gate is a post-tool seam, so a tool body that opens its own connection and writes inside itself is outside that guarantee; the framework states that limit rather than implying a coverage it does not have.
Each rule below has four parts: the canonical statement, why it exists, the concrete anti-pattern it forbids, and where in the framework’s own source it is actually enforced — a type, a filter, a non-nullable property, something a build or a test fails on — or, where a rule is enforced by the absence of any API that could break it, that absence named for what it is rather than dressed up as a check. Where a rule has a full worked treatment elsewhere in this documentation, this page states the rule with its full weight and links onward for the mechanics.
A [KernelFunction] is Semantic Kernel’s own attribute for exposing a C# method as a callable tool; it is Semantic Kernel’s convention, not Affiant’s, and the Microsoft Agent Framework and Microsoft.Extensions.AI adapters find a host’s tools by reflecting over a tool type’s public instance methods instead. Every rule below governs what a host is allowed to do inside and around a tool call on any of the three backends.
Rule 1 — One system prompt per agent, immutable after initialization
Section titled “Rule 1 — One system prompt per agent, immutable after initialization”The rule. An agent has exactly one system prompt. It is set once, when the agent is initialized, and nothing — no filter, no tool, no framework service — modifies it afterward.
Why it exists. The system prompt is where an agent’s persona, behavioral constraints, and tool-calling conventions live. If that text can change mid-conversation, then two turns that look identical on the surface may be running under different rules, and there is no record of which rules were in force when a given field was proposed. Provenance guarantees are only as strong as the stability of the thing producing them — a field tagged UserStated or Inferred (see the seven-source hierarchy) is a claim about how the agent behaved, and that claim only holds if the agent’s own instructions didn’t shift underneath it.
The anti-pattern. Appending live context to the system prompt instead of routing it through the Context Fabric — for example, a filter that mutates the system message to read “the customer just mentioned they prefer email contact” after a tool call, rather than upserting that fact as an EntityRef with its own ProvenanceTag. The information might be correct, but smuggling it into the system prompt makes it untraceable: nothing records that the change happened, when, or why, and the next filter or the next reviewer has no way to ask “where did this come from?”
Where it’s enforced. AffiantCoreOptions — the options type a host configures via services.AddAffiantCore() in Affiant.Core.Extensions — exposes exactly one system-prompt surface:
public sealed class AffiantCoreOptions{ // Host-specific system prompt passed to the LLM on the first turn. // Immutable after framework initialization (Normative Rule 1). public string? SystemPrompt { get; set; }
// The class carries four further settable members, none of them a second // system-prompt surface: DefaultDocketTtl, DocketExpiryWarningWindow, // EnableObservability and AcknowledgeMissingReviewWiring.}SystemPrompt is set once, at DI-registration time, before the service provider is built. There is no corresponding framework API — no method on ContextFabric, no filter, no service in Affiant.Core or in any of the three backend bridges — that accepts a system-prompt update at runtime. The rule is enforced by omission: the only path that exists is the startup path. Nothing in the framework reads the property back, either — the property declaration is the only occurrence of the name in the framework’s code, so the block’s comment describes what a host does with the value it set, not an injection the framework performs. Anything an agent learns after that point belongs in the Context Fabric instead, which is exactly what Rule 4 is about.
Rule 2 — Dual-audience tool returns
Section titled “Rule 2 — Dual-audience tool returns”The rule. Every tool return must be readable by both the LLM, for reasoning, and a UI, for rendering, from the same payload. Read tools satisfy this with markdown plus structured entity references; write tools satisfy it with a sworn Affidavit the LLM can summarize in prose and a UI can render as a review card.
Why it exists. A return that serves only one audience forces a second, lossy step somewhere downstream: either the UI has to re-query the database to render what the LLM already saw, or the LLM has to serialize state back out in some ad hoc shape a UI then has to re-parse. Either path introduces a place where the two views of the same fact can drift apart.
The anti-pattern. A tool that returns a raw SQL result set, an opaque JSON blob with no narrative structure, or a bare sentence with no way for a UI to recover which entities were actually involved.
Where it’s enforced. ToolEnvelope, in Affiant.Abstractions.Models, is a closed union of envelope shapes — a [JsonPolymorphic], [JsonDerivedType]-annotated discriminated union with exactly three members, serialized through the ToolEnvelopeExtensions.ToJsonString() extension method. The closure is of the union, not of what a tool may hand back: the pipeline reads a tool’s result as text and nothing type-checks that text against the union, so a read tool that returns plain markdown passes through every filter untouched. The refusal is scoped to a tool the framework’s registry declares write-capable — for one of those, ReviewGateFilter treats a result that does not deserialize as a WriteProposal as a wire-up refusal rather than a pass-through, on the framework’s own reasoning that such a tool either wrote something itself or lost its proposal, and neither may be reported to the model as a completed, reviewed write. That refusal has one hole: the filter returns on a null or empty result string before it ever consults the registry, so a declared write tool that hands back nothing at all passes through unrefused. Each comment line below names every member its variant adds to the base record’s ToolName and Timestamp:
public abstract record ToolEnvelope(string ToolName, DateTimeOffset Timestamp);// ReadResult → Summary + Markdown + Entities (the read-tool shape)// WriteProposal → Envelope + Arguments + Operation (the write-tool shape)// ToolError → Code + Message + Retryable (the failure shape)There is no fourth variant, and no variant that carries only one audience’s view. WriteProposal.Envelope is declared object and carries the proposed Affidavit; the two members added in 1.0.0-beta.3 — Arguments, the arguments the model passed to the call (null when the proposal did not come from one, and on Semantic Kernel at this version also when it did and the tool left the field unset — that backend’s completion-stage bridge hands the gate an empty argument dictionary, so the gate attaches none and leaves whatever the tool serialized), and Operation, a ProposedOperation? the host may declare — are carried because an entry id is derived from them, not because they are evidence. The full type shapes, the kind discriminator ($type before 1.0.0-beta.3), and the read/write/error contracts in depth are in Tool Envelopes.
Rule 3 — Write tools never write
Section titled “Rule 3 — Write tools never write”The rule. A write-intent tool never mutates a database. It produces a WriteProposal envelope carrying the proposed Affidavit, with full provenance, and stops. The actual write happens only after the review flow has approved it — a human reviewer confirming it, or a Standing Order a human authored in advance that the gate’s guardrails let through — and only through the one interface a host designates for that purpose.
Why it exists. This is the entire reason the framework exists. Every other rule on this page supports the guarantee this one states directly: a mutation is deterministic, auditable, and reversible-before-commit, because nothing commits until the review flow has said yes — a reviewer looking at the evidence, or a Standing Order a human wrote in advance, which the gate checks against its guardrails before it honours.
The anti-pattern. A “write” tool whose own method body calls dbContext.SaveChanges() — or any equivalent — directly, treating review as something that happens to the call rather than something that gates it.
Where it’s enforced. Two things, on either side of the review boundary. On the proposing side, a write tool is declared with [AffiantWriteTool(operation, entityType, typeof(TStrategy))] (Affiant.Abstractions.Attributes) and returns a WriteProposal; Affiant.Core.Filters.ReviewGateFilter — a neutral filter shared by all three backends, not an SK-specific IAutoFunctionInvocationFilter — runs after every auto-invoked function, checks whether the result deserializes as a WriteProposal, and — if so — routes it to ReviewGate.FileForReviewAsync for filing and approval-policy evaluation (FileReviewAsync, the blocking sibling this entry point has carried since 1.0.0-beta.1, is additionally marked [Obsolete] as of 1.0.0-beta.3). On the committing side, IWriteExecutor (Affiant.Abstractions.Interfaces) is the only sanctioned call site for a domain write — its own contract calls it “the one place an approved Affidavit becomes a real mutation in the host’s own system of record”, and that qualifier is exact rather than decorative: the framework’s own stores do write their own tables (Affiant.EntityFramework saves Docket rows and chat sessions), but the framework never writes domain data:
public interface IWriteExecutor{ Task<string?> ExecuteAsync( Affidavit affidavit, IReadOnlyDictionary<string, object?>? amendments, CancellationToken ct);}Nothing in the framework calls IWriteExecutor.ExecuteAsync — not Affiant.Core, not any of the three backend bridges; the only implementation shipped inside it is the compliance harness’s tripwire, which fails a conformance case if the gate ever reaches it. ReviewGateFilter files the review and logs the type of the ReviewFilingResult it got back; it does not act on an approval by writing anything. Invoking ExecuteAsync is entirely host code, exercised only after the host observes ReviewOutcome.Approved — and the host reports back what happened through ReviewGate.MarkExecutedAsync(entryId, outcome, detail, context), a guarded compare-and-set out of ExecutionOutcome.Unexecuted in which a second report on the same row is refused with execution-already-recorded and the first stands. That gap is deliberate, not an oversight: it is what keeps “propose” and “commit” as two calls into two different pieces of code, never one.
That approval has two shipped sources. One is a reviewer’s decision, through ReviewGate.HandleDecisionAsync. The other is a StandingOrder verdict: FileForReviewAsync files the entry Pending and then moves it to Approved in a second store call that carries the attestation with it — attested to the policy and the version that fired rather than to a person, so no approved entry is ever unattributed — broadcasts the Evidence Card marked RequiresConfirmation: false so a reviewer surface still shows what was approved with nobody present, and returns ReviewFilingResult.Decided(ReviewOutcome.Approved) — at which point the host may execute, with no person having seen that particular Affidavit. Two structural guards run ahead of the risk comparison and degrade such a verdict to reviewer confirmation rather than let it fire: a proposed field marked mandatory that reads Empty (mandatory-field-empty, checked first), and a provenance grade the policy predicates on that points at nothing (unbound-declared-input). The full state machine — ReviewGate.FileForReviewAsync, approval-policy routing, Evidence Card delivery, and the restart-safe HandleDecisionAsync — is in Review Gate & Write Executors.
Rule 4 — Filters over prompts for determinism
Section titled “Rule 4 — Filters over prompts for determinism”The rule. Context extraction, task inference, and review gating happen in filters — Affiant’s own IToolInvocationFilter seam, code that runs deterministically around every tool call on all three backends — never in prompt text. A prompt is allowed to request that a tool be called; it is never the mechanism that decides what happens with the result.
Why it exists. Asking a model to self-report — “after calling the tool, extract the customer’s email from the result and remember it” — is non-deterministic by construction: it depends on the model faithfully following an instruction, it varies across providers, and it can vary across two calls to the very same model. A filter that reads a tool’s structured ReadResult.Entities or a write tool’s inferred fields produces the identical outcome no matter which model, provider or backend answered the call underneath it.
The anti-pattern. Adding an instruction like “after calling the tool, extract the customer’s email from the result” to the system prompt instead of implementing a ContextExtractor.
One failure mode is worth naming, because the design that produces it looks equivalent to the design that does not. The LLM’s inferred field values exist as structured output attached to its decision to call the tool, before the tool runs; by the time a write tool has returned, that JSON was never part of the result. A filter that went looking for it after the tool body would find nothing to parse and would silently produce empty Affidavits — which is why InferenceTriggerFilter (below) is documented as a pre-tool filter that fires inference before a registered write-intent tool executes, never after.
Where it’s enforced. Four filters, each replacing a different piece of what a prompt-based approach would otherwise ask a model to self-report:
ContextExtractor(Affiant.Core.Filters, anIToolInvocationFilter) runs after a read tool returns and, only when the host subclass’s ownMatchesTool(context.FunctionName)returns true and the result text contains the literal"kind"— the envelope discriminator, so a plain-markdown result is never even parsed — deserializes itsToolEnvelope, and then only for aReadResultwith a non-emptyEntitiesarray hands it to that subclass’sExtractAsyncto upsert into theContextFabric.InferenceTriggerFilter(Affiant.Core.Filters, anIToolInvocationFilter— the neutral seam, which wraps the tool body rather than running after it, because it must run before that body) decides, per tool call, whether a registeredITaskInferenceStrategyshould run, then forwards throughTaskInferenceRunnertoTaskInferenceStep.ExecuteAsync, which merges the LLM’s structured-output field values into the fabric using the confidence-and-source tie-break rule described in Affidavits & Provenance.DeterministicShortCircuit(Affiant.Core.Services, anIToolInvocationFilter) iterates every registeredIIntentInterceptorand, for the first one whoseMatchesAsyncreturns true, lets it produce the result directly viaHandleAsync— the wrapped function body never runs at all for that call.TaskInferenceMergeFilter(Affiant.Core.Filters, anICompletionStageFilter— which is anIToolInvocationFilter— registered byAddAffiantCompletionFilters(), which all three adapter registrations call) is the post-tool counterpart: for a tool whose registered descriptor names anITaskInferenceStrategy, it parses the tool’s own result as JSON and forwards it to the sameTaskInferenceStepfor the same confidence-based merge. Its input is notInferenceTriggerFilter’s: field values and confidences a tool returned, rather than the structured output the model attached to its decision to call one. Read tools and unregistered tools are skipped by the same null check: a read tool’s descriptor names no strategy, and an unregistered tool has no descriptor at all.
None of the four depends on a model volunteering correct behavior; they run as ordinary C# in the invocation pipeline regardless of which provider answered the call. The full mechanics of the first three are in Context Fabric.
Rule 5 — Graceful degradation on provider failure
Section titled “Rule 5 — Graceful degradation on provider failure”The rule. When the primary LLM provider fails, the framework does not surface that failure as a blank screen or an unhandled crash. It falls back toward a secondary provider where a host has configured one, or continues in a deterministic mode where only non-LLM-dependent operations proceed.
Why it exists. An LLM API outage is an operational fact of running any agent framework at scale. An enterprise application whose only response to that is a stack trace has treated a routine failure mode as a fatal one.
The anti-pattern. Letting an unhandled exception propagate out of the chat client’s own completion call — IChatCompletionService.GetChatMessageContentsAsync on a Semantic Kernel host, IChatClient.GetResponseAsync on a Microsoft.Extensions.AI one — and take the whole turn down with it.
Where it’s enforced. Concretely, at the layer where the framework’s own inference pipeline calls an LLM: TaskInferenceRunner.RunAsync (Affiant.Core.Services) wraps its call to IInferenceCompletionPort.CompleteStructuredAsync in three catches, each emitting an "inference.failed" telemetry event with its own affiant.error.kind. A cancellation is tagged cancelled and rethrown — a caller that asked to stop is not an inference failure to swallow. A malformed response (JsonException) is tagged json_parse, and any other failure is tagged provider_outage; both of those log a warning and return an empty TaskInferenceResult rather than letting the exception propagate. The write tool call that triggered inference still proceeds; it just proceeds with fewer inferred fields, at lower aggregate confidence, rather than failing the turn outright.
At the provider-selection layer, Affiant.SemanticKernel.Connectors ships the primitives for a primary/secondary pair rather than a single hardcoded provider: AffiantProviderConfiguration is the shape a host binds from its own configuration, carrying a Primary and an optional Secondary LlmProviderConfiguration; ChatCompletionFactory.Create turns either one into an IChatCompletionService; and ProviderPair is the type that holds the two resolved services side by side. Everything above those types is host-authored: nothing in the framework constructs a ProviderPair, watches for an outage, or swaps providers — deciding when to reach for the secondary is the host’s logic, built on primitives the framework only supplies. A host wiring that failover path is free to fall back to DeterministicShortCircuit’s non-LLM tool calls — the ones a registered IIntentInterceptor claims by returning true from MatchesAsync over the call’s own arguments (see Rule 4) — for the subset of intents that don’t need a model at all; that path stays available regardless of which provider, if any, is currently healthy. DeterministicShortCircuit is itself an IToolInvocationFilter, though, so it is reached only when something drives a tool call into the pipeline — during an outage that something is host code, not the model that would normally have made the call.
Rule 6 — data-guide contracts are UI-layer registrations, not LLM-layer concerns
Section titled “Rule 6 — data-guide contracts are UI-layer registrations, not LLM-layer concerns”The rule. An agent discovers which UI elements it can guide a user toward through a host-owned registry — never by inspecting the DOM, never by asking the user to describe the page, and never by generating a CSS selector itself.
Why it exists. LLMs are not reliable generators of CSS selectors, and even a correct selector today can be wrong tomorrow: DOM structures change between deployments in ways a model has no way to observe. A registry that a host updates when its UI changes is a stable contract; a selector a model invents on the spot is not.
The anti-pattern. Prompting the LLM to “find the button labeled Save” and letting it generate a querySelector string to hand back to the frontend.
Where it’s enforced. IRouteRegistry (Affiant.Abstractions.Interfaces) is the registry a host implements and populates with GuidableElement records:
public interface IRouteRegistry{ void Register(GuidableElement element); IReadOnlyList<GuidableElement> GetElementsForRoute(string route); IReadOnlyList<GuidableElement> GetAllElements(); GuidableElement? GetElementById(string elementId);}
public record GuidableElement( string ElementId, string ElementType, Dictionary<string, object>? Attributes = null){ // Nullable to construct, never null to read: the body substitutes an empty dictionary. public Dictionary<string, object> Attributes { get; } = Attributes ?? new();}UiGuidanceBridge (Affiant.Core.UiBridge) is the framework-side consumer, and it owns the wire path as well as the read: it reads registered elements back out of IRouteRegistry, and it takes an IStreamingTransport and broadcasts an assembled walkthrough itself through BroadcastGuidanceAsync (TransportEvent.UiGuidance, which the SignalR transport sends as the client method GuideUI) rather than handing elements to a hub a host wires up — without ever touching a DOM node or constructing a selector itself. Whatever data-guide attribute convention a host’s frontend actually uses to make an element targetable lives inside GuidableElement.Attributes, which the framework stores and hands back verbatim on a registry read — with three exceptions it reads by name when building a guidance step: displayName (the step’s title, when the caller names none — and when neither a caller’s title nor a registered displayName exists, the title falls back to the element id itself), side (defaulting to "bottom") and highlightPadding (an int, else omitted). Every other key is opaque to it, and a UiGuidanceStep carries no attribute bag of its own, so nothing beyond those three ever reaches a client through BroadcastGuidanceAsync. How an agent becomes aware of which elements exist at all is, by design, a Rule 1 question: a host that wants the LLM to know about guidable elements includes that list in the system prompt at initialization — one of the two agent-facing channels Rule 1 leaves open, the other being a conformant ReadResult, which is agent-facing because it is a tool return the model reads. The Context Fabric is not a third: nothing in the framework puts fabric contents in front of the model — it feeds the filters and the affidavit projection. The inference port is not a consumer either: an InferenceCompletionRequest carries the conversation history, the strategy, the function name and the call’s arguments, and nothing fabric-derived. The framework’s contribution stops at the registry and the bridge; it never becomes a second, informal channel for injecting UI knowledge into the conversation at runtime.
Rule 7 — Every Affidavit field carries provenance, no exceptions
Section titled “Rule 7 — Every Affidavit field carries provenance, no exceptions”The rule. Every field in every Affidavit carries a ProvenanceTag. If a field’s origin is genuinely unknown, it is tagged ProvenanceSource.Empty — it is never left untagged, and it is never dropped from the field list.
Why it exists. A field with no provenance tag and a field tagged Empty would look identical to a careless reader, but they mean opposite things: the first is a bug — a place the framework failed to track something — and the second is an honest, checkable statement that nothing is known. Collapsing that distinction would make “the AI invented this value” indistinguishable from “the framework forgot to record where this came from,” which defeats the entire evidentiary premise of an Affidavit.
The anti-pattern. A field rendered on a review surface with no provenance tag at all, so a reviewer has no way to tell it apart from a field the user actually confirmed.
Where it’s enforced. Structurally, AffidavitField.Provenance is a non-nullable ProvenanceChain, and ProvenanceChain.Current is a non-nullable ProvenanceTag — there is no field shape in Affiant.Abstractions.Models that omits one. At projection time, SchemaDrivenAffidavitProjection (Affiant.Core.Services), the default IAffidavitProjection, resolves each field the active ITaskInferenceStrategy declares Projected — in declared order, and only those — trying three sources in turn: a registered IFieldResolver first; then the [Obsolete] IDeterministicFieldSource whose FieldName matches, kept fully working; then the ContextFabric’s stored ProvenanceChain for that field name. Only when none of the three resolves anything does it fall through to the explicit case this rule requires:
// AF-1: never omit a field. A field with no chain has nothing behind it and is sworn// Empty at confidence 0 — present, and honest about knowing nothing, which is what// makes the aggregate 0 and the empty-field count include it. It also carries no// value: a value with no provenance is exactly the claim the tag denies.provenance = ProvenanceChain.From(ProvenanceTag.Empty);value = null;That fallback is the last rung of the ladder: it runs only for a projected field none of the three sources resolved. What holds for every projected field is AssertExactCoverage, which throws if the emitted list is not exactly the strategy’s declared Projected set — a declared field missing, an undeclared one present, or one projected twice — so the resulting Affidavit.Fields array always has one entry per projected field, tagged one way or another, never fewer and never more. A field declared Projected: false is deliberately not among them: it is still asked for and still merged into the fabric, but it reaches an IFieldResolver as an extraction fact instead of reaching the card. See Context Fabric for what that is for. And at test time, Affiant.Testing.ComplianceHarness.ComplianceHarness.AssertProvenanceIsSubstantive checks the same invariant mechanically against a host’s own inference strategies. It records failures rather than throwing — it returns a SubstanceFailure list — and it makes four checks: an Affidavit whose Fields come back empty (it stops there); a field whose Provenance or Provenance.Current is null; a field that carries a real value alongside ProvenanceSource.Empty, the exact shape of a regression where a filter produced a value without tracking where it came from; and a field the strategy declared Required that projected with IsMandatory: false. The full determinism hierarchy, the Merge tie-break rule, and this check in context are in Affidavits & Provenance and The Compliance Harness.
Reading the seven rules together
Section titled “Reading the seven rules together”The rules split naturally into three groups. Rules 1 and 6 bound where an agent’s knowledge is allowed to come from at all — a system prompt fixed at initialization, and a UI registry it can query but never inspect directly. Rules 2, 3, and 4 govern the tool boundary itself: what a tool is allowed to return, what a write tool is forbidden to do with that return, and what mechanism — filters, never prompts — is allowed to act on it deterministically. Rule 7 is the record those first six rules exist to protect: an Affidavit’s provenance is only meaningful because Rules 1–4 make it deterministic and Rule 3 makes it reviewable before it has any effect. Rule 5 is the framework’s answer to what happens when any of this runs into an outage instead of a clean turn — degrade the specific thing that failed, not the whole system.
None of the seven is a suggestion. A change that would violate one — in the framework itself, or in a host built on it — is a defect, not a style disagreement. For the worked patterns that keep a plugin author on the right side of all seven, see Authoring Read Tools and Authoring Write Tools. For the limits of what these rules can cover in the first place — which tool calls the framework can actually see — see The Honest Boundary.