Skip to content

Docket & Evidence Cards

A WriteProposal — the envelope a write tool returns instead of touching the database, covered in Tool Envelopes — does not sit in memory waiting for a reviewer. It is filed into the Docket, a durable, persisted queue of pending proposals, as a DocketEntry. What a reviewer actually sees on screen is an Evidence Card: the wire payload that renders the entry’s Affidavit — every field, its proposed value, and its provenance — as something a human can approve or reject. This page covers the DocketEntry lifecycle, the IDocketStore contract that persists it, the Evidence Card payloads that carry it to and from the reviewer, and what happens when a policy asks for an escalation or a multi-party approval the framework doesn’t yet implement.

DocketEntry is defined in Affiant.Abstractions.Models:

public sealed record DocketEntry(
Guid EntryId,
string SessionId,
string TenantId,
string UserId,
string? ReviewerUserId, // [Obsolete] as of 1.0.0-beta.3 — see below
string OperationType,
Affidavit Envelope,
ReviewStatus Status,
DateTimeOffset CreatedAt,
DateTimeOffset ExpiresAt,
IReadOnlyDictionary<string, object?>? Amendments,
Guid? ResubmittedTo = null,
ExecutionOutcome? Execution = null,
string? ExecutionDetail = null,
DecisionRecord? Decision = null,
Attestation? Attestation = null,
BlockedMarker? Blocked = null,
string? CompositeRef = null,
Affidavit? AmendedAffidavit = null,
PreservedAmendments? PreservedAmendments = null,
Guid? Supersedes = null,
DateTimeOffset? DecidedAt = null,
string ProtocolVersion = AffiantProtocol.Version)
{
// Declared outside the positional constructor: three init-only properties, one computed.
// ToolName is a manual property, not an auto-property: the fallback is in the getter.
private readonly string? _toolName;
public string ToolName
{
get => _toolName ?? OperationType;
init => _toolName = value;
}
public ReviewRequirement Requirement { get; init; } = ReviewRequirement.ReviewerConfirmation;
public string? Channel { get; init; }
public Lineage Lineage => new(Supersedes, ResubmittedTo); // computed, read-only
}

