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 nothing reaches a database before a human has seen that record.
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, not merely a convention a plugin author is trusted to remember. 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, but every rule below governs what a host is allowed to do inside and around one.
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; }}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 Affiant.SemanticKernel — that accepts a system-prompt update at runtime. The rule is enforced by omission: the only path that exists is the startup path. 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, [JsonDerivedType]-annotated discriminated union with exactly three members — a tool is structurally required to return one of them, serialized through the ToolEnvelopeExtensions.ToJsonString() extension method:
public abstract record ToolEnvelope(string ToolName, DateTimeOffset Timestamp);// ReadResult → Summary + Markdown + EntityRef[] (the read-tool shape)// WriteProposal → Envelope (an Affidavit) (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. The full type shapes, the $type discriminator, 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 a human reviewer has confirmed it through the review flow, 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 a human has seen the evidence and said yes.
The anti-pattern. A “write” tool whose [KernelFunction] method 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.SemanticKernel ships ReviewGateFilter, an IAutoFunctionInvocationFilter that runs after every auto-invoked function, checks whether the result deserializes as a WriteProposal, and — if so — routes it to ReviewGate.FileReviewAsync for filing and approval-policy evaluation. On the committing side, IWriteExecutor (Affiant.Abstractions.Interfaces) is the only sanctioned call site for a mutation anywhere in the system:
public interface IWriteExecutor{ Task<string?> ExecuteAsync(Affidavit affidavit, Dictionary<string, object>? amendments, CancellationToken ct);}Nothing in Affiant.Core or Affiant.SemanticKernel calls IWriteExecutor.ExecuteAsync. ReviewGateFilter files the review and logs the outcome; it does not act on an Approved outcome by writing anything. Invoking ExecuteAsync is entirely host code, exercised only after the host observes ReviewOutcome.Approved. 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. The full state machine — ReviewGate.FileReviewAsync, approval-policy routing, Evidence Card delivery, the restart-safe HandleDecisionAsync / ReplayApprovalAsync pair — 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 Semantic Kernel filters — code that runs deterministically in the function-invocation pipeline — 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 IChatCompletionService is configured 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.
A real regression illustrates the failure mode this rule guards against. In April 2026, an early version of the framework’s task-inference pipeline ran as a post-tool filter — it inspected a write tool’s return value looking for the LLM’s structured-output intent. The decomposition looked equivalent to the pre-tool design it replaced, but it was behaviorally lossy: 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, so a post-tool filter finds nothing to parse and silently produces empty Affidavits. The fix was to make inference-triggering a pre-tool concern again, which is why InferenceTriggerFilter (below) is explicitly documented as running before the tool body, never after.
Where it’s enforced. Three filters, each replacing a different piece of what a prompt-based approach would otherwise ask a model to self-report:
ContextExtractor(Affiant.Core.Filters, anIFunctionInvocationFilter) runs after a read tool returns, deserializes itsToolEnvelope, and — only for aReadResultwith a non-emptyEntitiesarray — hands it to a host subclass’sExtractAsyncto upsert into theContextFabric.InferenceTriggerFilter(Affiant.SemanticKernel.Filters, anIFunctionInvocationFilter— explicitly not anIAutoFunctionInvocationFilter, because it must run before the tool 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, anIFunctionInvocationFilter) 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.
None of the three 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 all 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 an IChatCompletionService.GetChatMessageContentsAsync call 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 a catch that treats a malformed response (JsonException) or any other failure — logged with an "inference.failed" telemetry event tagged provider_outage — the same way: it returns 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 binds a Primary and an optional Secondary LlmProviderConfiguration from host configuration, and ProviderPair holds the two corresponding resolved IChatCompletionService instances side by side. Deciding when to reach for the secondary — detecting that the primary has failed and switching — is host-authored logic built on top of these primitives; the framework hands over a primary/secondary pair, it does not itself watch for outages and swap providers automatically. A host wiring that failover path is free to fall back to DeterministicShortCircuit’s keyword-matched, non-LLM tool calls (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.
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);UiGuidanceBridge (Affiant.Core.UiBridge) is the framework-side consumer: it reads registered elements back out of IRouteRegistry and surfaces them to downstream consumers such as a SignalR hub, 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 — a plain dictionary the framework never inspects beyond passing it through. 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, the one place Rule 1 permits agent-facing context to be established. 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 in order — a registered IDeterministicFieldSource override first, then the ContextFabric’s stored ProvenanceChain — and only when neither resolves anything does it fall through to the explicit case this rule requires:
// Rule 7: never omit a field — tag it Empty rather than dropping it.provenance = ProvenanceChain.From(ProvenanceTag.Empty);That fallback runs for every field in the strategy’s schema, so the resulting Affidavit.Fields array always has one entry per declared field, tagged one way or another — never fewer. And at test time, Affiant.Testing.ComplianceHarness.ComplianceHarness.AssertProvenanceIsSubstantive checks the same invariant mechanically against a host’s own inference strategies: it fails an Affidavit outright if Fields comes back empty, and it fails any individual 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. 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.