Skip to content

Review Gate & Write Executors

Rule 3 of the framework’s Seven Normative Rules is the whole point of Affiant: write tools never write. A tool marked as a write tool returns a WriteProposal — covered in Tool Envelopes — containing a fully-sworn Affidavit, and stops there. No [KernelFunction] method ever calls SaveChanges() or any equivalent. Everything between “the LLM asked for a write” and “a row actually changed” is the concern of two things: the ReviewGate, a service in Affiant.Core that evaluates approval policy, stamps the review deadline from what the policy chain returned, files the proposal, and — when a human is required — sends an Evidence Card and returns without waiting, leaving a separate call to deliver the eventual decision; and IWriteExecutor, a host-implemented interface that is the one place an approved Affidavit becomes a mutation in the host’s own system of record. This page covers both, plus the IApprovalPolicy pipeline that decides how much human involvement any given proposal actually needs.

A WriteProposal’s Envelope property is typed object at the wire level, so something has to narrow it back to a strongly-typed Affidavit and gather the session, tenant, and user identity needed to file a review. That’s IReviewContextProvider, a host-implemented service:

public interface IReviewContextProvider
{
ReviewContext? BuildReviewContext(WriteProposal proposal);
}

It returns a ReviewContext — or null if the ambient request doesn’t carry enough identity to file a review at all (an unauthenticated request, for instance):

public record ReviewContext(
string SessionId,
string TenantId,
string UserId,
string ReviewerUserId,
Affidavit Affidavit,
Guid? EntryId = null,
IReadOnlyDictionary<string, object?>? Amendments = null,
Guid? Supersedes = null,
string? Channel = null,
DateTimeOffset? ConversationStartedAt = null);

This hand-off happens automatically, on all three backends: Affiant.Core.Filters.ReviewGateFilter — a neutral ICompletionStageFilter, not an SK-specific type, registered as position 7 of the framework’s filter pipeline — runs after every auto-invoked tool call, checks whether the result deserializes as a WriteProposal, and if so, calls ReviewGate.FileForReviewAsync itself. On Semantic Kernel it runs inside AffiantAutoFunctionInvocationBridge’s own pipeline (the type that actually implements SK’s IAutoFunctionInvocationFilter); on MAF it runs inside AffiantFunctionInvocationMiddleware’s onion; on M.E.AI it runs inside AffiantDelegatingAIFunction’s onion — no adapter carries its own copy of this filing logic. FileForReviewAsync has been the non-blocking entry point since 1.0.0-beta.1, alongside the blocking FileReviewAsync (below); as of 1.0.0-beta.3 the latter also carries [Obsolete] (AFFIANT0002) — it still awaits a hand-off and reports it, but decides nothing.

As of 1.0.0-beta.3, three previously-silent gaps here now fail closed instead of failing open. Before this release, ReviewGateFilter logged a debug message and skipped — leaving the tool’s raw, unreviewed WriteProposal as the visible result — when IReviewContextProvider had nothing registered, when a call had no review context available, or when no ReviewGate was registered at all; a model was then free to report an unfiled, unreviewed write as done. All three are now refusals carrying the wireup-invalid code instead, which matters most for exactly the call sites with nobody watching to notice the old silent skip: a queue consumer, a cron trigger, a background job. A write tool the framework’s registry declares write-capable that returns something other than a proposal is refused the same way, rather than passed through — with one hole left in 1.0.0-beta.3: a result that is null or an empty string returns from the filter before the registry is consulted at all, so a declared write tool that hands back nothing still passes through unrefused. And AffiantWireUpValidator now refuses a host that declares a write-capable tool and registers no IReviewContextProvider or no ReviewGate — at startup, before any turn — where before this was reachable only per-call, silently.

ReviewGate.FileForReviewAsync: the state machine

Section titled “ReviewGate.FileForReviewAsync: the state machine”

ReviewGate lives in Affiant.Core.Services. Its constructor takes IStreamingTransport, IDocketStore, IApprovalPolicyEvaluator, AffiantCoreOptions and a logger, plus three optional collaborators: TimeProvider? (the gate’s only clock — every instant it stamps and every deadline comparison it makes reads from here, defaulting to TimeProvider.System), IDecisionAuthorizationPolicy? (defaulting to DenyAllDecisionAuthorization, below), and ToolCoverage? (the host’s declared-uncovered-tool list, null when it declared none). AddAffiantCore() registers it, Scoped — matching ApprovalPolicyEvaluator’s own lifetime, because a Singleton ReviewGate would capture the Scoped evaluator the moment a policy carried a Scoped dependency such as a host DbContext. The registration is a TryAddScoped, so a host that registers its own ReviewGate still wins. What it does not do is supply the two Affiant.Abstractions contracts the gate resolves through — IStreamingTransport and IDocketStore have no default implementation in Affiant.Core, and a host still installs a transport and a store package for them; AffiantWireUpValidator, registered by the same call, is what fails the host at startup when either is missing by the time the application starts. FileForReviewAsync is the non-blocking entry point — it never awaits a reviewer, and returns as soon as the entry is filed and, where one is needed, the Evidence Card is on the wire:

public Task<ReviewFilingResult> FileForReviewAsync(
WriteProposal proposal,
ReviewContext context,
CancellationToken cancellationToken = default)
=> FileForReviewCoreAsync(proposal, context, cancellationToken);

ReviewFilingResult is a closed hierarchy of two shapes: RequiresReview(Guid EntryId) — the Evidence Card was broadcast and awaits a reviewer, and the caller routes the eventual decision to HandleDecisionAsync rather than waiting here — and Decided(ReviewOutcome Outcome), for a review settled with no client round-trip at all: a Standing Order auto-approval, a blocked ReferralRequired/MultiParty entry, a proposal from a tool the host declared uncovered (filed and blocked in the same way), or an idempotent replay of an entry already resolved.

The shared private core, FileForReviewCoreAsync (also what ResubmitAsync calls), runs in the order the rules fix — substance refusal → idempotent replay → the approval-policy chain → the deadline stamped from what the chain returned → filed — which is not the order 1.0.0-beta.1 ran them in: that release filed the Docket row first, with a deadline computed from one process-wide default, and evaluated the approval policy afterward, so a policy could never actually name its own review window and nothing checked whether a proposal swore to anything before a reviewer was asked about it. Walked through step by step, against the current implementation:

  1. Substance refusal, before anything else runs and before an entry id is even derived. A proposal with no fields, with every proposed field tagged Empty, or with a value asserted under Empty provenance throws AffiantSubstanceException — not filed, not counted, not broadcast — and the tool’s error result carries substance-refused. This check used to live only in ComplianceHarness, which runs in an adopter’s own test suite and never in production; the gate now has one too, so a hollow proposal can’t reach a reviewer looking like a sworn one. (0, false, an empty array, and an empty object are values; only null and a blank string are empty.)
  2. Idempotent replay. An entry id is derived from the proposal (context.EntryId, or the tenant, the conversation, the tool, and the canonical form of the operation and of the arguments the model passedReviewGateFilter folds those arguments onto the proposal at this seam precisely so two calls differing only in what the model passed get different rows, and it can fold only what the seam hands it: on MAF and M.E.AI the bridge carries the call’s arguments through, while Semantic Kernel’s completion-stage bridge deliberately builds its request with an empty argument dictionary — so on SK the material’s args is null and two calls differing only in their arguments do land on one row; see Docket & Evidence Cards) and looked up, scoped to the caller’s tenant — a row outside it is treated as a miss, never read or reported. An existing row that is Blocked or no longer Pending returns its settled state as ReviewFilingResult.Decided with nothing re-filed; a still-Pending row re-broadcasts its own card with its existing ExpiresAt rather than a freshly computed one, so a retry never refreshes the deadline.
  3. Evaluate approval policy. IApprovalPolicyEvaluator.EvaluateAsync(context.Affidavit, identity, ...) returns an ApprovalVerdict — see Approval policies below. Two policy faults are refused right here too, with nothing filed (wireup-invalid, raised as AffiantPolicyException): a verdict carrying a review window that isn’t a deadline, and an EvaluateAsync that throws (both emit policy.invalid first — the throw is not swallowed, because a chain that can’t answer must not fall through to a weaker requirement).
  4. Stamp the deadline from what the policy chain returned — its verdict’s own TimeToLive, else the policy’s DefaultTimeToLive, else AffiantCoreOptions.DefaultDocketTtl — rather than from one host-wide default applying regardless of what the policy asked for. See Docket & Evidence Cards for DefaultDocketTtl and the related DocketExpiryWarningWindow option.
  5. File, then branch on the verdict’s requirement:
    • StandingOrder — the entry is transitioned straight to ReviewStatus.Approved, attested standing-order naming the policy and the version it fired under, and ReviewFilingResult.Decided(new ReviewOutcome.Approved(entryId)) is returned. An Evidence Card is still broadcast, marked RequiresConfirmation: false, so a reviewer surface can show what was approved with nobody present. A Standing Order held back by its own risk ceiling no longer just vanishes here (returning null for a later policy to speak as though the order never fired); it now degrades to a verdict requiring reviewer confirmation, with the reason (risk-above-threshold, or mandatory-field-empty/unbound-declared-input for the two structural guards below) on the record.
    • A tool the host declared uncovered (ToolCoverage) — filed and blocked with a CoverageRefused marker, regardless of what the policy said; see The Honest Boundary.
    • ReferralRequired or MultiParty — the entry is filed Pending with a RequirementNotImplemented Blocked marker, an Evidence Card is still broadcast (carrying the marker), and ReviewFilingResult.Decided wraps a ReviewOutcome.Refused. Neither verdict is routed to the single-reviewer branch (which used to let one click satisfy what was meant to be a joint approval) or written Deferred for a transition nothing implements. See Docket & Evidence Cards for the full story of what changed here.
    • ReviewerConfirmationReviewGate builds an EvidenceCardRequest and calls IStreamingTransport.BroadcastToGroupAsync to send it to the session’s group, then returns ReviewFilingResult.RequiresReview(entryId) — no waiter is registered and nothing here blocks.