(Shown here at the shape the record now has — the constructor’s own twelve original parameters (EntryId through ResubmittedTo, per the source’s own comment, which counts ResubmittedTo among them), Execution through ProtocolVersion added as eleven later, defaulted parameters, plus three init-only properties (ToolName, Requirement, Channel) and one computed, read-only property (Lineage) declared outside the positional constructor entirely. DecisionRecord, Attestation, BlockedMarker, and PreservedAmendments are each their own small type, covered below.)

  • EntryId — a Guid that is both the entry’s identity and its idempotency key. The same EntryId filed twice is a no-op, not a duplicate entry (see IDocketStore below). It’s also the value that flows through the framework under two other names: EvidenceCardRequest.DocketId and ReviewOutcome.DocketId refer to the same Guid — the framework doesn’t invent a second identity for the same pending review. As of 1.0.0-beta.3 an entry filed with no explicit id has one derived, not randomly minted: the SHA-256 of the tenant, the conversation, the tool name, and the canonical form of the proposed operation and its arguments, laid out as a version-8 UUID (Affiant.Core.Services.EntryIdDerivation) — so two implementations filing the same proposal agree on which row it is, and a resubmission’s id is derived the same way plus the id of the row it replaces.
  • SessionId, TenantId, UserId — the conversation, tenant, and user that produced the proposal.
  • ReviewerUserIddeprecated as of 1.0.0-beta.3 (AFFIANT0001), in favour of Attestation below, which can say how a decision was made — a person, a person through a relay, or a Standing Order — where ReviewerUserId can only name one id. Scheduled for removal in the release after this one.
  • OperationType — the tool name that produced the proposal, copied from WriteProposal.ToolName.
  • Envelope — the Affidavit itself: the sworn, per-field-provenance record covered in Affidavits & Provenance. This is what the Evidence Card renders, and it is never edited in place — an accepted amendment is written to AmendedAffidavit (below) beside it, not over it.
  • Status — a ReviewStatus value; see the state machine below.
  • CreatedAt / ExpiresAt — when the entry was filed and when it stops being actionable. ExpiresAt is stamped when the entry is filed, as CreatedAt plus the window the approval-policy chain returned — the verdict’s own TimeToLive, into which the chain has already folded the policy’s DefaultTimeToLive — falling back to AffiantCoreOptions.DefaultDocketTtl only when the verdict names none. DefaultDocketTtl is a host-configurable option, set via AddAffiantCore(options => options.DefaultDocketTtl = ...), that defaults to 30 minutes rather than being a fixed constant. A related option, DocketExpiryWarningWindow, defaults to 2 minutes and controls how far ahead of ExpiresAt the framework starts warning the UI that a docket is about to expire; a background sweep checks for both conditions on a bounded, batched tick (AffiantDocketOptions.ExpirySweepBatchSize, default 100, and ExpirySweepBatchesPerTick, default 10). As of 1.0.0-beta.3 the pipeline runs in the order the rules fix — runtime substance refusal, then the idempotent-replay lookup that answers an id already on the Docket from the row it already holds, then the approval-policy chain, then the deadline stamped from what the chain returned, then filed — so a policy’s own verdict can set the window instead of one host-wide default applying regardless of policy outcome. See Review Gate & Write Executors for where DefaultDocketTtl is consumed and how filing works.
  • Amendments — fields a reviewer changed, keyed by field name, written at two different moments. It is seeded when the entry is filed, from the ReviewContext a host builds for the proposal; the reviewer’s own edits — the ones a card comes back with on EvidenceCardResponse.Amendments, which a host hands to ReviewGate.HandleDecisionAsync as its amendments argument — are written afterwards, by the guarded transition an approval performs, and only by an approval: a rejection accepts nothing, so it records nothing here. On approval they are what a host passes its IWriteExecutor alongside the Affidavit so the executor can apply the reviewer’s edits rather than the LLM’s original proposal — see Review Gate & Write Executors for that hand-off.
  • Attestation — who or what decided, written by the decision itself. The record is Attestation(Attestor By, DateTimeOffset At, Guid EntryId); the three kinds are arms of Attestor: Member (a human-verified session), MemberViaRelay (a machine caller naming both the person it spoke for and the relay assertion that carried them — the record must not read as though the person signed in directly), or StandingOrder (naming the policy and the version it fired under; a policy that versions nothing records "unversioned" rather than a blank). A Service principal with neither an asserted member nor a relay assertion is refused outright — a machine cannot agree to a write in a person’s name. The rule is structural: every attestor kind’s constructor is private, and the only member attestation a host can produce comes from a factory taking a Principal.Member, so there is no expression through which a machine caller reaches one. (A second factory, Attestor.Member.FromStorage(string), mints one from a bare id, but it is internal — rehydration is the stores’ business, and the assemblies that may see it are named in Affiant.Abstractions.csproj.) The store itself refuses to write an Approved or Rejected transition with no attestation, and refuses an execution report against a row that carries none — defence in depth, on top of the decision core making the state unreachable in the first place.
  • Execution (ExecutionOutcome?: Unexecuted, Executed, or Failed) — non-null exactly when the row is Approved, defaulting to Unexecuted, and null otherwise: the transition writes it that way. ExecutionDetail — what the executor reported — carries no such rule; it is null when the executor has not reported or had nothing to say. ReviewGate.MarkExecutedAsync(entryId, outcome, detail, context, ct) is the supported path to an executed write: the host runs its own executor against the attested row and reports what happened, once. The gate resolves the caller’s principal, refuses a row that is approved and carries no attestation, and then calls IDocketStore.RecordExecutionAsync — a public store member a host holding the store can call directly too, under the store’s own guards: the tenant scope (the store-wide scope is refused), the same attestation rule, and a compare-and-set out of Unexecuted. Through either door a second report is refused with execution-already-recorded and the first stands. The status stays Approved either way — the approval happened and is not undone by a failed write.
  • Decision (DecisionRecord: { Kind, Reason, At }, Kind an Approve/Reject enum) — what a reviewer chose and why, or null for a pending row or a Standing Order’s approval, where no person chose anything. Kept separate from Attestation, which answers a different question — who may be held to this rather than what they chose.
  • Blocked (RequirementNotImplemented(Level) or CoverageRefused(Category, ToolName)) — why an entry sitting Pending cannot be decided at all. See Referrals and multi-party review below for RequirementNotImplemented, and The Honest Boundary for CoverageRefused.
  • CompositeRef — the composite approval this entry is one constituent of, or null. Nothing in the framework writes it: ReviewContext carries no composite to name, ReviewGate names none on the row it constructs, and DocketTransitionPatch has no member for it — the EF store maps the column and round-trips whatever it holds, and that is the whole of it. The record’s own note describes what it is for: until multi-party semantics land, a host composes multi-party approval above the gate, one entry per approver, all naming the same composite, with no single constituent’s approval alone reaching the executor. A host doing that is constructing DocketEntry values itself and filing them through IDocketStore.FileDocketEntryAsync — the gate has no filing path that sets it.
  • AmendedAffidavit and PreservedAmendments ({ Amendments, At, By }) — two different facts a correction can produce. An accepted amendment folds into AmendedAffidavit, which travels beside the original proposal (Envelope is never edited); a refused late decision’s amendments — typed by a reviewer whose click lost the race with the deadline — are kept in PreservedAmendments instead, so a resubmission can prefill them without the record claiming a refused caller’s edits were ever approved.
  • Supersedes / ResubmittedTo, read together as the computed Lineage property ({ Supersedes, SupersededBy }) — the id a resubmitted row replaced, and the id of the row a superseded one was resubmitted to (ResubmittedTo is the older name for the same fact Lineage.SupersededBy reads).
  • DecidedAt — when the decision was made, stamped from the gate’s own TimeProvider rather than a caller-supplied instant, for the same reason an Affidavit’s CreatedAt is: a caller cannot date its own agreement, and a row cannot be back-dated inside its deadline by the caller whose lateness the deadline is about.
  • ProtocolVersion — the Affiant protocol version this row’s shapes conform to, defaulting to the build’s own version.
  • ToolName — an init-only property, not a constructor parameter, falling back to OperationType when nothing set it explicitly, so a row filed by any release carries a correct tool name; new code writes and reads ToolName.
  • Requirement — the review level the policy chain resolved (StandingOrder, ReviewerConfirmation, ReferralRequired, or MultiParty), init-only, defaulting to ReviewerConfirmation. Before 1.0.0-beta.3 this was nowhere on the record, and a reader had to infer it from what happened afterward — which can’t distinguish a row that required one reviewer from one that required two and got one.
  • Channel — an init-only property: the channel the proposal arrived on, as the host’s ConversationIdentity.Channel carried it, null when the host named none.

