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 files the proposal, evaluates approval policy, and — when a human is required — sends an Evidence Card and blocks on the response; and IWriteExecutor, a host-implemented interface that is the only place in the entire system a mutation is allowed to happen. This page covers both, plus the IApprovalPolicy pipeline that decides how much human involvement any given proposal actually needs.
From WriteProposal to ReviewGate
Section titled “From WriteProposal to ReviewGate”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);In a Semantic Kernel host, this hand-off happens automatically: Affiant.SemanticKernel ships a ReviewGateFilter — an IAutoFunctionInvocationFilter registered as position 7 of the framework’s filter pipeline — that runs after every auto-invoked tool call, checks whether the result deserializes as a WriteProposal, and if so, resolves IReviewContextProvider and ReviewGate from a fresh DI scope and calls ReviewGate.FileReviewAsync itself. If either service isn’t registered, or BuildReviewContext returns null, the filter logs and skips — it never throws the write proposal away, and it never fails a host that hasn’t wired up the review infrastructure yet.
ReviewGate.FileReviewAsync: the state machine
Section titled “ReviewGate.FileReviewAsync: the state machine”ReviewGate lives in Affiant.Core.Services and takes its dependencies through its constructor — IStreamingTransport, IDocketStore, IApprovalPolicyEvaluator, and a logger. It is not registered by AddAffiantCore(); that method’s own documentation calls out ReviewGate explicitly as a service a host must register directly, because its lifetime depends on host-chosen transport and persistence adapters. FileReviewAsync is the entry point:
public async Task<ReviewOutcome> FileReviewAsync( WriteProposal proposal, ReviewContext context, CancellationToken cancellationToken = default)Walked through step by step, against the actual implementation:
- Idempotency check. If
context.EntryIdnames an entry that already exists and is no longerPending, the method returns its already-resolved outcome immediately rather than re-filing or re-evaluating anything. - File the entry. If no entry exists yet,
ReviewGatebuilds aDocketEntry—Status: ReviewStatus.Pending,ExpiresAt: DateTimeOffset.UtcNow.AddMinutes(10)— and callsIDocketStore.FileDocketEntryAsync. That 10-minute window is a constant (DocketTimeoutMinutes) insideReviewGateitself; in the current beta it is not exposed as a configurable option. - Evaluate approval policy.
IApprovalPolicyEvaluator.EvaluateAsync(context.Affidavit, ...)returns aReviewRequirement— see Approval policies below. - Branch on the requirement:
StandingOrder— the entry is transitioned straight toReviewStatus.ApprovedandReviewOutcome.Approvedis returned. No Evidence Card is sent; no human is involved.ReferralRequired— the entry is transitioned toReviewStatus.DeferredandReviewOutcome.Referralis returned, again without sending an Evidence Card synchronously. This is the Referral path covered from the Docket’s side in Docket & Evidence Cards.- Anything else (
ReviewerConfirmationorMultiParty) —ReviewGatebuilds anEvidenceCardRequestand callsIStreamingTransport.BroadcastToGroupAsyncto send it to the session’s group, then callsIStreamingTransport.AwaitEventAsync<EvidenceCardResponse>, blocking with an internalCancellationTokenSourcethat cancels after the same 10-minute window.
- Resolve the response. If the wait times out, the entry is marked
ExpiredandReviewOutcome.Expiredis returned. If the reviewer rejected, the entry is markedRejectedandReviewOutcome.Rejected(carrying the reviewer’s optional reason) is returned. If approved,ReviewGatecallsIDocketStore.UpdateReviewStatusAsyncone more time — and here theWHERE Status = 'Pending'-style guard fromIDocketStorematters: if it reports0rows affected, someone else already resolved this entry (a double-submit, or a restart-path resolution — see below), soReviewGatere-reads the entry and returns whatever it actually settled to, instead of claiming a second approval that didn’t happen.
The return type, ReviewOutcome, is a closed hierarchy — Approved, Rejected, Expired, Referral — each carrying the DocketId (the same Guid as DocketEntry.EntryId) and, for Rejected, a Reason, and for Referral, an EscalationPath string.
Delivering the reviewer’s decision
Section titled “Delivering the reviewer’s decision”FileReviewAsync blocks inside a running process, but a review can outlive that process — a host can restart between an Evidence Card being sent and a reviewer clicking a button. ReviewGate exposes two more methods for that:
HandleDecisionAsync(Guid entryId, ApprovalDecision decision, ...)first triesIStreamingTransport.TryDeliverResponse— if aFileReviewAsynccall is live and actually waiting on thisentryId, the decision is delivered straight to it andHandleDecisionAsyncreturns(null, null), leaving the waiting call to own the outcome. If no live waiter exists, it falls back to the replay path: read the entry fromIDocketStore, check it’s stillPendingand not expired, and transition it directly.ReplayApprovalAsync(Guid entryId, ApprovalDecision decision, ...)is the same replay logic on its own, for a host that always wants to resolve a decision against the Docket rather than trying the live-waiter path first.
A host’s SignalR hub method (or API endpoint) handling a reviewer’s Approve/Reject click calls one of these, not FileReviewAsync directly — FileReviewAsync is for filing a new proposal, these two are for resolving an existing one.
IWriteExecutor: the host-owned single write path
Section titled “IWriteExecutor: the host-owned single write path”public interface IWriteExecutor{ Task<string?> ExecuteAsync(Affidavit affidavit, Dictionary<string, object>? amendments, CancellationToken ct);}This is the interface Rule 3 exists to protect: it is the only sanctioned call site for a mutation anywhere in an Affiant-based host. 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.
The detail worth being precise about: nothing in Affiant.Core or Affiant.SemanticKernel calls IWriteExecutor. ReviewGateFilter files the review and logs the outcome type — it does not act on ReviewOutcome.Approved by invoking a write. ReviewGate itself never references IWriteExecutor either. The call is entirely host code: after a host observes an Approved outcome — whether from FileReviewAsync returning directly, or from HandleDecisionAsync/ReplayApprovalAsync resolving a pending entry — it is the host’s own responsibility to look up the DocketEntry, and call its IWriteExecutor.ExecuteAsync with that entry’s Envelope and Amendments. One detail to watch when doing that hand-off: DocketEntry.Amendments is typed IReadOnlyDictionary<string, object>?, while ExecuteAsync expects a concrete Dictionary<string, object>? — a host copies rather than casts.
That every write, in every host, funnels through one interface with one method is what makes the framework’s evidentiary claim hold: there is no second, undocumented path by which an Affidavit becomes a database row. If a host wants to know whether a mutation could have happened outside of review, the answer is: only if something called IWriteExecutor.ExecuteAsync directly, bypassing ReviewGate — which is exactly the kind of thing a code review or a compliance harness check can catch, because there’s only one interface to watch.
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 at step 3 above) actually iterates over:
public interface IApprovalPolicy{ Task<ReviewRequirement?> EvaluateAsync(Affidavit affidavit, CancellationToken cancellationToken = default);}
public enum ReviewRequirement{ StandingOrder, ReviewerConfirmation, ReferralRequired, MultiParty}A policy returns null to defer to the next policy in the chain, or a ReviewRequirement to terminate the chain with that value. IApprovalPolicyEvaluator (implemented by Affiant.Core.Services.ApprovalPolicyEvaluator) 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.
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 (default: (int)RiskLevel.Low, i.e. 1) and GetAutoApproverIdAsync for logging. EvaluateAsync checks MatchesAsync first, then computes a risk score via the injected RiskScoreCalculator; if the score is at or below RiskThreshold, it returns ReviewRequirement.StandingOrder, otherwise null — the affidavit matched the order’s conditions but was too risky to auto-approve, so the chain continues.
RiskScoreCalculator (Affiant.Policies.Services) is the abstract scorer a StandingOrderBase consumes:
public abstract class RiskScoreCalculator{ public virtual async Task<int> ComputeAsync(Affidavit affidavit, CancellationToken cancellationToken = default); public RiskLevel ClassifyScore(int score);}
public enum RiskLevel { Low = 1, Medium = 2, High = 3 }The default implementation looks for an AffidavitField named "Value" and scores by magnitude — greater than 50 is High, present-but-lower is Medium, absent is Medium. DefaultRiskScoreCalculator is the framework’s own no-op subclass registered by default; a host with real risk logic overrides it via SetRiskScoreCalculator<TCalculator>() on the DI builder below.
ReferralRuleBase (Affiant.Policies.Referrals) is the escalation counterpart, covered from the Docket side in Docket & Evidence Cards. A subclass implements MatchesAsync and GetReferredToUserIdAsync(Affidavit, ct); when both match and return a non-empty user ID, EvaluateAsync returns ReviewRequirement.ReferralRequired. An empty or null referred-to ID is treated as a non-match — the chain continues rather than escalating to nobody.
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 registers DefaultRiskScoreCalculator (via TryAddScoped, so a host registration elsewhere wins) and hands a PoliciesBuilder to the configuration callback. 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>() replaces the registered RiskScoreCalculator entirely and should be called before any AddStandingOrder call that depends on it.
Where this fits
Section titled “Where this fits”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 files it into the Docket, asks IApprovalPolicyEvaluator how much human attention it needs, and — when a human is needed — sends and awaits an Evidence Card over the transport described in Transport & Wire Contract. Whatever ReviewOutcome comes out the other end, only a host’s own IWriteExecutor is ever allowed to turn an approved Affidavit into a row.