A Standing Order never auto-approves through a hole in the proposal. Two checks run ahead of the risk comparison and, if either fires, degrade the verdict to reviewer confirmation rather than letting a Standing Order fire past them: a proposed field marked mandatory that reads Empty (mandatory-field-empty, checked first, because it depends on nothing the policy declared — a host’s risk scorer is never spent on a proposal with a hole in it), and a provenance grade the policy predicates on that points at nothing (unbound-declared-input, via IApprovalPolicy.DeclaredInputs). Both keep the policy’s own review window — the degrade changes who decides, not when the window closes.

ReviewGate.FileReviewAsync — present alongside FileForReviewAsync since 1.0.0-beta.1, and marked [Obsolete] under AFFIANT0002 as of 1.0.0-beta.3, kept for one release — is the only method on this class that actually blocks: it calls FileForReviewAsync, and if the result RequiresReviews, it awaits IStreamingTransport.AwaitEvidenceCardResponseAsync on the same call chain the caller’s own connection is holding open, timing out to ReviewOutcome.Expired after AffiantCoreOptions.DefaultDocketTtl. Over SignalR — the framework’s only shipped transport, whose MaximumParallelInvocationsPerClient defaults to 1 — the one hub invocation that could deliver the reviewer’s decision queues behind the very invocation blocked awaiting it, a same-connection deadlock. It decides nothing itself: every decision — the principal, the tenant check, the authorization port, the attestation — runs in HandleDecisionAsync, which has already written the row by the time a hand-off exists to report.

ReviewOutcome, Guid DocketId at its root, is a closed hierarchy: Approved(DocketId, AmendedAffidavit?), Rejected(DocketId, Reason), Expired(DocketId, AmendmentsPreserved), Referral(DocketId, EscalationPath), and, added in 1.0.0-beta.3, Refused(DocketId, Code, Detail?). Referral is not removed — a pre-1.0.0-beta.3 row persisted with ReviewStatus.Deferred still reads back as one — but nothing the gate does today produces a fresh Referral; a ReferralRequired verdict now produces Refused instead (below). Refused is also what a decision on a missing entry, an already-decided entry, one that lost a race, or one sitting behind a Blocked marker returns, each named by a Code rather than conflated into a single outcome that read as an expiry. Code is one of DocketRefusalCodes, and the gate answers with six of the eight the registry declares: entry-not-found, decision-not-pending, decision-expired, decision-lost-race, decision-unauthorized, and execution-already-recorded. The other two — requirement-not-implemented and coverage-refused — never arrive as a Code: an entry carrying either blocked marker refuses every act with decision-not-pending (the row is pending and no decision on it will ever be accepted, which is exactly what that code is registered to mean), and the marker’s own code and context travel in Detail — so a switch arm written on either of those two as a Code can never match.

As of 1.0.0-beta.3, nine events are named in one versioned registry (Affiant.Abstractions.Telemetry.TelemetryKeys), so an operator’s alerts for these nine have one place to look rather than guessing at string literals scattered through the source: affidavit.filed, affidavit.refused.substance (the substance check above, emitted from two sites in this release — the projection, which detects a hollow Affidavit, reports it with no tool name and carries on, and the gate’s own runtime refusal described above, which names the tool and files nothing), coverage.refused, docket.transition (entry.id, gen_ai.conversation.id, from, to, execution, decision.kind, attestation.kind, amended), docket.expired, decision.unauthorized (entry.id, gen_ai.conversation.id, reason, principal.kind, path — the last naming the entry point, decide, mark-executed or resubmit), standing-order.fired / standing-order.blocked, and policy.invalid. A registry key is never renamed and never removed once shipped, only deprecated — affidavit.projected is superseded by affidavit.filed and still emitted alongside it for this one release so an existing alert doesn’t go dark on upgrade. The framework’s other event names — affiant.tool_error, affiant.review.filing_failed, affiant.review.broadcast_failed, affiant.extractor.failed, and the inference.* family — are not in the registry and are not deprecated: they name things the registry doesn’t cover, and they keep their names.