Every field from Execution through DecidedAt is a later fact, appended beside what the row already held — with two exceptions, both sitting inside that range and appended by nothing. Supersedes is written at filing, and so are Requirement, Channel and ProtocolVersion: the gate stamps all four in the same operation that files the row, so they are as much part of the filing as Envelope and CreatedAt. CompositeRef is the other, and it is written at no moment at all (above). Lineage is neither; it is computed from Supersedes and ResubmittedTo.

public enum ReviewStatus
{
Pending,
Approved,
Rejected,
Expired,
Deferred
}

Every entry starts Pending. From there, ReviewGate (covered in full on Review Gate & Write Executors) is what actually drives entries to Approved, Rejected, or Expired.

Deferred is declared but, as of 1.0.0-beta.3, nothing writes it. Before this release a ReferralRequired verdict transitioned an entry to Deferred; that path is gone (see Referrals and multi-party review below) — a ReferralRequired or MultiParty verdict now files the entry Pending with a Blocked marker instead. The enum member stays for now because removing it is its own breaking change, but a reader should not expect to see a Deferred entry produced by the shipped gate.

The same file defines a ReviewStep record — ReviewerId, Status, ReviewedAt, an optional Comment — intended to capture one step of a multi-step review history. It is declared but not yet referenced by DocketEntry itself, which has no Steps collection; a DocketEntry records only its single Status, not a history of prior review steps.

IDocketStore, in Affiant.Abstractions.Interfaces, is what makes the Docket durable rather than in-memory ceremony. As of 1.0.0-beta.3 it is a substantially larger contract than beta.1’s — twelve members added, four removed — because the rules the framework enforces (a guarded transition, a once-only execution report, a bounded and tenant-scoped listing) are now stated as part of the store’s own contract rather than left to each backend to get right on its own:

public interface IDocketStore
{
Task SaveContextAsync(string sessionId, ConversationContext context, CancellationToken ct);
Task<ConversationContext?> LoadContextAsync(string sessionId, CancellationToken ct);
Task FileDocketEntryAsync(DocketEntry entry, CancellationToken ct);
Task<DocketEntry?> GetDocketEntryAsync(Guid entryId, CancellationToken ct);
// Resubmission (Area-5 Decision 2): the race guard and the reverse lookup for its lineage.
Task<int> ConsumeForResubmitAsync(Guid entryId, Guid newEntryId, CancellationToken ct);
Task<DocketEntry?> GetResubmissionParentAsync(Guid entryId, CancellationToken ct);
// Kept for one release, both [Obsolete] AFFIANT0001 — unpaged and/or unscoped:
Task<IReadOnlyList<DocketEntry>> ListPendingBySessionAsync(string sessionId, CancellationToken ct);
Task<IReadOnlyList<DocketEntry>> ListAllPendingAsync(CancellationToken ct);
// The scoped, guarded, paged surface — the Docket's real contract as of 1.0.0-beta.3:
Task<DocketTransitionResult> TransitionAsync(
Guid entryId, DocketScope scope, ReviewStatus expected, DocketTransitionPatch patch, CancellationToken ct);
Task<PreserveAmendmentsResult> PreserveAmendmentsAsync(
Guid entryId, DocketScope scope, IReadOnlyDictionary<string, object?> amendments, PreservedAct act, CancellationToken ct);
Task<RecordExecutionResult> RecordExecutionAsync(
Guid entryId, DocketScope scope, ExecutionOutcome outcome, string? detail, ExecutionOutcome expected, CancellationToken ct);
Task<RecordSupersessionResult> RecordSupersessionAsync(
Guid entryId, DocketScope scope, Guid supersededBy, CancellationToken ct);
Task<int> MarkBlockedAsync(Guid entryId, DocketScope scope, BlockedMarker marker, CancellationToken ct);
Task<long> CountPendingAsync(CancellationToken ct);
Task<DocketPageResult<DocketEntry>> ListPendingAsync(DocketScope scope, DocketPage page, CancellationToken ct);
Task<DocketPageResult<DocketEntry>> ListApprovedUnexecutedAsync(DocketScope scope, DocketPage page, CancellationToken ct);
Task<ExpireDueResult> ExpireDueAsync(DateTimeOffset now, DocketScope scope, int limit, CancellationToken ct);
Task<RetentionResult> ApplyRetentionAsync(DocketRetentionPolicy policy, DocketScope scope, int limit, CancellationToken ct);
Task<int> PurgeTenantAsync(string tenantId, CancellationToken ct);
IAsyncEnumerable<DocketEntry> ExportAsync(DocketScope scope, CancellationToken ct);
}

