Transport & Wire Contract
Everything covered on Affidavits & Provenance, Tool Envelopes, and Docket & Evidence Cards eventually has to leave the .NET process and reach a browser. IStreamingTransport is the abstraction that carries it there: a small interface in Affiant.Abstractions.Interfaces, implemented by Affiant.Transport.SignalR as the framework’s reference adapter. This page covers that interface, the adapter, and — because the three other pages promised it — the actual JSON shape that crosses the wire: which properties are camelCase, which enum carries a PascalCase string instead of a number, where isMandatory sits in an AffidavitField, and why the order of all of it is not incidental.
IStreamingTransport: the abstraction
Section titled “IStreamingTransport: the abstraction”public interface IStreamingTransport{ Task SendAsync(string connectionId, TransportEvent eventType, object payload, CancellationToken ct); Task BroadcastToGroupAsync(string groupId, TransportEvent eventType, object payload, CancellationToken ct); Task<DecisionHandOff> AwaitEvidenceCardResponseAsync(string sessionGroupId, Guid docketId, CancellationToken ct = default); bool TryDeliverResponse(Guid docketId, DecisionHandOff handOff) => false;}Four members — the interface carries no pull-based ReceiveAsync and no generic AwaitEventAsync<T>.
SendAsynctargets one connection;BroadcastToGroupAsynctargets everyone in a named group.ReviewGate(covered in Review Gate & Write Executors) callsBroadcastToGroupAsyncwith the session ID as the group, so every connection subscribed to that session — not just the one that triggered the tool call — receives the Evidence Card. Both are at-least-once with no receipt guarantee: a completed, non-faulted task means only that the underlying transport call didn’t throw, never that a client received or rendered the payload — a group with zero currently-connected members completes successfully with zero recipients, and SignalR group membership is neither queryable nor preserved across a reconnect. The framework compensates for exactly theEvidenceCardRequestcase, in two places and neither of them the gate’s own filing path:Affiant.Docket‘sDocketExpiryServicere-broadcasts pending entries’ cards on each sweep tick — walking them from a cursor under a per-phase budget, so a large backlog is covered across ticks rather than all at once — andReviewGate.RebroadcastPendingCardsAsync(sessionId, tenantId, ct)replays a session’s pending cards on reconnect. The second has no framework caller —AffiantHub.OnConnectedAsyncdoes nothing butbase.OnConnectedAsync()— so a host wires it itself, as the Quickstart’sChatHubdoes from itsRehydrateSessionmethod; a client must treat a repeated card for the sameDocketIdas idempotent — update it in place, never append a duplicate.AwaitEvidenceCardResponseAsyncandTryDeliverResponseare the pair that back the obsolete blocking path,ReviewGate.FileReviewAsync(marked[Obsolete],AFFIANT0002, as of1.0.0-beta.3— see Review Gate & Write Executors), without pinning a thread per pending review in the meantime. As of1.0.0-beta.3both carry aDecisionHandOffin place of a rawEvidenceCardResponse.AwaitEvidenceCardResponseAsyncis called with the session group and theDocketIdand returns the hand-off once one arrives; whatever later callsTryDeliverResponsewith a matchingGuidunblocks it. A host cannot construct aDecisionHandOff— its constructor isinternal, and the assembliesAffiant.Abstractions.csprojgrantsInternalsVisibleToare three of the framework’s own (Affiant.Core,Affiant.Docket,Affiant.EntityFramework) plus six test assemblies; in shipped codeReviewGateis the one type that mints one — so a delivery into the transport can no longer approve anything by itself; it can only report a result the gate already decided, through its own decision core (principal resolved, tenant-scoped row,IDecisionAuthorizationPolicyasked, attestation written — see Review Gate & Write Executors).TryDeliverResponsehas a default interface implementation that returnsfalse— a transport that doesn’t maintain an in-process waiter registry doesn’t have to implement it at all.ReviewGate.HandleDecisionAsyncresolves a decision against the Docket directly and unconditionally; it callsTryDeliverResponseafterward purely to notify a live waiter if one happens to exist, and discards the returned bool without branching on it — a host hub doesn’t touch either method directly for its own Approve/Reject handling: it hands a reviewer’s decision toReviewGate.HandleDecisionAsyncwith aDecisionContext, and the gate is what talks to the transport.
TransportEvent: the closed event vocabulary
Section titled “TransportEvent: the closed event vocabulary”public enum TransportEvent{ EvidenceCardRequest = 0, // Framework sends a review request (Evidence Card) to the UI. EvidenceCardResponse = 1, // UI sends a review response (approval/rejection) back to the framework. AgentMessage = 2, // Chat message from the agent. ContextUpdate = 3, // Framework notifies UI of context changes. SystemNotification = 4, // Framework sends a transient notification (error, warning, success). DocketExpiring = 5, // A Pending entry is approaching its TTL. May repeat; idempotent. DocketExpired = 6, // A Pending entry transitioned to Expired with no decision. UiGuidance = 7 // Rule 6's UI-guidance walkthrough.}Inbound chat text is not one of these members — it arrives as an ordinary SignalR hub-method parameter (a host-defined call — SendMessage(string message, string sessionId) in the Quickstart’s ChatHub), never as something the framework broadcasts.
Every SendAsync or BroadcastToGroupAsync call names one of these eight values — there is no stringly-typed event name anywhere in the framework’s own code. EvidenceCardRequest is what ReviewGate broadcasts, carrying an EvidenceCardRequest payload (the record, confusingly sharing its name with the enum member — see Docket & Evidence Cards) built by EvidenceCardRequestFactory.CreateAsync — the one builder ReviewGate’s filing path, its reconnect rebroadcast and Affiant.Docket’s expiry sweep all go through, so the payload for a given entry cannot drift between the three. It carries more than the entry’s DocketId, Affidavit and RequiredBy deadline: a PriorAmendments the factory re-derives from the resubmission parent, the Blocked marker and the host’s own verb its caller passes in, and the PopulatedConfidence, EmptyFieldCount, RequiresConfirmation, Warnings and Presentation values EvidenceCardRequest.For lifts off the record itself so a card can never report a number about a different set of values than the ones it shows — the full property list is in the worked example below. EvidenceCardResponse is the return trip — in the framework’s own reference wiring (see the hub example in Quickstart), this leg is typically implemented as a reviewer invoking a distinct hub method — the Quickstart’s ChatHub declares ApproveEntry(Guid entryId, Dictionary<string, object?>? amendments) and RejectEntry(Guid entryId) — that calls ReviewGate.HandleDecisionAsync directly, with a DecisionContext naming who is deciding, rather than the UI constructing and sending an EvidenceCardResponse payload itself. AgentMessage carries chat turns, broadcast by a host rather than by the framework — the Quickstart’s ChatHub is what sends one; SystemNotification is the general error/warning signal, and the framework does send it itself (ReviewGateFilter and ReviewGate both broadcast one); ContextUpdate is reserved and host-driven — nothing in the framework or in the reference host broadcasts it, and it carries no dedicated payload record anywhere in the framework, so it is there for a host that wants to push its own context change to a UI rather than something the Context Fabric emits; DocketExpiring/DocketExpired carry the expiry lifecycle (see Quickstart for what a client does with each); UiGuidance is Rule 6’s walkthrough, mapped onto the wire method name "GuideUI" a reference host’s client already listened for.
Affiant.Transport.SignalR: the reference adapter
Section titled “Affiant.Transport.SignalR: the reference adapter”Six public types make up the adapter. Three do the wiring: AffiantHub, an abstract hub base class a host subclasses; SignalRStreamingTransport<THub>, the singleton IStreamingTransport implementation; and ServiceCollectionExtensions, the pair of IServiceCollection/WebApplication extension methods that wire the two together. The other three are SignalROptions, the object both of those extension methods configure; TransportEventExtensions, the client-method-name mapping (below), itself part of the wire contract; and IAffiantHubClient, the typed client contract AffiantHub is generic over (Hub<IAffiantHubClient>), whose method names are pinned to that mapping’s output.
Registration
Section titled “Registration”services.AddAffiantSignalR<ChatHub>(options =>{ options.MaximumMessageSize = 32768; // default, in bytes options.EnableDetailedErrors = false; // default});AddAffiantSignalR<THub> calls AddSignalR() under the hood — reading MaximumMessageSize and EnableDetailedErrors from the options you configure, and applying AffiantJson.Configure to the hub’s payload serializer — then registers SignalRStreamingTransport<THub> as a singleton and exposes it as IStreamingTransport. It does not map the hub — that’s a separate call, made after routing middleware is configured, and it is where the endpoint is named:
app.MapAffiantSignalR<ChatHub>(options =>{ options.HubEndpoint = "/hubs/affiant"; // default});HubEndpoint belongs in this callback, not the other one. MapAffiantSignalR constructs a fresh SignalROptions, invokes its own callback against it, and maps the hub at whatever HubEndpoint that instance carries. Nothing hands it the options AddAffiantSignalR configured, so an endpoint set there is silently ignored and the hub maps at the default path.
THub must derive from AffiantHub, the abstract base in Affiant.Transport.SignalR.Hubs. A host’s concrete hub (ChatHub in the site’s own Quickstart walkthrough) supplies the domain-specific methods a client actually invokes — ApproveEntry, RejectEntry, whatever a SendMessage equivalent looks like for that host — while inheriting session-group management, reviewer-group management, and session rehydration from AffiantHub itself: AddToSessionGroupAsync, AddToReviewerGroupAsync, and RehydrateSessionAsync (which adds the connection to its session group and loads persisted messages via IChatSessionStore) are all protected helpers a subclass calls rather than reimplements.
SignalRStreamingTransport<THub>: the rendezvous registry
Section titled “SignalRStreamingTransport<THub>: the rendezvous registry”The listing below is the type’s declaration only, not code to paste — the body is omitted: the private ConcurrentDictionary waiter registry and the four IStreamingTransport members it implements, SendAsync, BroadcastToGroupAsync, AwaitEvidenceCardResponseAsync and TryDeliverResponse, each described in the paragraph below.
public sealed class SignalRStreamingTransport<THub>(IHubContext<THub> hubContext) : IStreamingTransport where THub : AffiantHubThis is the singleton AddAffiantSignalR registers. SendAsync and BroadcastToGroupAsync are thin wrappers over IHubContext<THub>.Clients.Client(...)/.Group(...).SendAsync(methodName, payload, ct) — the SignalR primitive that actually pushes a hub method invocation to connected clients. AwaitEvidenceCardResponseAsync and TryDeliverResponse are backed by a ConcurrentDictionary<Guid, TaskCompletionSource<DecisionHandOff>> keyed on DocketId: awaiting one registers a TaskCompletionSource, and delivering a decision resolves it (or, if two callers raced awaiting the same DocketId, the second reuses the first’s already-registered TaskCompletionSource rather than creating a competing one). This dictionary is in-process memory — it does not survive a host restart. That is why ReviewGate.HandleDecisionAsync never depends on it: the store read and the guarded transition always run first and unconditionally, and TryDeliverResponse is called only afterward, as a best-effort notification to whatever might still be waiting in this same process.
Method names: ToClientEventName()
Section titled “Method names: ToClientEventName()”A TransportEvent value has to become the string SignalR actually invokes as a client-side method name. A public extension method in TransportEventExtensions (Affiant.Transport.SignalR.Transport) does the mapping, exhaustively — one arm per named member, no default/discard fallthrough. The listing below is that class’s shipped source — it shares a file with SignalRStreamingTransport<THub> — with three things elided: the file’s namespace declaration, the four using directives the rest of that file needs, and the class’s own XML doc comment, whose rationale the paragraph below restates. The #pragma comment is shortened; the one using the enum needs is kept, so the block compiles as printed:
using Affiant.Abstractions.Transport;
public static class TransportEventExtensions{#pragma warning disable CS8524 // exhaustive over every NAMED member; CS8509 stays live. public static string ToClientEventName(this TransportEvent evt) => evt switch { TransportEvent.EvidenceCardRequest => "ConfirmAction", TransportEvent.EvidenceCardResponse => "EvidenceCardResponse", TransportEvent.AgentMessage => "ReceiveToken", TransportEvent.ContextUpdate => "ContextUpdated", TransportEvent.SystemNotification => "SystemNotification", TransportEvent.DocketExpiring => "DocketExpiring", TransportEvent.DocketExpired => "DocketExpired", TransportEvent.UiGuidance => "GuideUI", };#pragma warning restore CS8524}Four of the eight members get a purpose-named client method ("ConfirmAction", "ReceiveToken", "ContextUpdated", "GuideUI" for UiGuidance); the rest map to their own enum member name as a string. The method is public and exhaustive — one arm per named member, no default/discard fallthrough — so adding a TransportEvent member with no matching arm here is a compile error, not a silent fallthrough. The #pragma pair suppresses only CS8524, the separate diagnostic every enum switch expression without a discard arm raises — an enum admits any cast integral value, (TransportEvent)99 included, so no finite set of named arms can ever cover them; CS8509, a genuinely missing named member, stays live, and the repository’s TreatWarningsAsErrors turns it into a build failure. A frontend client subscribes to these exact strings — connection.on("ConfirmAction", handler) in JavaScript, or the equivalent HubConnection.On<T>("ConfirmAction", ...) in a .NET client — so this mapping is itself part of the wire contract, not an implementation detail. The framework’s own test suite pins it down directly: TransportEventExtensionsExhaustivenessTests (tests/Affiant.Transport.SignalR.Tests/Transport/) asserts, over Enum.GetValues<TransportEvent>(), that ToClientEventName() returns the exact expected string for every member — a rename the compiler wouldn’t catch on its own, since CS8509 only fires on a missing arm, not a changed string. Round-trips over a real SignalR connection live in two other suites: SignalRTransportContractTests pushes five of the names end-to-end (ConfirmAction, EvidenceCardResponse, ReceiveToken, ContextUpdated, SystemNotification), and UiGuidanceBridgeWireTests does the same for "GuideUI". AffiantHubTypedClientTests adds one more round-trip, for "ReceiveToken" through a typed Clients.Caller call, plus a reflection check that IAffiantHubClient’s method-name set is exactly the set ToClientEventName() produces — so the typed client interface and the wire names cannot drift apart. No suite round-trips DocketExpiring or DocketExpired; those two are pinned by name only.
The wire contract: what actually crosses as JSON
Section titled “The wire contract: what actually crosses as JSON”Everything above establishes how a payload travels. What follows is the shape of the payload itself once System.Text.Json has serialized it — the part a frontend author actually has to code against.
camelCase property names, and every enum as a string — AffiantJson
Section titled “camelCase property names, and every enum as a string — AffiantJson”Every one of the framework’s wire conventions is declared once, in Affiant.Abstractions.Serialization.AffiantJson, and every call site that writes a payload for the wire or for a canonical form applies it rather than configuring its own. Four sites write one — the SignalR hub protocol, ToolEnvelopeExtensions, EntryIdDerivation and CanonicalSerializer; ContextExtractor and ToolTracingFilter hold the same options object too, but only to read a ToolEnvelope back, never to write one. AddAffiantSignalR<THub> calls .AddJsonProtocol(o => AffiantJson.Configure(o.PayloadSerializerOptions)) on the hub explicitly (this is not an ambient ASP.NET Core default the package merely inherits), and ToolEnvelopeExtensions.ToJsonString() (covered in Tool Envelopes) is JsonSerializer.Serialize(envelope, AffiantJson.SerializerOptions) — the same shared, frozen options object, not one it builds itself. Affiant.EntityFramework’s stores are one exception, on the persistence side rather than the wire: EfDocketOperations and DocketRowSerialization each build their own new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase } for the JSON columns they write, so those rows carry camelCase keys and the enums that carry their own type-level converter attribute (below) as strings, but neither AffiantJson’s instant spelling nor its ReviewStatus converter reaches them. One reader inside Affiant.Core does the same thing on the way in: ReviewGateFilter deserializes a tool’s ToolEnvelope through camelCase options of its own rather than through AffiantJson — so “reads a ToolEnvelope” and “reads it through AffiantJson” are not the same statement. AffiantJson.Configure sets:
- camelCase property names (
PropertyNamingPolicy = JsonNamingPolicy.CamelCase) — every payload handed toSendAsyncorBroadcastToGroupAsynccrosses with camelCase keys because this call configured it, not because SignalR happened to default that way. - every enum as a string — a plain
System.Text.Jsonserializes enums as their underlying numbers by default;AffiantJson.Configureregisters a generalJsonStringEnumConverter()last, so any enum without a more specific converter still crosses as a string.ApprovalDecisiononEvidenceCardResponseis one: it crosses as"Approved"/"Rejected", via this general converter, not a type-level attribute of its own. DefaultIgnoreCondition = JsonIgnoreCondition.Never— a required-and-nullable property is writtennullrather than omitted, so a reader never has to tell “unbound” from “the property was left off”. A property the schemas mark genuinely optional is the exception, carrying its own[JsonIgnore(WhenWritingNull)](EvidenceCardRequest.Presentation/Warnings/HostOperation, for instance).- one spelling for every instant —
ConfigureregistersIsoInstantJsonConverteras the first of its three converters: aDateTimeOffsetis written UTC with milliseconds and a trailingZ(2026-08-01T00:05:00.000Z), and any RFC 3339 form still reads back in, so a record an earlier build wrote still loads (below).
ProvenanceSource — the seven-value enum every ProvenanceTag carries — also carries its own type-level attribute on top of all this:
[JsonConverter(typeof(JsonStringEnumConverter))]public enum ProvenanceSource{ UserStated, External, Computed, Conversation, Inferred, Default, Empty}That attribute is what makes ProvenanceSource cross as a string even outside AffiantJson’s pipeline — a Postgres store’s own column converter, or any other System.Text.Json caller that never touches AffiantJson.Configure — because the attribute lives on the type itself rather than on any one options instance.
The enums do not all spell themselves the same way, which is why no single blanket converter would do. Under AffiantJson.SerializerOptions, serializing each one gives:
| Enum | On the wire | Where the converter lives |
|---|---|---|
ProvenanceSource |
"UserStated" (PascalCase) |
type attribute |
ReviewRequirement, ApprovalDecision |
PascalCase | the general converter in AffiantJson.Configure |
ReviewStatus |
"pending" |
a named converter in AffiantJson.Configure, registered before the general one so it wins for its type |
ExecutionOutcome |
"unexecuted" |
type attribute + per-member names |
DecisionOutcome |
"resubmitted" |
type attribute + per-member names |
CoverageCategory |
"provider-executed" (kebab-case) |
type attribute + per-member names |
The four with a type attribute keep their spelling outside AffiantJson too. ReviewStatus does not: its converter is registered on the options, not on the type, so a caller that serializes a bare ReviewStatus with default options gets the integer 0 rather than "pending". TransportEvent is absent from the table because it never crosses the wire as a serialized enum at all: SignalRStreamingTransport maps every member to a SignalR client method name through ToClientEventName() before it sends, and four of the eight names are deliberately not the member’s own (above).
The isMandatory field
Section titled “The isMandatory field”AffidavitField.IsMandatory (covered in Affidavits & Provenance) is a plain bool, camelCased like every other property: isMandatory. It carries no enum ambiguity, but it’s worth naming here because it’s one of the fields an Evidence Card renders as a visual flag alongside the provenance badge — a true value paired with ProvenanceSource.Empty is the specific combination worth calling out, and it is the one the framework itself acts on: StandingOrderGuardrails.Apply degrades a Standing Order verdict to ReviewerConfirmation with the blocked reason mandatory-field-empty when any mandatory field’s tag in force is Empty, so a card carrying that pair is one no Standing Order could have auto-approved. It does not stop a human reviewer approving it.
note, not evidence — and three other renamed or reshaped values, as of 1.0.0-beta.3
Section titled “note, not evidence — and three other renamed or reshaped values, as of 1.0.0-beta.3”The Affiant protocol fixes one spelling for every value that crosses the wire, and four of the framework’s own spellings didn’t match it before this release:
- A
ProvenanceTag’sEvidenceproperty is spellednoteon the wire. The C# property name is unchanged — only the JSON key moves. A client readingtag.evidencereads nothing after upgrading; readtag.note. The key is lowercasenote([property: JsonPropertyName("note")] string? Evidence). A payload stored before this release deserializes withEvidencenull. - An instant is written UTC with milliseconds and a trailing
Z—2026-08-01T00:05:00.000Z— where .NET’s round-trip default previously wrote2026-08-01T00:05:00+00:00. Same instant, one spelling: a canonical form (below) is a byte sequence, and two spellings of the same instant would hash to two different digests. Nothing that parses the string needs to change —new Date(s)in JavaScript handles both forms — but a test or client asserting the exact former string does. Sub-millisecond precision doesn’t travel. - A
ReviewStatuscrosses lowercase —"pending", not"Pending"— matching the spelling the protocol’s schemas freeze.ProvenanceSourceandReviewRequirementstay PascalCase, for the same underlying reason: each schema freezes its own casing, and no implementation case-folds one on the wire on its own initiative. - A tool result’s discriminator is
kind, not$type— see Tool Envelopes for the full migration note.
Money is two strings, never a JSON number
Section titled “Money is two strings, never a JSON number”A field whose value is money is written as { "amount": "4000.10", "currency": "GBP" } — a decimal string and an ISO 4217 currency string — and a JSON number is refused where money is expected, naming the rule and why: no binary floating-point value represents 0.10 exactly, so a card showing “£4,000.10” and a store holding 4000.099999999999 would disagree about what a reviewer actually approved, with nothing on the record to say which one they saw. No currency list travels with the type — ISO 4217 changes over time, and a table frozen into a serialization type would eventually be wrong — so the wire shape is checked here and membership in a real currency list is left to the host.
Stable field ordering
Section titled “Stable field ordering”Two different kinds of ordering are stable here, for two different reasons:
- JSON key order.
System.Text.Jsonserializes a type’s properties in the order they’re declared, not alphabetically, and for most wire-facing types inAffiant.Abstractionsthat means the primary constructor’s own parameter order — for anAffidavit, as of1.0.0-beta.3,operationType, entityType, entityId, fields, aggregateConfidence, populatedConfidence, emptyFieldCount, warnings, requiresConfirmation, conversationTurn, createdAt, protocolVersion, deterministic across every serialization and useful for anything that diffs or snapshot-tests raw wire payloads. This holds only where a type’s constructor parameters aren’t re-declared as explicit properties in the record body — see the worked example below forProvenanceTagandEvidenceCardRequest, where that re-declaration moves a key out of constructor order. ProvenanceChain.Priorordering. This one is a data invariant, not a serializer default:Prioris documented and implemented to hold tags newest-first (see Affidavits & Provenance). A UI that renders a field’s provenance history readsCurrentas the authoritative tag andPrior[0]as whatever it most recently superseded. If that ordering were ever reversed without every consumer being updated in lockstep, a history view would render backwards — not crash, just quietly show the trail in the wrong direction.
A worked example — an actual JsonSerializer.Serialize(card, AffiantJson.SerializerOptions) run against the published 1.0.0-beta.3 packages, for an EvidenceCardRequest.For(...) built over a one-field LeaveRequest proposal. AffiantJson.SerializerOptions is frozen with WriteIndented = false, so a real run emits one line; the block below is re-indented for reading, and every key, its position and its value are the run’s own:
{ "docketId": "550e8400-e29b-41d4-a716-446655440000", "affidavit": { "operationType": "WriteCreate", "entityType": "LeaveRequest", "entityId": null, "fields": [ { "name": "StartDate", "value": "2026-08-03", "previousValue": null, "provenance": { "current": { "source": "UserStated", "note": "User stated: StartDate", "conversationTurn": null, "binding": { "kind": "form-input", "ref": { "field": "startDate" } }, "at": null, "confidence": 1 }, "prior": [] }, "isMandatory": true, "kind": "text", "allowedValues": null, "pattern": null } ], "aggregateConfidence": 1, "populatedConfidence": 1, "emptyFieldCount": 0, "warnings": [], "requiresConfirmation": true, "conversationTurn": null, "createdAt": null, "protocolVersion": "0.1.0" }, "requiredBy": "2026-08-20T15:32:03.104Z", "priorAmendments": null, "populatedConfidence": 1, "emptyFieldCount": 0, "requiresConfirmation": true, "blocked": null, "protocolVersion": "0.1.0"}Two things worth flagging precisely, both confirmed against the actual output rather than assumed from the constructor’s parameter list:
confidenceserializes last on aProvenanceTag, not second.ProvenanceTag’s primary constructor declares it second (Source, Confidence, Evidence, ConversationTurn, Binding, At), but the record body re-declaresConfidenceas an explicit property (to clamp it and special-caseEmpty), and that explicit re-declaration is whatSystem.Text.Json’s reflection-based serializer orders by — not the constructor parameter list.EvidenceCardRequesthas the same pattern:Presentation,Warnings, andHostOperationare constructor parameters 9–11, but each is re-declared in the record body (to attach[JsonIgnore(WhenWritingNull)]), so all three serialize afterProtocolVersion, an init-only property declared outside the constructor entirely — see the worked example above. Wherever a type re-declares a constructor parameter as an explicit property, verify the key order against a real serialization rather than reading it off the positional constructor.protocolVersionhere is"0.1.0"— the Affiant protocol’s own version string (AffiantProtocol.Version), not the1.0.0-beta.3NuGet package version. The two are versioned independently: a package version says which release of the .NET implementation this is; the protocol version says which edition of the cross-implementation rulebook the record’s shapes conform to.
Affidavit.PopulatedConfidence/EmptyFieldCount/ConversationTurn/CreatedAt/ProtocolVersion, a tag’s binding and at (the beta.1 ProvenanceTag record carried neither), and EvidenceCardRequest.PopulatedConfidence/EmptyFieldCount/RequiresConfirmation/Blocked/Presentation/Warnings/HostOperation/ProtocolVersion are 1.0.0-beta.3 additions — see Affidavits & Provenance for what the Affidavit-level ones carry. EvidenceCardRequest.Presentation/Warnings/HostOperation are the three properties genuinely omitted rather than written null ([JsonIgnore(WhenWritingNull)]) — absent here because this card has none of them; see Docket & Evidence Cards for what each one is. Every other property that could theoretically be absent is written null instead, per AffiantJson.Configure’s DefaultIgnoreCondition = Never (above) — including Affidavit.EntityId, every field’s PreviousValue, and EvidenceCardRequest.Blocked, all shown above.
The canonical form and CanonicalHash, as of 1.0.0-beta.3
Section titled “The canonical form and CanonicalHash, as of 1.0.0-beta.3”A record two parties both swear to has to mean the same thing on both sides of a network, and years later — which needs more than “the JSON keys are in a stable order” (above). Affiant.Core.Serialization.CanonicalSerializer is what the framework hashes: Canonicalize returns the canonical UTF-8 bytes of an Affidavit (folding in any accepted amendments), CanonicalString the same document one encoding step earlier, and CanonicalHash the SHA-256 over it as 64 lowercase hexadecimal characters. Inside that form specifically — not the general wire contract above — object keys sort by Unicode code point at every level, there’s no insignificant whitespace, a number is written as the shortest decimal that round-trips (1e21 in full, never 1e+21, and -0 written 0), a non-finite number is refused outright, null is written and an absent property is omitted, and money is always its two strings.
The form is taken over the accepted state, not the proposal alone — the amended record where a reviewer corrected one, the original proposal otherwise. Hashing only the original proposal would let an execution grant minted for the record a reviewer was shown keep validating against the record they actually amended, which is exactly the gap a canonical hash exists to close. The Docket entry id itself is derived from a related but distinct canonical form — the tenant, the conversation, the tool name, the canonical form of the proposed operation and its arguments, and, on a resubmission only, the superseded entry’s id (the key is absent rather than null on a first filing, so that first id is what it always was). The arguments in that material are the ones the model passed, attached by the review gate’s filter at the seam that knows them: on Semantic Kernel that filter runs at the completion stage, where the bridge deliberately supplies none, so args is null there and carries the call’s arguments on the Microsoft Agent Framework and Microsoft.Extensions.AI seams. The derivation is described in Docket & Evidence Cards.
CanonicalSerializer is new in 1.0.0-beta.3 — no earlier release shipped a canonicaliser or minted a CanonicalHash. Its form is taken over the Affiant protocol’s own Affidavit schema, as the rulebook’s v0.1.2 tag defines it, protocol version included — a host building its own canonicalisation or hash-comparison logic should build it against that form, consistent with what Versioning & compatibility says a prerelease tag promises generally.
Why wire stability is a compliance surface
Section titled “Why wire stability is a compliance surface”An Evidence Card’s whole job is letting a reviewer trust a badge instead of re-deriving provenance from scratch. The framework broadcasts the record rather than a rendering, and the host’s reviewer surface decides how to show it — the quickstart sample’s <affiant-evidence-card> element, for instance, shows each field’s source as a badge beside a confidence meter, mutes the Inferred and Default badges, and gives a field whose tag reads Empty the warning colour, as described in Affidavits & Provenance. That element does not show a tag’s explanation line at 1.0.0-beta.3: it reads tag.evidence, and the wire spells that property note (above), so the line it appends comes out empty until the client reads tag.note. That trust is only as good as the wire contract underneath it. If field.provenance.current.source silently stopped being a JsonStringEnumConverter string and started being a bare integer — a dropped attribute, an accidental change to a shared JsonSerializerOptions — a badge-rendering UI keyed on string values like "UserStated" wouldn’t necessarily throw. Depending on how defensively it was written, it might fall through to a default badge, render nothing, or worse, render the wrong badge for whatever number happened to land in that slot. Same story if Prior’s newest-first ordering flipped, or if a property were renamed without every consumer updating in lockstep: none of these are the kind of failure a green test suite reliably catches, because a test that only checks “did we get an object back” is checking shape, not meaning — the same failure mode the framework’s own compliance tooling exists to rule out for inference logic (see The Compliance Harness). A silent wire drift is that same category of risk, just at the transport boundary instead of the inference boundary, which is exactly why the framework pins the client-method-name mapping down with an executable contract test rather than leaving it to documentation alone.
Where this fits
Section titled “Where this fits”IStreamingTransport is a Layer 0 contract — it ships in Affiant.Abstractions, and Affiant.Transport.SignalR implements it at Layer 2, in the package dependency graph’s numbering: ReviewGate depends on it to send Evidence Cards, without waiting on them, and to back the one obsolete path that does wait (see Review Gate & Write Executors), and a host depends on Affiant.Transport.SignalR — or a transport it writes itself against the same interface — to get those payloads into a browser. What travels over it is the Affidavit described in Affidavits & Provenance and the EvidenceCardRequest/EvidenceCardResponse pair described in Docket & Evidence Cards; this page is the reference for the JSON shape both of those become once they leave the process. See The Honest Boundary for what this transport layer does not reach — locally-invoked tool calls are Affiant’s interception surface; a hosted or server-side tool execution path never touches IStreamingTransport at all.