FileForReviewAsync never blocks, but a review still outlives the request that filed it — a host restarts, or the reviewer simply takes a while — so the decision arrives on a separate call. ReviewGate exposes one method for that:

The signature, without its body:

public async Task<(ReviewOutcome? Outcome, DateTimeOffset? EntryCreatedAt)> HandleDecisionAsync(
Guid entryId,
ApprovalDecision decision,
DecisionContext context,
IReadOnlyDictionary<string, object?>? amendments = null,
CancellationToken cancellationToken = default)

As of 1.0.0-beta.3 this takes a DecisionContext (below), and there is deliberately no overload that omits the principal or the tenant — one that defaulted them would be the fail-open this change exists to close. The single overload 1.0.0-beta.1 shipped, (entryId, decision, amendments, ct), is gone. Every call runs through one core before anything else happens: the principal is resolved and refused with decision-unauthorized before the Docket is even read; who the resulting attestation is built from is fixed at the same step, and a service principal with nothing to relay is refused here too, before the row is touched; the row is read by id and the gate compares the row’s own tenant with the caller’s itself, rather than leaving the boundary to a scoped store read — a check that consists of handing a tenant id to the store is a check the store performs — so a row in another tenant answers entry-not-found rather than leaking that an id it may not touch exists; the host’s IDecisionAuthorizationPolicy is asked (below); the row’s Blocked marker and state are checked; and only then is the attestation written under a guarded transition. HandleDecisionAsync resolves the decision directly against the Docket and reports the Outcome itself; if a FileReviewAsync call (the obsolete blocking path, above) is also live and waiting on this entryId, the same result is additionally delivered to it as a fire-and-forget notification. EntryCreatedAt is a convenience the caller can log or ignore.

A host’s SignalR hub method (or API endpoint) handling a reviewer’s Approve/Reject click calls this, not FileForReviewAsync — that method is for filing a new proposal, this one is for resolving an existing one. ResubmitAsync(Guid expiredEntryId, DecisionContext context, CancellationToken ct) runs four checks of its own before minting a fresh entry that clones the expired one’s Affidavit, prefilled from whatever the reviewer had already amended: the principal, the same read-then-compare tenant check, the host’s IDecisionAuthorizationPolicy — and then, in place of the attestor and blocked-marker checks it does not run, a throw when the entry it was handed is not Expired.

DecisionContext and IDecisionAuthorizationPolicy: who may decide

Section titled “DecisionContext and IDecisionAuthorizationPolicy: who may decide”

Two things new in 1.0.0-beta.3 close the gap the paragraphs above describe: nothing previously required a caller to say who was deciding, and nothing asked whether that caller was even entitled to.

DecisionContext (Affiant.Abstractions.Models) carries the principal, the tenant, the conversation, the channel, and the reviewer’s reason — built at the call site from whatever the host actually authenticated, never resolved from ambient state, and with no unattributed variant to fall back on:

public sealed record DecisionContext(
Principal? Principal,
string TenantId,
string? ConversationId = null,
string? Channel = null,
string? Reason = null);

Pass Principal: null when the host could not authenticate anybody, rather than inventing an id — the gate refuses that, which is the point. Principal is Principal.Member (a human-verified session) or Principal.Service (a machine caller that may name the person it speaks for and the relay assertion that carried them); it deliberately carries no instant of its own — the moment an attestation and a decision record are dated to is the one the gate observed on its own TimeProvider.

IDecisionAuthorizationPolicy is the host’s own answer to the one question left once identity and tenancy are settled — is this specific person, in a tenant that already matched, entitled to decide this specific row?:

public interface IDecisionAuthorizationPolicy
{
Task<bool> MayDecideAsync(
Principal principal, DocketEntry entry, CancellationToken cancellationToken = default);
}