(The exact current shape, from Affiant.Abstractions.Interfaces.IDocketStore; see Affiant.Docket’s in-memory store for the reference implementation, and DocketRow/DocketCursor for the row semantics and page-cursor shape every backend shares, so an implementer writes queries against those, not the rules themselves.)

SaveContextAsync / LoadContextAsync persist a session’s ConversationContext — a SessionId and its EntityRefs, the state the Context Fabric holds while a turn runs. The fabric itself is a scoped service, one instance per turn scope, and nothing in the framework saves it: writing that state down, so it outlives the turn or a restart, is the host’s own work through SaveContextAsync, which no framework code calls. Reading it back is not only the host’s: on Semantic Kernel, SessionRehydrator.RehydrateAsync calls LoadContextAsync on reconnect and hands what it finds to the host on RehydrationResult.Context — there is no equivalent on the other two backends, and nothing rehydrates the fabric itself from it. The rest of the interface is the Docket proper.

FileDocketEntryAsync must be idempotent on EntryId: filing the same entry twice is a no-op, not a second row. This is what makes it safe for the framework to retry filing without risking a duplicate review appearing to two different reviewers. As of 1.0.0-beta.3 it also refuses a row that is anything other than Pending: a decided row filed directly would put a state nobody agreed to in front of the host’s executor without ever passing the guarded transition that checks who agreed.

ConsumeForResubmitAsync and GetResubmissionParentAsync back a resubmission’s lineage. ConsumeForResubmitAsync atomically claims an expired entry for resubmission by writing newEntryId onto its ResubmittedTo — the same guard shape as TransitionAsync, equivalent to WHERE Status = 'Expired' AND ResubmittedTo IS NULL, so two concurrent resubmissions of one entry can never both succeed. There is deliberately no ReviewStatus.Resubmitted: the source entry stays Expired forever, and ResubmittedTo alone records that it was superseded. GetResubmissionParentAsync is the reverse lookup — given a fresh entry, find the expired one it came from — used to re-derive amendments a reconnecting client missed.

ListPendingBySessionAsync and ListAllPendingAsync are [Obsolete] (AFFIANT0001, a warning not an error, kept for one release): the first is unpaged and returns everything for one session, the second is unpaged across the whole store with no ordering contract. Replace them with the scoped, paged members below — ListPendingAsync(DocketScope.Conversation(tenantId, conversationId), page, ct) and ListPendingAsync(DocketScope.EntireStore, page, ct) respectively. (The [Obsolete] message spells that second argument sessionId; the factory’s own parameter is conversationId, matched against DocketEntry.SessionId.)

DocketScope names the tenant a call is scoped to, and optionally the conversation within it (DocketScope.Conversation(tenantId, conversationId)); DocketScope.EntireStore (a null TenantId) is the store-wide scope. What refuses it — with ArgumentException, through DocketRow.RequireTenant — is the five members that move a single row on a caller’s behalf: TransitionAsync, PreserveAmendmentsAsync, RecordExecutionAsync, RecordSupersessionAsync, and MarkBlockedAsync. A decision, a preserved late amendment, an execution report, and a supersession each name the tenant they belong to, so no code path moves a row without one. The reading and maintenance members take it as it comes: ListPendingAsync and ListApprovedUnexecutedAsync — the paged replacement for ListAllPendingAsync above is exactly ListPendingAsync(DocketScope.EntireStore, page, ct) — along with ExpireDueAsync, ApplyRetentionAsync, and ExportAsync. (CountPendingAsync takes no scope at all; it counts the whole store.) A lookup with the wrong tenant is not an error, it’s a miss, indistinguishable from an id that does not exist.

