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 both 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); IAsyncEnumerable<TransportMessage> ReceiveAsync(string connectionId, CancellationToken ct); Task<T> AwaitEventAsync<T>(string sessionGroupId, Guid docketId, CancellationToken ct = default); bool TryDeliverResponse(Guid docketId, EvidenceCardResponse response) => false;}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.ReceiveAsyncis a pull-based inbound stream. It exists on the interface because not every transport is push-only, butAffiant.Transport.SignalR’s implementation throwsNotSupportedExceptionfor it — SignalR’s own hub-method-invocation model is push-based in the other direction, so a SignalR host doesn’t pull messages, it receives them as hub method calls (more on that below). This is a real, documented gap in the reference adapter, not an oversight: a transport built on a genuinely pull-based protocol would implement it; SignalR’s doesn’t need to.AwaitEventAsync<T>andTryDeliverResponseare the pair that makeReviewGate.FileReviewAsyncable to block on a human decision without pinning a thread per pending review.AwaitEventAsync<EvidenceCardResponse>is called with the session group and theDocketId; whatever later callsTryDeliverResponsewith a matchingGuidunblocks it.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, andReviewGatetreats afalseresult as “no live waiter, fall back to reading the Docket instead” rather than an error.AwaitEventAsync<T>itself is narrower than its generic signature suggests:Affiant.Transport.SignalR’s implementation only supportsT = EvidenceCardResponseand throwsNotSupportedExceptionfor anything else.
public record TransportMessage( string MessageId, string SessionId, TransportEvent EventType, string EventPayload, DateTimeOffset Timestamp);TransportMessage is the shape ReceiveAsync would yield — EventPayload is a pre-serialized JSON string rather than object, so a pull-based transport doesn’t need to know the payload’s CLR type to hand it back. Since the reference SignalR adapter never actually produces one, you’ll only encounter this record if you write a transport for a different protocol.
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. UserMessage = 3, // Chat message from the user. ContextUpdate = 4, // Framework notifies UI of context changes. SystemNotification = 5 // Framework sends a transient notification (error, warning, success).}Every SendAsync or BroadcastToGroupAsync call names one of these six 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 from the entry’s DocketId, Affidavit, and RequiredBy deadline. 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 (ApproveEntry(Guid entryId) / RejectEntry(Guid entryId)) that calls ReviewGate.HandleDecisionAsync directly, rather than the UI constructing and sending an EvidenceCardResponse payload itself. AgentMessage and UserMessage carry chat turns; ContextUpdate and SystemNotification are auxiliary — the Context Fabric and general error/warning signaling, respectively.
Affiant.Transport.SignalR: the reference adapter
Section titled “Affiant.Transport.SignalR: the reference adapter”Three pieces make up the adapter: a hub base class a host subclasses, a singleton IStreamingTransport implementation, and a pair of IServiceCollection/WebApplication extension methods that wire the two together.
Registration
Section titled “Registration”services.AddAffiantSignalR<ChatHub>(options =>{ options.HubEndpoint = "/hubs/affiant"; // default options.MaximumMessageSize = 32768; // default, in bytes options.EnableDetailedErrors = false; // default});AddAffiantSignalR<THub> calls AddSignalR() under the hood, 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:
app.MapAffiantSignalR<ChatHub>();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”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. AwaitEventAsync<T> and TryDeliverResponse are backed by a ConcurrentDictionary<Guid, TaskCompletionSource<EvidenceCardResponse>> keyed on DocketId: filing a review registers a TaskCompletionSource, and delivering a decision resolves it (or, if two FileReviewAsync calls raced for 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, which is exactly why ReviewGate.HandleDecisionAsync falls back to reading the entry back out of IDocketStore when TryDeliverResponse reports no live waiter.
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. An internal extension method does the mapping:
internal static string ToClientEventName(this TransportEvent evt) => evt switch{ TransportEvent.EvidenceCardRequest => "ConfirmAction", TransportEvent.AgentMessage => "ReceiveToken", TransportEvent.ContextUpdate => "ContextUpdated", TransportEvent.SystemNotification => "SystemNotification", _ => evt.ToString()};Three of the six members get a purpose-named client method ("ConfirmAction", "ReceiveToken", "ContextUpdated"); the other three — EvidenceCardResponse, UserMessage, and the unmapped SystemNotification case above — fall through to evt.ToString(), which is the enum member’s own name: "EvidenceCardResponse", "UserMessage". 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: SignalRTransportContractTests in Affiant.Transport.SignalR.Tests asserts, for every TransportEvent member, that sending it produces a client-visible invocation of the exact method name this switch expression defines. Changing a case here, or renaming a TransportEvent member without updating a matching case, is something that test suite is built to catch before it reaches a released package.
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
Section titled “camelCase property names”ASP.NET Core SignalR’s JSON Hub Protocol serializes hub method arguments in camelCase by default — this is a SignalR-level default, not something Affiant.Transport.SignalR configures itself. Every payload object handed to SendAsync or BroadcastToGroupAsync — an EvidenceCardRequest, a chat token object, a context-update payload — crosses the wire with camelCase keys without a host writing any serializer configuration. The same convention is applied deliberately everywhere else in the framework that touches JSON directly: ToolEnvelopeExtensions.ToJsonString() (covered in Tool Envelopes) constructs its own JsonSerializerOptions with PropertyNamingPolicy = JsonNamingPolicy.CamelCase, and the same explicit setting appears in the SQLite and PostgreSQL session/docket stores and in the SK filter pipeline’s own envelope deserialization. camelCase isn’t an accident of one call site defaulting a certain way — it’s the one property-casing convention repeated at every serialization boundary in the framework.
PascalCase enum values — the JsonStringEnumConverter on ProvenanceSource
Section titled “PascalCase enum values — the JsonStringEnumConverter on ProvenanceSource”Plain System.Text.Json serializes enums as their underlying numbers by default, and SignalR’s camelCase default doesn’t change that. ProvenanceSource — the seven-value enum every ProvenanceTag carries — is the exception, and it’s explicit:
[JsonConverter(typeof(JsonStringEnumConverter))]public enum ProvenanceSource{ UserStated, External, Computed, Conversation, Inferred, Default, Empty}Because the attribute lives on the type itself rather than on any one JsonSerializerOptions instance, it holds no matter which of the framework’s several serialization call sites is doing the work — the SignalR hub protocol serializing an EvidenceCardRequest, a Postgres store persisting a DocketEntry’s Affidavit column, the SK filter pipeline reading a WriteProposal back out of a tool result. Wherever a ProvenanceSource value travels, it travels as "UserStated", "Inferred", "Empty" — never 0, 4, 6. Other enums in the framework — ApprovalDecision on EvidenceCardResponse, for instance — don’t carry this attribute; the wire-string guarantee is specific to ProvenanceSource, which is not incidental, since ProvenanceSource is the one enum a reviewer-facing UI actually keys badge rendering on (see below).
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 a reviewer UI should call out, per the framework’s own convention.
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. Because every wire-facing type inAffiant.Abstractionsis a record with a fixed primary-constructor parameter order, the JSON key order for, say, anAffidavitis alwaysoperationType, entityType, entityId, fields, aggregateConfidence, warnings, requiresConfirmation— deterministic across every serialization, useful for anything that diffs or snapshot-tests raw wire payloads (including the framework’s own contract tests). 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 — the JSON an EvidenceCardRequest actually carries for one field of a LeaveRequest proposal, keys in declaration order, ProvenanceSource as a string:
{ "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", "confidence": 1.0, "evidence": "User stated: StartDate", "conversationTurn": null }, "prior": [] }, "isMandatory": true } ], "aggregateConfidence": 1.0, "warnings": [], "requiresConfirmation": true }, "requiredBy": "2026-07-04T15:32:03.104Z"}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 — green for UserStated, amber for Inferred, grey for Default, per the convention described in Affidavits & Provenance. 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 Layer 1 of the framework: ReviewGate depends on it to send and await Evidence Cards (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.