Register it with services.AddDecisionAuthorization<TPolicy>(). A host that registers none gets DenyAllDecisionAuthorization, the built-in default that refuses every decision — obviously broken, and broken in the direction that can never approve a write nobody was entitled to approve. AffiantWireUpValidator refuses at startup when a host declares a write-capable tool and registers no policy, so no host runs on the deny-all by accident, and AffiantCoreOptions.AcknowledgeMissingReviewWiring does not waive this check. The acknowledgment downgrades the validator’s other missing contracts to warnings only while the review-wiring set is itself empty, and IDecisionAuthorizationPolicy is in that set, alongside IReviewContextProvider, ReviewGate and any policy configuration fault. Contracts outside it — IPreviousValueSource, for instance — are downgraded. Both false and a thrown exception from MayDecideAsync refuse — a callback that fell over has not said yes.

A host guarding HandleDecisionAsync itself before 1.0.0-beta.3 should delete that guard: the framework now refuses an unresolved principal before it reads the Docket and compares the row’s own tenant itself, so a host-side check repeating either is dead code that can only drift from the framework’s. What’s left for the host is the narrow question above. DocketEntry.ReviewerUserId looks like the natural field to key this on, but it’s [Obsolete] as of 1.0.0-beta.3 (AFFIANT0001, see Docket & Evidence Cards) in favor of Attestation — and Attestation is written by the decision itself, so it is still null on the pending row at the point MayDecideAsync runs; it isn’t a usable stand-in here. DocketEntry.UserId — the user whose turn produced the proposal — is the non-deprecated field to key on instead. The example below reads it as a self-review — the proposer decides their own row — admits any resolved principal outright on a row that names nobody at all, and falls back to an approval-manager check for a row that names somebody else; it is written for this page, not lifted from a shipped host (the sample’s own QuickstartDecisionAuthorization reads only the principal and ignores the entry entirely, because that sample has exactly one reviewer):

services.AddDecisionAuthorization<FiledForOrManager>();
internal sealed class FiledForOrManager(IMembership membership) : IDecisionAuthorizationPolicy
{
public async Task<bool> MayDecideAsync(Principal principal, DocketEntry entry, CancellationToken ct)
{
// The tenant already matched and the principal is resolved — the gate saw to both.
var memberId = principal switch
{
Principal.Member member => member.Id,
Principal.Service { AssertedMember: { } asserted } => asserted,
_ => null,
};
if (memberId is null) return false;
return string.IsNullOrEmpty(entry.UserId)
|| memberId == entry.UserId
|| await membership.IsApprovalManagerAsync(memberId, entry.TenantId, ct);
}
}

IWriteExecutor: the host-owned single write path

Section titled “IWriteExecutor: the host-owned single write path”
public interface IWriteExecutor
{
Task<string?> ExecuteAsync(
Affidavit affidavit, IReadOnlyDictionary<string, object?>? amendments, CancellationToken ct);
}

This is the interface Rule 3 exists to protect: it is the only sanctioned call site for a mutation of the host’s own system of record — its domain data. (The framework’s own stores still write their own rows: the Docket entry, the attestation it carries, the execution report against it.) Its doc comment is direct about the contract — route on Affidavit.OperationType to the correct domain handler, apply any amendments, persist, and raise on failure, because the caller does not retry. amendments’ nullable values are load-bearing: a key present with a null value means the reviewer cleared that field, distinct from the key being absent (leave it alone) — an implementation that collapses the two loses information the review actually recorded. DocketEntry.Amendments and EvidenceCardResponse.Amendments are both typed the same way, so a host reads either straight through with no cast or copy.

Don’t hand-roll the amendment fold. AffidavitAmendments.Apply is the one implementation of what an accepted correction does to the record, and it is exactly what ReviewOutcome.Approved.AmendedAffidavit already carries — the reviewer’s values, a UserStated tag carrying a ReviewerAct binding appended on top of each amended field’s chain (never replacing the machine’s tag beneath it), and all three confidence numbers recomputed. An executor that folds amendments itself, rather than reading AmendedAffidavit, risks showing a corrected value under the machine’s pre-correction confidence.

The detail worth being precise about: nothing the framework ships calls IWriteExecutor — not Affiant.Core, and not the Semantic Kernel, MAF or M.E.AI bridge. ReviewGateFilter files the review and logs the type of the ReviewFilingResult it got back — it does not act on an approval by invoking a write. On the settled path the result it holds is a ReviewFilingResult.Decided carrying the final ReviewOutcome; the filter never reads or acts on it, testing only whether the filing RequiresReview so it can end the turn. ReviewGate itself never references IWriteExecutor either, and the compliance harness arms a tripwire implementation that throws if the gate ever reaches one. The call is entirely host code: after a host observes an Approved outcome from HandleDecisionAsync (or, for a Standing Order, directly from FileForReviewAsync’s ReviewFilingResult.Decided), it is the host’s own responsibility to look up the DocketEntry and call its IWriteExecutor.ExecuteAsync with entry.AmendedAffidavit ?? entry.Envelope and entry.Amendments.