TransitionAsync replaces UpdateReviewStatusAsync (removed — see below) as the framework’s guarded compare-and-set: it takes the row’s expected current status (always Pending — nothing else in the state machine transitions out of it) and a DocketTransitionPatch, and answers one of four DocketTransitionResult arms — Transitioned(Entry), NotFound, AlreadyDecided, or Expired — rather than a bare row count. This is the same double-submit guard UpdateReviewStatusAsync used to provide, restated as an explicit result a caller can branch on instead of inferring from 0 rows affected. A row carrying a Blocked marker refuses every transition with AlreadyDecided except a transition to Expired: a blocked entry still runs out of time like any other. ReviewGate relies on exactly this contract — see Review Gate & Write Executors for how it reacts to each result.

PreserveAmendmentsAsync writes a refused late decision’s amendments — a reviewer’s edits that lost the race with the deadline — onto DocketEntry.PreservedAmendments, carrying the decision’s own instant and principal as a PreservedAct, so a resubmission can prefill them without the record claiming they were ever approved. It answers Preserved(Entry), NotFound, or NotExpired.

RecordExecutionAsync is the host’s once-only execution report, guarded out of ExecutionOutcome.Unexecuted the same way a decision is guarded out of Pending — a second report against an already-recorded execution answers ExecutionAlreadyRecorded rather than silently overwriting the first, distinct from NotApproved, which the stores answer both for a row that was never approved at all and for an approved row carrying no attestation: neither is an authorised write to report on.

RecordSupersessionAsync and MarkBlockedAsync write the two other later facts a row can carry — the id of the row that replaced a terminal entry (RecordSupersessionResult: Recorded, NotFound, or NotTerminal), and why an entry sitting Pending cannot be decided at all (see Referrals and multi-party review below). MarkBlockedAsync returns 1 when this call wrote the marker, 0 when the row was not pending or already carries one — written once, under the same kind of guard every other transition uses.

ListPendingAsync and ListApprovedUnexecutedAsync return a DocketPageResult<DocketEntry>{ Items, Cursor, More } — from an opaque DocketPage { Limit, Cursor } request. CountPendingAsync answers how many entries are awaiting review as a bare long — a count query on a SQL store, never a listing of the rows. It is new in 1.0.0-beta.3, and what reads it is the docket-depth gauge affiant.docket.pending (Affiant.Core.Observability.DocketDepthInstrument, new in the same release, registered by AddAffiantCore when AffiantCoreOptions.EnableObservability is on): a metrics scrape reads a cached snapshot rather than the store, and an observation made on a sample older than fifteen seconds starts one background refresh, with at most one in flight at a time — so the number is a trend signal that can be a sample interval plus a refresh out of date.

ExpireDueAsync is the bounded, scoped sweep primitive: it finds the due rows and commits their transitions under one guard, returning ExpireDueResult { Expired, More } — the entries this call itself transitioned, and whether another call with the same arguments would find more. Affiant.Docket’s DocketExpiryService is what calls it: a BackgroundService driving a PeriodicTimer on a 30-second tick, registered unconditionally by AddAffiantDocket(). This class owns a schedule; the store owns the sweep — every decision about what expires is the store’s, and the service does nothing but call it until the store says no more remain or until a tick’s own cap is reached (AffiantDocketOptions.ExpirySweepBatchesPerTick batches of ExpirySweepBatchSize). That is why a large backlog drains across ticks instead of loading the whole Docket into memory at once. A tick runs three phases, each with its own budget so none can starve another, and each one broadcasts only when the host registered an IStreamingTransport: draining the due queue sends a DocketExpired notification for every row the store’s own guarded write transitioned (a row a concurrent decision claimed a beat earlier is not in that list, and that caller owns its notification); then every entry still pending and inside AffiantCoreOptions.DocketExpiryWarningWindow of its deadline gets a DocketExpiring warning, which repeats each tick while the entry stays inside the window and is skipped altogether when that window is not positive; then every entry still pending has its Evidence Card re-broadcast unconditionally, since a group send to zero connected members completes successfully and so can never report whether a card was delivered. Without a transport a tick still transitions every due row and sends none of the three.

ApplyRetentionAsync, PurgeTenantAsync, and ExportAsync are the host’s own maintenance operations. ApplyRetentionAsync takes a DocketRetentionPolicy { OlderThan } and never ages out an approved row whose write has not been reported, however old — it’s the only record that a write was authorised and has not happened. PurgeTenantAsync is unbounded and unpaged by design: a tenant asking for its data to be deleted is asking for all of it. ExportAsync streams every entry in a scope as an IAsyncEnumerable<DocketEntry> rather than a list, so a large Docket never has to fit in memory.

What was removed. UpdateReviewStatusAsync and UpdateAmendmentsAsync are gone, both superseded by TransitionAsync: UpdateAmendmentsAsync in particular took only an entry id — no tenant scope, no expected status, no attestation — so anything holding the store could overwrite any row’s amendments in any tenant, bypassing every check the decision path exists to enforce. ListExpiredAsync and MarkExpiredAsync are gone, superseded by ExpireDueAsync. A host with a custom IDocketStore will not compile against 1.0.0-beta.3 until it implements the new members — deliberately: a default implementation that quietly did the wrong thing would ship a store that looks conforming and is not.

