Skip to content

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.

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;
}
  • SendAsync targets one connection; BroadcastToGroupAsync targets everyone in a named group. ReviewGate (covered in Review Gate & Write Executors) calls BroadcastToGroupAsync with 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.
  • ReceiveAsync is a pull-based inbound stream. It exists on the interface because not every transport is push-only, but Affiant.Transport.SignalR’s implementation throws NotSupportedException for 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> and TryDeliverResponse are the pair that make ReviewGate.FileReviewAsync able to block on a human decision without pinning a thread per pending review. AwaitEventAsync<EvidenceCardResponse> is called with the session group and the DocketId; whatever later calls TryDeliverResponse with a matching Guid unblocks it. TryDeliverResponse has a default interface implementation that returns false — a transport that doesn’t maintain an in-process waiter registry doesn’t have to implement it at all, and ReviewGate treats a false result 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 supports T = EvidenceCardResponse and throws NotSupportedException for 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.

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 : AffiantHub

This 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.

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.

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).

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.

Two different kinds of ordering are stable here, for two different reasons:

  1. JSON key order. System.Text.Json serializes a type’s properties in the order they’re declared, not alphabetically. Because every wire-facing type in Affiant.Abstractions is a record with a fixed primary-constructor parameter order, the JSON key order for, say, an Affidavit is always operationType, 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).
  2. ProvenanceChain.Prior ordering. This one is a data invariant, not a serializer default: Prior is documented and implemented to hold tags newest-first (see Affidavits & Provenance). A UI that renders a field’s provenance history reads Current as the authoritative tag and Prior[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.

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.