Reporting what the executor did is a separate, additional step as of 1.0.0-beta.3. ReviewGate.MarkExecutedAsync(Guid entryId, ExecutionOutcome outcome, string? detail, DecisionContext context, CancellationToken ct) is the supported path to an executed write landing on the Docket row itself — the host runs its own executor against the attested entry and reports what happened, once, under a guarded compare-and-set out of ExecutionOutcome.Unexecuted. The guarded write itself is IDocketStore.RecordExecutionAsync, a public store member the gate calls and a host holding the store could call directly — which skips the principal, tenant, authorization and attestation checks below, and those checks are the reason to go through the gate:

string? recordId = null;
ExecutionOutcome outcome;
try
{
recordId = await writeExecutor.ExecuteAsync(
entry.AmendedAffidavit ?? entry.Envelope, entry.Amendments, ct);
outcome = ExecutionOutcome.Executed;
}
catch (Exception ex)
{
outcome = ExecutionOutcome.Failed;
recordId = ex.Message;
}
await gate.MarkExecutedAsync(entry.EntryId, outcome, recordId, decisionContext, ct);

A second report against the same entry is refused with execution-already-recorded, and the entry’s Status stays Approved either way — the approval happened and is not undone by a failed write; retrying the write is the host’s business, but the outcome is the Docket’s and it is recorded once. MarkExecutedAsync also refuses a row that is Approved and carries no Attestation — an execution report is evidence that an approved write ran, and a row nobody attested was never approved. A row that is simply not approved gets the store’s own decision-not-pending answer instead, which is a different fact and not an authorization failure. A machine caller is admitted here and refused as a decider: reporting an outcome is a statement of fact about work the host performed, which a machine is the right party to make, while a decision is an act of authority a machine may never make in a person’s name. HandleDecisionAsync is not closed to machines outright: a service principal that names both the person it speaks for and the relay assertion that carried them decides normally, and the row attests member-via-relay rather than member; only a service principal with nothing to relay is refused there. The tenant check and the host’s IDecisionAuthorizationPolicy apply to an execution report just as they do to a decision, so which service may report on an entry is still the host’s answer.

That every sanctioned write funnels through one interface with one method is what makes the framework’s evidentiary claim workable: there is one place an approved Affidavit becomes a row in the host’s own system of record, and one interface to watch. Two routes escape that, and the framework names both rather than implying a coverage it does not have. The first is host code calling IWriteExecutor.ExecuteAsync directly, bypassing ReviewGate — which is exactly the kind of thing a code review of the host’s own call sites can catch, and the compliance harness arms a tripwire executor that fails a run if the gate ever reaches one. The second is a write tool that opens its own connection and writes inside its body: ReviewGateFilter runs after the tool body, because that is the only seam that sees the tool’s result — a pre-body position exists on the same pipeline, and Affiant’s own InferenceTriggerFilter and DeterministicShortCircuit run there, but nothing at it has a result to gate on — so no filter and no wire-up check can see such a write. What the framework does guarantee is that such a tool cannot commit through it — the gate never calls a write tool’s own execute, no public API lets a tool commit through the framework, and a tool the registry declares write-capable that hands back something other than a proposal is refused rather than passed through — except when it hands back nothing at all: a null or empty result returns before the registry is consulted, and passes through unrefused.

Approval policies: IApprovalPolicy and Affiant.Policies

Section titled “Approval policies: IApprovalPolicy and Affiant.Policies”

IApprovalPolicy, in Affiant.Abstractions.Interfaces, is what IApprovalPolicyEvaluator.EvaluateAsync (called by ReviewGate above) actually iterates over. As of 1.0.0-beta.3 it returns an ApprovalVerdict? rather than a bare ReviewRequirement?, and takes a ConversationIdentity — the parameter the framework specification (docs/affiant-framework-specification.md in the framework repository) has always declared — so a policy can bind to it (a member-bound or tenant-bound Standing Order, one that trusts one channel and not another); authorizing the actor stays the framework’s job and is never delegated to a policy, so a policy with nothing to bind to just ignores the parameter, as the built-in ones do:

public interface IApprovalPolicy
{
Task<ApprovalVerdict?> EvaluateAsync(
Affidavit affidavit, ConversationIdentity identity, CancellationToken cancellationToken = default);
IReadOnlyCollection<ProvenanceSource> DeclaredInputs => []; // default interface member
string PolicyId => GetType().FullName ?? GetType().Name; // default interface member
string? PolicyVersion => null; // default interface member
string? ConfigurationFault => null; // default interface member
TimeSpan? DefaultTimeToLive => null; // default interface member
}
public sealed record ApprovalVerdict(
ReviewRequirement Requirement,
TimeSpan? TimeToLive = null,
string? Reason = null,
string? BlockedReason = null,
ReviewRequirement? DegradedFrom = null,
string? PolicyId = null,
string? PolicyVersion = null,
int? RiskScore = null)
{
// Plus two members, elided here. `DegradeToReviewer(blockedReason, reason)` returns a copy
// requiring reviewer confirmation with `DegradedFrom` set to what it was — what the three
// Standing Order guards below produce instead of vanishing. And
// `public static implicit operator ApprovalVerdict(ReviewRequirement)` is what still lets a
// policy return a bare requirement.
}
public enum ReviewRequirement
{
StandingOrder,
ReviewerConfirmation,
ReferralRequired,
MultiParty
}

A bare ReviewRequirement still converts to an ApprovalVerdict implicitly, so a policy with nothing to say about the deadline still reads as one line. An existing body needs both halves of the signature change, not only the return type: Task<ReviewRequirement?> EvaluateAsync(Affidavit, CancellationToken) becomes Task<ApprovalVerdict?> EvaluateAsync(Affidavit, ConversationIdentity, CancellationToken), so a body changed only at Task.FromResult<ReviewRequirement?>(x)Task.FromResult<ApprovalVerdict?>(x) does not compile until the identity parameter is added too. A policy returns null to defer to the next policy in the chain, or a verdict to terminate the chain with it. IApprovalPolicyEvaluator.EvaluateAsync (implemented by Affiant.Core.Services.ApprovalPolicyEvaluator, and itself now returning Task<ApprovalVerdict> rather than Task<ReviewRequirement>) runs every registered IApprovalPolicy in DI registration order and returns the first non-null result — falling back to ReviewRequirement.ReviewerConfirmation if every policy passes. That fallback means a host with zero policies registered still gets the safe default: every write proposal requires a human reviewer.

DeclaredInputs, PolicyId, PolicyVersion, ConfigurationFault, and DefaultTimeToLive are default interface members, so an existing implementation needs no new code to keep compiling. DeclaredInputs names the ProvenanceSource values a Standing Order predicates on, for the unbound-declared-input guard above. PolicyId/PolicyVersion are what the chain stamps onto a verdict — and, for a StandingOrder verdict, onto the attestation the filing writes — rather than trusting a policy to report itself, so “who approved this write with no person present” has one honest answer. ConfigurationFault exists so a Standing Order that declares a risk ceiling with no scorer registered is refused at startup, read by AffiantWireUpValidator, rather than only on its first evaluation — register one with SetRiskScoreCalculator<T>(), or drop the RiskThreshold override.

Affiant.Policies is where the reusable policy building blocks live:

StandingOrderBase (Affiant.Policies.StandingOrders) is an abstract IApprovalPolicy for auto-approving low-risk operations. A host subclasses it, implements MatchesAsync(Affidavit, ct) to describe which affidavits the order applies to, and optionally overrides RiskThreshold (an int?, defaulting to nullno risk ceiling at all: matching the conditions is the whole test) and GetAutoApproverIdAsync for logging. EvaluateAsync runs three checks in order, each of which can hold the order back: the empty-required-field guard and the unbound-declared-input guard (both described above, and both pure reads of the Affidavit that run whether or not a RiskThreshold is declared), then — only if RiskThreshold is set — the risk comparison, via a scorer injected at construction. If none of the three holds it back, it returns a StandingOrder verdict. As of 1.0.0-beta.3, a Standing Order held back by any of the three degrades instead of vanishing: it returns a verdict requiring reviewer confirmation, with a BlockedReason code and DegradedFrom: ReviewRequirement.StandingOrder on the record, and keeps the policy’s own review window. The risk-ceiling case is the one that changed — the framework used to return null there, letting a later policy in the chain speak as though the order had never fired at all; the two structural guards are new in this release, with no prior behavior to compare to. A host chain that relied on a later policy having the final say after an over-threshold order now stops at the degraded verdict instead — the safe direction — so a chain built on the old behavior should reorder a later policy meant to have the final say ahead of the Standing Order.