As of 1.0.0-beta.1, the three IDocketStore implementations are split across two packages, not one — Affiant.Docket ships only the in-memory store; the two SQL-backed stores moved to Affiant.EntityFramework, next to the AffiantDbContext and entity configuration they’re built on. This is a deliberate split, not an oversight: the SQL stores take AffiantDbContext as a constructor dependency, and having Affiant.Docket reference Affiant.EntityFramework to get it would have been an adapter-to-adapter dependency the framework’s own layering rule forbids (see Packages). The practical effect: installing Affiant.Docket alone does not drag in EF Core, SQLite, or Npgsql — and has not at any published version.

In-memory — registered by Affiant.Docket’s own AddAffiantDocket, which a host calls for the shipped expiry sweep whatever its persistence choice, because it is also what registers DocketExpiryService as a hosted service — unconditionally, store selection or not. Nothing enforces the call: AddAffiantCore()’s startup validator asks only that some package registered an IDocketStore, and the framework sanctions a host that schedules the sweep itself — a serverless deployment, a cron entry, a queue worker — calling IDocketStore.ExpireDueAsync on its own cadence and never calling AddAffiantDocket at all:

services.AddAffiantDocket(options => options.UseInMemory());

SQLite or PostgreSQL — registered by Affiant.EntityFramework’s AddAffiantEntityFramework, which also supplies the matching IChatSessionStore. AddAffiantDocket() is still called, with no store selection, for the expiry sweep — on the terms just above:

services.AddAffiantEntityFramework(ef => ef.UseSqlite(connectionString)); // or ef.UsePostgres(...)
services.AddAffiantDocket(); // no store selection here — EF already registered IDocketStore

Affiant.EntityFramework’s EntityFrameworkOptions carries the store-selection guard: calling more than one of UsePostgres/UseSqlite/UseInMemory on it throws InvalidOperationException. Affiant.Docket’s DocketOptions has no equivalent to trigger — it exposes UseInMemory() and ExpirySweepBatchSize (which writes through to AffiantDocketOptions.ExpirySweepBatchSize), and nothing else, since UsePostgres/UseSqlite moved to Affiant.EntityFramework in the split described above. ExpirySweepBatchesPerTick is not on this builder; a host that wants to change it sets it on AffiantDocketOptions directly. AddAffiantDocket() does not throw when no store is selected — it has not at any published version — and that is the normal shape for a SQL-backed host, where Affiant.EntityFramework supplies the store instead. If a host registers neither an Affiant.EntityFramework SQL store nor Affiant.Docket’s in-memory one, AddAffiantCore()’s startup validator fails the host at boot with an AffiantStartupException naming the missing registration and the call that supplies it, rather than the first write failing silently against no IDocketStore at all. (AffiantCoreOptions.AcknowledgeMissingReviewWiring downgrades that failure to a startup warning, for a host that deliberately runs without a review loop; a container that does not provide IServiceProviderIsService skips the check altogether.)

Migrating an existing beta.1 database. The new row facts above (Attestation, Execution, Requirement, and the rest) are columns, and 1.0.0-beta.3 adds them two different ways: one checked-in EF migration for PostgreSQL, and a drift heal for SQLite. Both are idempotent and both are safe to run against a database 1.0.0-beta.1 created. The migration, 20260904040752_AddDocketRowFacts, adds the new affiant."Docket" columns and indexes and backfills two integer “tick” columns from the timestamp columns they mirror — SQLite’s EF provider can translate neither an inequality nor an ORDER BY over a DateTimeOffset into SQL, so the tick columns are what let a paged listing or a bounded sweep read an integer instead of loading every candidate row into memory to filter there. SQLite has no migration history for this change (the checked-in migrations were generated under the Npgsql provider); instead, AffiantMigrator’s drift heal adds any of the new columns an existing table lacks, creates the matching indexes, and backfills the tick columns row by row, on every MigrateAffiantSchemaAsync call. Either way, run MigrateAffiantSchemaAsync (or dotnet ef database update on PostgreSQL) before a host built on 1.0.0-beta.3 reads or writes an existing Docket table.

Evidence Cards: the human-facing rendering

Section titled “Evidence Cards: the human-facing rendering”

An Evidence Card is not a distinct C# type — it’s the name for what a reviewer sees when the framework renders an Affidavit for approval. The wire types that carry it are in Affiant.Abstractions.Transport. The listing below is their shape, not code to paste — For’s body is omitted, and so is the FieldPresentation record Presentation carries (Name, Kind, AllowedValues, Pattern, and its own For factory), described in prose below instead:

public record EvidenceCardRequest(
Guid DocketId,
Affidavit Affidavit,
DateTimeOffset RequiredBy,
IReadOnlyDictionary<string, object?>? PriorAmendments = null,
float? PopulatedConfidence = null,
int EmptyFieldCount = 0,
bool RequiresConfirmation = true,
BlockedMarker? Blocked = null,
IReadOnlyList<FieldPresentation>? Presentation = null,
IReadOnlyList<string>? Warnings = null,
string? HostOperation = null)
{
public string ProtocolVersion { get; init; } = AffiantProtocol.Version; // init-only, not a constructor parameter
// Presentation, Warnings and HostOperation are each re-declared with
// [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)], which is what keeps them
// off the wire when unset while every other nullable is written null. Elided here.
public static EvidenceCardRequest For(
Guid docketId,
Affidavit affidavit,
DateTimeOffset requiredBy,
IReadOnlyDictionary<string, object?>? priorAmendments = null,
BlockedMarker? blocked = null,
string? hostOperation = null);
}
public enum ApprovalDecision
{
Approved,
Rejected
}
public record EvidenceCardResponse(
Guid DocketId,
ApprovalDecision Decision,
string? Reason = null,
IReadOnlyDictionary<string, object?>? Amendments = null)
{
// Plus one init-only property elided here: Attestation. It is [JsonIgnore]d, so it never
// crosses the wire, and no code the framework ships at 1.0.0-beta.3 constructs an
// EvidenceCardResponse or sets it.
}

EvidenceCardRequest is sent to the reviewer’s session — DocketId matches the filed DocketEntry.EntryId, Affidavit is the full sworn record with every field’s provenance, and RequiredBy is the entry’s ExpiresAt, so the UI can show a countdown rather than let a reviewer approve something already past its window. PriorAmendments carries a resubmission’s inherited corrections forward onto the fresh card.

The seven constructor parameters from PopulatedConfidence onward, and the init-only ProtocolVersion beside them, were added in 1.0.0-beta.3 — at 1.0.0-beta.1 the record ended at PriorAmendments. Build a card with EvidenceCardRequest.For(docketId, affidavit, requiredBy, priorAmendments, blocked, hostOperation) rather than the constructor directly — it’s the factory that lifts everything the envelope repeats from the Affidavit itself. The framework’s own card-building sites never call it directly: every card it builds — on the filing path, on a replay of an entry filed again, on a reconnect’s re-broadcast, on the Standing-Order and blocked branches, and on the expiry sweep — goes through Affiant.Core.Services.EvidenceCardRequestFactory.CreateAsync, which looks the entry’s resubmission parent up in the store for the prior amendments a fresh card carries forward, and then calls For once — which is what stops those paths building a differently-shaped card for the same entry. For reads affidavit.PopulatedConfidence and affidavit.EmptyFieldCount straight off the record — the two companions to the aggregate confidence described in Affidavits & Provenance; sets RequiresConfirmation to false whenever blocked is non-null (a card carrying a marker that says no decision will be accepted must not also offer a working approve button) and to affidavit.RequiresConfirmation otherwise; derives Presentation from each field’s own AllowedValues and Pattern — the two things that constrain a reviewer’s input — carrying the field’s Kind along in the entry but never creating one on the strength of a Kind alone, and omitting the section entirely when no field declares either; and copies Warnings from the Affidavit’s own, likewise omitted when empty. CreateAsync carries three optional arguments of its own past that factory — hostOperation, blocked, and requiresConfirmation, the last of which overrides the answer For computed: the Standing-Order branch passes false, so an auto-approved write’s card carries RequiresConfirmation: false with no Blocked marker on it at all — a person was not asked, and the card is there so the reviewer surface can see what was approved in the organisation’s name rather than to collect a confirmation. HostOperation — the host’s own verb for the operation, “Reprice”, “Onboard” — is a plain passed-through argument, carried beside the two-valued operation shape an Affidavit swears to — create or update, out of a framework operation vocabulary that is itself four-valued (ReadQuery, WriteCreate, WriteUpdate, WriteDelete) — never instead of it, so a card can be headed with a term a person recognises while a policy still tests the shape underneath. Presentation, Warnings, and HostOperation are omitted from the wire entirely when empty or unset; PopulatedConfidence and Blocked are still written null when absent, and EmptyFieldCount/RequiresConfirmation always carry a real value (0/true by default) rather than being optional at all — a reader can rely on finding every property except the three that are genuinely absent-when-empty.