Declaring a RiskThreshold with no scorer registered is caught twice: at startup, and again on evaluation. A Standing Order that declares a ceiling but was constructed with no RiskScoreCalculatorBase — the abstract scorer type in Affiant.Policies.Services, registered via SetRiskScoreCalculator<TCalculator>() on the DI builder below — cannot run, because the framework ships no default scoring formula and no floor; what counts as risk is the host’s to say. StandingOrderBase.ConfigurationFault, read by AffiantWireUpValidator, reports that before any write is auto-approved. 1.0.0-beta.3 added that startup check; it did not remove the runtime one — EnsureConfigured() still throws an InvalidOperationException carrying the same message for a host that reaches evaluation anyway. It runs early in StandingOrderBase.EvaluateAsync — before MatchesAsync, so the conditions are never even tested — which is what the source means by “configuration first”. DefaultRiskScoreCalculator, an earlier stock formula, was removed in 1.0.0-beta.1.1 for the opposite reason to the one you might expect: paired with StandingOrderBase’s then-default RiskThreshold of Low, it scored Medium or High on every path, so a Standing Order written to the documented contract could never auto-approve — every one of them fell through to reviewer confirmation.

ReferralRuleBase (Affiant.Policies.Referrals) is the escalation counterpart. A subclass implements MatchesAsync and GetReferredToUserIdAsync(Affidavit, ct); when both match and return a non-empty user ID, EvaluateAsync returns a ReferralRequired verdict. An empty or null referred-to ID is treated as a non-match — the chain continues rather than escalating to nobody. As of 1.0.0-beta.3, a ReferralRequired verdict no longer results in a delegated review — see Referrals and multi-party review: blocked, not delegated on the Docket page for what it does instead (files the entry Pending, blocked, and refuses every decision against it). ReferralRuleBase still exists and still names an escalation target; it’s the framework’s handling of a match that changed, not the policy shape itself.

ReferralRuleBase.ReferralTimeToLive is the Referral policy’s equivalent of a Standing Order’s own review window: a protected virtual TimeSpan? defaulting to null, which a subclass overrides and the class supplies as its explicitly-implemented IApprovalPolicy.DefaultTimeToLive — not a member a caller can read. EvaluateAsync also stamps that same value on every verdict it returns, so an overridden window reaches the gate as the verdict’s own TimeToLive and the DefaultTimeToLive fallback never has to speak for this class.

ReviewerConfirmationPolicy (Affiant.Core.Policies) and the internal DefaultReviewerConfirmationPolicy registered by AddDefaultReviewerConfirmation() (below) both do the same thing: unconditionally return ReviewRequirement.ReviewerConfirmation. Registering one is largely a matter of making the “always require a human” default explicit in a policy graph, since ApprovalPolicyEvaluator’s own built-in fallback already does the same thing when no policy matches at all.

Wiring the policy graph: AddAffiantPolicies

Section titled “Wiring the policy graph: AddAffiantPolicies”
services.AddAffiantPolicies(policies =>
{
policies
.AddStandingOrder<LowValueAutoApproval>()
.AddReferralRule<HighValueEscalation>()
.AddDefaultReviewerConfirmation();
});

AddAffiantPolicies hands a PoliciesBuilder to the configuration callback, then registers a placeholder RiskScoreCalculatorBaseMissingRiskScoreCalculator, via TryAddSingleton so a host registration always wins — purely so a Standing Order whose constructor takes the type as a required dependency still resolves from DI. ComputeAsync — the abstract member the placeholder overrides, and the only one that scores anything — throws, naming SetRiskScoreCalculator<T>(); ClassifyScore, the non-virtual band lookup on RiskScoreCalculatorBase, still returns normally. PoliciesBuilder.AddStandingOrder<TPolicy>() and AddReferralRule<TRule>() both register their type against IApprovalPolicy — order matters, because that’s the order ApprovalPolicyEvaluator walks the chain in, which is why specific Standing Orders and Referral rules should be registered before the catch-all AddDefaultReviewerConfirmation() call. SetRiskScoreCalculator<TCalculator>() removes any existing RiskScoreCalculatorBase registration and adds its own — it can be called anywhere in the chain, before or after AddStandingOrder, since Standing Orders resolve their scorer only after the whole chain has run.

Rule 3 is the seam between “the LLM proposed something” and “the database changed”: a WriteProposal reaches ReviewGate through ReviewGateFilter and a host’s IReviewContextProvider; ReviewGate asks IApprovalPolicyEvaluator how much human attention it needs, files it into the Docket under a deadline stamped from what that chain returned, and — when a human is needed — sends an Evidence Card over the transport described in Transport & Wire Contract and returns, leaving the eventual decision to arrive on a separate HandleDecisionAsync call. Whatever ReviewOutcome that call settles, only a host’s own IWriteExecutor is ever allowed to turn an approved Affidavit into a row in the host’s own system of record.