EvidenceCardResponse is what comes back over the wire: an ApprovalDecision of Approved or Rejected, an optional Reason a reviewer can attach, and Amendments, the fields the reviewer edited before approving (keyed by field name, a null value meaning the reviewer cleared it). Nothing in the framework reads that Reason: getting it onto the row is the host’s own hand-off, the same one this page marks for Amendments above — the host copies the wire reason onto the DecisionContext.Reason it passes to ReviewGate.HandleDecisionAsync, and that is what a rejection’s ReviewOutcome.Rejected.Reason echoes, falling back to "No reason provided" when the context named none. The response never carries who made the decision on the wire: EvidenceCardResponse.Attestation is [JsonIgnore]d, and at 1.0.0-beta.3 no code the framework ships constructs an EvidenceCardResponse or sets that property. The hand-off that does carry a concluded decision between two calls in one host is DecisionHandOff — what IStreamingTransport.AwaitEvidenceCardResponseAsync returns and what TryDeliverResponse takes, and in shipped code ReviewGate is the one type that mints one, its constructor being internal (Transport & Wire Contract). Who a decision is held to reaches the gate as the DecisionContext a host passes to ReviewGate.HandleDecisionAsync, from which the gate resolves the principal and builds the attestation (Review Gate & Write Executors).

The card goes out under the transport layer’s own event vocabulary, as TransportEvent.EvidenceCardRequest — which the SignalR transport puts on the wire as the client method name ConfirmAction, since TransportEvent never crosses as an enum string. The decision comes back the other way, as a hub method the host defines and calls the gate from, not as a framework broadcast: the TransportEvent.EvidenceCardResponse member and the IAffiantHubClient.EvidenceCardResponse method that mirrors it name that direction, and no framework code sends either. Both are described in full in Transport & Wire Contract. What actually turns an EvidenceCardRequest into a visual card — colored provenance indicators per field, a countdown to RequiredBy, Approve/Reject controls — is host UI code; the framework’s job stops at handing over a fully-formed, fully-sworn Affidavit and returning, without waiting for a decision. See Review Gate & Write Executors for the service that sends the request and the separate call that resolves the eventual decision.

Referrals and multi-party review: blocked, not delegated

Section titled “Referrals and multi-party review: blocked, not delegated”

Not every entry the policy chain resolves is a single reviewer’s decision. ReviewRequirement.ReferralRequired is a host policy’s declaration that an operation needs escalation — implemented as an IApprovalPolicy subclassing ReferralRuleBase in Affiant.Policies.Referrals, which matches an Affidavit against some condition (say, an operation above a value threshold) and names the user ID it would escalate to — and ReviewRequirement.MultiParty is a declaration that more than one approver must agree.

Neither ReferralRequired nor MultiParty has an implementation that actually routes a card to a second reviewer or collects more than one approval; routing a MultiParty verdict through the single-reviewer branch — where one click satisfied what was meant to be a joint approval — or writing a Deferred status nothing downstream acted on was a gap disguised as a feature. As of 1.0.0-beta.3, a ReferralRequired or MultiParty verdict now files the entry Pending with a Blocked marker of RequirementNotImplemented instead, and refuses the write. An Evidence Card is still broadcast — built through EvidenceCardRequestFactory.CreateAsync(..., blocked: marker), so it carries the marker and RequiresConfirmation: false — but it states on its face that the entry is blocked rather than offering a working approve control, and every decision attempt against it is refusedReviewGate.HandleDecisionAsync answers a (ReviewOutcome? Outcome, DateTimeOffset? EntryCreatedAt) tuple whose outcome is a ReviewOutcome.Refused naming the reason, never Approved by way of a single reviewer, and no entry is written ReviewStatus.Deferred any more. ReviewOutcome.Referral itself is not removed from the outcome union — a row already sitting at ReviewStatus.Deferred still maps to it through ReviewStatusExtensions.ToReviewOutcome() — but nothing the gate does today produces a fresh Deferred row. DocketEntry.ReviewerUserId is separately deprecated (see above) in favor of Attestation, unrelated to this path.

A host that was treating a Referral outcome as an escalation hand-off should read the entry’s Blocked marker instead, and build its own escalation UI above the gate: multi-party and delegated approval are, as of this release, composed by the host, not implemented by the framework — the protocol doesn’t yet define either, and the framework declines to guess at a shape ahead of it. StandingOrderBase, the sibling policy for auto-approval, is unaffected by any of this — see Review Gate & Write Executors for the full policy pipeline, including how a Standing Order held back by its own risk ceiling degrades to ReviewerConfirmation rather than disappearing.

The Docket is what makes a WriteProposal durable rather than a fire-and-forget in-memory promise: a host process can restart between an Evidence Card being sent and a reviewer clicking Approve, and IDocketStore is what lets the review resume from where it left off. See Review Gate & Write Executors for the service that files entries, evaluates approval policy and sends Evidence Cards without waiting on them. It stops there: ReviewGate takes no IWriteExecutor dependency, and nothing the framework ships calls IWriteExecutor.ExecuteAsync on any backend — the compliance harness registers an implementation that throws the moment it is called, precisely so a conformance run fails if the gate ever reaches one. IWriteExecutor is the host’s own domain write port — the one place an approved Affidavit becomes a real mutation in the host’s own system of record, the framework’s own stores writing only their own rows — and it is the host that calls it, against the attested row, and reports back once through ReviewGate.MarkExecutedAsync as the Execution bullet above describes.