# ADR 0001: AcBinaryHubProtocol optional feature stack — decorator-based composition design ## Status Proposed (2026-04-25) ## Context `AcBinaryHubProtocol` (`AyCode.Services/SignalRs/AcBinaryHubProtocol.cs`) is the AyCode binary `IHubProtocol` implementation for SignalR — the canonical wire format for binary hub messaging. The base implementation handles AcBinary serialization, three protocol modes (`Bytes` / `Segment` / `AsyncSegment`), chunked AsyncSegment streaming, and the base layer of features every consumer needs. A class of optional features is planned for NuGet competitiveness (currently P3 idea status, see `../SIGNALR_BINARY_PROTOCOL/SIGNALR_BINARY_PROTOCOL_TODO.md` ACCORE-SBP-T-H7M5..8): **encryption**, **compression with MinSize**, **OpenTelemetry tracing**, **HMAC signing-only**. These features: - Are NOT current-priority — speculative additions for public NuGet positioning. - Must be **opt-in** (default-off, zero overhead when not used). - Must be **stackable** in some combinations (encrypt + sign + compress simultaneously). - Must work with **all three** `BinaryProtocolMode` send strategies (Bytes / Segment / AsyncSegment). - Each carries its own design + threat model decisions (own ADR per leaf feature). This ADR establishes the **composition mechanism** — how these features attach to and stack with `AcBinaryHubProtocol`. Each leaf feature gets a follow-up ADR (0002 encryption, 0003 compression, 0004 tracing, 0005 signing) inheriting this composition decision. Without an explicit umbrella, every leaf ADR would re-derive the same composition pattern → 4× redundancy and risk of drift. ## Decision Adopt the **decorator-chain pattern** for composing optional features onto `AcBinaryHubProtocol`. Each feature is a self-contained `IHubProtocol` implementation that wraps an inner protocol; stacking order is encoded at DI registration time via composition. ### `AcHubProtocolDecoratorBase` abstract base A new abstract class in `AyCode.Services/SignalRs/` (concrete API spec is impl-level — see Follow-up #1 for tracking): - Delegates passthrough `IHubProtocol` members (`Name`, `Version`, `TransferFormat`, `IsVersionSupported`, `GetMessageBytes`) to the wrapped inner protocol. - Exposes `protected IHubProtocol Inner { get; }` to derived classes. - Declares virtual `WriteMessage` and `TryParseMessage` with default passthrough impl. - **Each concrete decorator overrides ONLY the methods relevant to its feature** (~10-15 lines of feature-specific code per decorator; no boilerplate per-impl). ### Stacking order (canonical) `compress → encrypt → sign` (innermost decorator wraps outwards): ``` AcBinaryHubProtocol ← CompressingHubProtocol ← EncryptingHubProtocol ← SigningHubProtocol ↑ (outermost wrapper) ``` DI registration: ```csharp services.AddSingleton(sp => new SignDecorator( new EncryptDecorator( new CompressDecorator( new AyCodeBinaryHubProtocol(opts))))); ``` Send order: `AyCodeBinaryHubProtocol` serializes → `CompressingHubProtocol` (innermost wrap) compresses → `EncryptingHubProtocol` encrypts → `SigningHubProtocol` (outermost) appends HMAC tag. Receive order is reversed. **Rationale**: compressed ciphertext is uncompressible (compression must precede encryption to avoid wasted CPU AND CRIME/BREACH-class side-channel attacks — TLS 1.3 deprecated TLS-level compression for this reason); encrypt-then-MAC is the industry-standard signing+encryption combination order. The DI registration order encodes this canonically; non-canonical orderings emit a startup warning (validation in DI extension methods — follow-up #2). ### Handshake-based feature negotiation At connection setup (1 application-level handshake roundtrip after SignalR's protocol handshake), both sides exchange a feature manifest: ``` { "compression": { "active": bool, "algorithm": "lz4" | "brotli" | "zstd" | ... }, "encryption": { "active": bool, "algorithm": "aes-gcm" | "chacha20-poly1305", "keyId": "..." }, "signing": { "active": bool, "algorithm": "hmac-sha256" | "hmac-sha512", "keyId": "..." }, "tracing": { "active": bool, "samplingRate": float? } } ``` `MinSize` for compression is **sender-side config**, NOT a handshake parameter — the two endpoints may use different `MinSize` thresholds. The receiver detects per-message via the `IsCompressed` wire flag (see below). **Mismatch handling**: asymmetric registration (one side has decorator, other doesn't) → handshake fails fast with a logged reason at `Error` level. Per-message asymmetry (handshake-skip artefacts) is handled by the decorator-failure unified protocol below. The app-level handshake mechanism's concrete shape (Option A: a special `__ac_handshake` HubMessage as first message; Option B: wire-level `WriteHandshake` virtual on `AcHubProtocolDecoratorBase`) is **deferred to a follow-up** — the umbrella commits to "handshake-based" but does not pick the mechanism. See Follow-up #3. ### Per-message `IsCompressed` flag — semantic (A): standalone byte The `IsCompressed` flag is a **standalone byte** in the wire format, NOT a value-range extension of the existing message-type byte. Wire format with the compression decorator active: ``` [INT32 LE length: 4 byte][IsCompressed: 1 byte][message-type byte: 1 byte][message body: N byte] ``` - The `INT32 length` is the existing `AcBinaryHubProtocol` length prefix (unchanged). - The `length` value INCLUDES the `IsCompressed` byte: `length = 1 (IsCompressed) + 1 (message-type) + N (body) = N + 2`. - The `IsCompressed` byte takes values `0x00` (uncompressed) or `0x01` (compressed). Future extension values reserved (`0x02..0xFF` unused for now). - **Per-message overhead: +1 byte / message constant**, regardless of compressed-or-not state. Even uncompressed messages carry the `0x00` byte. In `AsyncSegment` mode the `IsCompressed` byte sits at the SAME position (offset 4 in the wire — directly after the INT32 length prefix), BEFORE the `200 CHUNK_START` marker. **Per-chunk overhead: 0 bytes** — the chunk-level framing (`[201][UINT16 size][data]`) is unchanged. **Why semantic (A) and not semantic (B) — value-range extension**: an alternative considered was encoding the compressed flag into the unused high bit of the message-type byte (compressed values disjoint from `{1..9, 200..202}`), giving 0 byte overhead in the uncompressed case. Rejected because: - Adds branching complexity to the receiver (parse high bit, strip it, then dispatch on remaining bits). - Future feature flags would compete for the same byte's bit space. - 1 byte / message overhead is negligible at typical message sizes. - Simpler is better for a NuGet-public protocol. ### Simple compression rule ``` IF handshake.compression.active AND message_size >= MinSize: → IsCompressed = 0x01, compress whole message bytes (no per-arg inspection) ELSE: → IsCompressed = 0x00, passthrough ``` No per-arg inspection (no `isAcBinary` magic-byte gating, no mixed-message policy handling). If a developer sends raw byte[] (e.g., already-compressed JPEG) with handshake compression on, the bytes are compressed once more — at most ~1-5% size growth (negligible vs CPU cost; HTTP `Content-Encoding: gzip` follows the same "compress everything" pattern industry-wide). The `isAcBinary` detection in the existing wire format remains a per-arg discriminator (first-byte 0x44 vs 0x01) for the byte[] fast-path's serialization branching — **independent** of compression decision. **Layer responsibility**: the protocol decorator orchestrates (handshake state, MinSize check, IsCompressed flag emit/read); the `AcBinarySerializer` / `AcBinaryDeserializer` provide the algorithm code (`Compress(bytes, algo)` / `Decompress(bytes, algo)` static helpers). The protocol delegates to these helpers without owning the algorithm implementations. ### BufferSize semantics — wire-chunk target with internal source-chunk derivation `AcBinaryHubProtocolOptions.BufferSize` semantically denotes the **wire-chunk target size** (post-decoration), aligned to the SignalR / Kestrel slab convention (default 4096). The protocol transparently derives the **actual source-chunk size** by subtracting the cumulative per-chunk decoration overhead: ``` ActualSourceChunkSize = BufferSize - Σ(decorator.PerChunkOverhead) ``` Each decorator MUST expose a `PerChunkOverhead` constant (or `MaxPerChunkOverhead` worst-case bound for compression where output size is data-dependent). The decorator chain sums these at composition time. The chunking logic in `AcBinaryHubProtocol` reads `ActualSourceChunkSize` for source-chunk granularity. | Feature | Algorithm | Per-chunk overhead | Type | |---------|-----------|---------------------|------| | Encryption | AES-GCM | +28 byte | Fixed (12 nonce + 16 auth tag) | | Encryption | ChaCha20-Poly1305 | +28 byte | Fixed (12 nonce + 16 auth tag) | | Signing | HMAC-SHA256 | +32 byte | Fixed (256-bit tag) | | Signing | HMAC-SHA512 | +64 byte | Fixed (512-bit tag) | | Compression | LZ4 frame @ 4 KB input | up to +32 byte (worst case) | Bounded | | Compression | Zstd @ 4 KB input | up to +28 byte (worst case) | Bounded | | Compression | Brotli @ 4 KB input | up to +160 byte (worst case) | Bounded | | Tracing | (any) | 0 byte | Fixed | **Result**: user-facing `BufferSize` stays Kestrel-slab-aligned regardless of decoration stack. Wire-chunk size ≤ `BufferSize` is guaranteed; `UINT16` chunk size prefix never overflows because BufferSize is well below 65535. **Constraint**: `BufferSize` MUST be ≥ 256 byte (sanity floor); decoration stacks summing to ≥ `BufferSize` overhead are rejected at registration time. ### AsyncSegment per-chunk decoration constraint In `AsyncSegment` mode, decorators MUST operate per-chunk (each chunk independently encrypted / compressed / signed) to preserve the protocol's pipeline-parallelism guarantee. Per-message decoration in AsyncSegment defeats the purpose of chunked streaming (the receiver would have to accumulate the entire message before decoding could begin). `Bytes` and `Segment` modes have no such constraint and may use per-message decoration freely. ### UINT16 size prefix design intent (chunk-level) The `[201][UINT16 size][data]` chunk framing intentionally caps wire-chunks at 65535 bytes. Larger chunks would defeat the AsyncSegment design: pipeline parallelism benefits diminish past Kestrel slab size (~4 KB), MTU-aligned (~1.5 KB) sizes are typical. A 4-byte INT32 size prefix per chunk would add 2× the overhead at multi-megabyte payloads (20+ chunks × 2 bytes saved per chunk vs the 4-byte alternative). UINT16 is the correct choice for the AsyncSegment use-case. This umbrella ADR codifies the design intent so leaf ADRs do not propose changing it as a side-effect. ### Decorator-failure unified protocol When a decorator's `TryParseMessage` cannot complete (decryption auth-tag fails, decompression error, signature mismatch, opaque feature data on a peer with no matching decorator): 1. The decorator's `TryParseMessage` returns `false` (per `IHubProtocol` contract — message not parseable). 2. The decorator logs the failure reason at `Error` level via the `ILogger<>` infrastructure (logger threaded through `AcBinaryHubProtocolOptions.Logger`). 3. The SignalR `HubConnection` closes the connection on persistent parse-failure (per SignalR's standard behavior — never silent). **Never silent**: a decorator MUST NOT swallow a parse error and return a partial / default message. If the decorator cannot extract the original message, the connection closes with a logged reason. This contract holds for ALL decorators (uniform failure semantics across the entire feature stack). ### Backward compatibility (handshake-mismatch handling) A v1 client (no decorators registered) connecting to a v2 server (decorators in chain), or vice versa, must NOT silently break. The handshake is the SOLE point of feature negotiation: - **Handshake mismatch** (one side advertises feature, other does not): handshake fails fast. The connection closes with a logged `Error`-level reason indicating the missing capability. Clients can retry with a reduced feature set (or accept the failure and signal a configuration error to ops). - **Symmetric DI registration encouraged**: for clean rolling upgrades, both ends of a connection should register matching decorators before either side enables a feature. - **No on-wire fallback heuristic** — the design relies on handshake state, not first-byte-detection or magic-byte sniffing on a per-message basis. This avoids the fragile heuristic disambiguation that earlier protocol designs (e.g., HTTP/1 vs HTTP/2 on the same port) had to engineer around. ## Consequences ### Positive - **Single Responsibility**: each feature lives in 1 class, 1 file, 1 concern. `AcBinaryHubProtocol.cs` (~1400 lines) stays unchanged. - **Truly zero overhead when opted out**: a decorator absent from the DI chain means the feature's code path **does not exist** in the runtime — not JIT-eliminated, not branch-not-taken, but literally no method to invoke. Contrast in-protocol embedding (Alt-2) where opt-out depends on JIT successfully eliminating null-checked branches. - **Boilerplate mitigated by `AcHubProtocolDecoratorBase`**: passthrough members live in the base; each concrete decorator only writes ~10-15 lines of actual feature code. - **Composable**: stacking order = DI registration order, runtime-explicit and reviewable. - **Extensible**: new feature = new `MyFeatureDecorator : AcHubProtocolDecoratorBase` class + DI extension method. Neither the base class nor existing decorators change. - **Industry-standard handshake negotiation**: matches TLS, HTTP/2 ALPN, WebSocket per-message-deflate, gRPC patterns. Familiar to .NET developers; checks the "polished NuGet" box for procurement / security audits. - **Backward-compatibility paths preserved**: v1 ↔ v2 mixed deployments fail fast at handshake (early, debuggable) instead of mysteriously corrupting messages. - **Wire protocol stays minimal**: the only NuGet-feature-related new wire byte is the per-message `IsCompressed` (1 byte). Encryption and signing add only their cryptographic data overhead (nonce + tag); tracing has zero wire impact. ### Negative - **1 vtable indirection per protocol-method call per layer**: 4-deep chain = up to 4 virtual call frames before reaching the inner `AcBinaryHubProtocol`. Negligible at message granularity (sub-microsecond), but real. - **Debug stack up to 4 frames deeper when all features active**: navigating call stacks in production debugging is mildly noisier. - **Stacking order fixed at registration**: no runtime-adaptive composition (e.g., "compress only on payloads > 1MB"); intra-decorator logic (like the `MinSize` policy in ADR-0003) handles per-message branching internally. - **+1 byte / message constant overhead** when compression decorator is in the chain (semantic A: the IsCompressed standalone byte) — accepted as the cost of architectural simplicity. - **App-level handshake mechanism is new infrastructure**: requires designing an upfront message-exchange before normal traffic. Adds 1 roundtrip to connection setup. Concrete shape (Option A vs B) deferred to a follow-up. ### Follow-ups required 1. **`AcHubProtocolDecoratorBase` implementation** — abstract class to be added to `AyCode.Services/SignalRs/`. **Implementation is deferred** until at least one leaf ADR (0002-0005) reaches `Status: Accepted` and triggers the actual feature work. **At that point**, a `SIGNALR_BINARY_PROTOCOL_TODO.md` entry will be added with a freshly-generated `ACCORE-SBP-T-` ID per the `docs-check` skill Step 5 procedure, and this follow-up's references throughout the ADR will be updated. 2. **Stacking-order validation in DI** — `AddAcEncryption` / `AddAcSigning` / `AddAcCompression` extension methods should warn or throw if registered in a non-canonical order. Concrete implementation lands together with Follow-up #1 once that work begins. 3. **App-level handshake mechanism design** — choose between Option A (`__ac_handshake` HubMessage as first message) vs Option B (`WriteHandshake` virtual on `AcHubProtocolDecoratorBase`). Decided + implemented together with Follow-up #1 when that work begins. 4. **Benchmark scaffolding** — message-granularity vtable cost measurement for the "negligible" claim. Each leaf ADR's acceptance criteria includes verifying this in their feature's context. 5. **Cross-references** — Step 8 of the `adr-author` skill: this ADR links to the 4 forthcoming leaf ADRs (0002-0005); `../SIGNALR_BINARY_PROTOCOL/README.md` gets a new `## Related ADRs` section pointing back to `0001-acbinary-decorator-feature-stack-design.md`. ## Alternatives considered - **In-protocol embedding** (rejected): all features inline inside `AcBinaryHubProtocol` via null-checked `_options` branches. Loses SRP (`AcBinaryHubProtocol.cs` would grow from ~1400 to 2300+ lines), zero-overhead depends on JIT successfully eliminating null branches (not a static guarantee), and cross-feature ordering becomes hardcoded in method bodies. - *Reversibility:* low — switching to decorator later means a near-total rewrite. - *Future flexibility:* low — every new feature pressures the monolithic class. - **Middleware pipeline** (rejected): ASP.NET Core-style `IAcHubProtocolMiddleware` with `next` delegate chaining. Heavier infrastructure (per-message delegate allocation + closure capture), doesn't align with `IHubProtocol.WriteMessage`'s sync-void contract, and the middleware abstraction shines for HTTP request branching — not linear feature stacking. Effectively reinvents the decorator pattern with extra ceremony. - **Source-gen specialization** (rejected): Roslyn generator emits compile-time-specialized `AcBinaryHubProtocol_AesGcm_Lz4_Tracing` types per feature combination. True zero overhead but combinatorial type explosion (4 features × on/off + algorithm choice → 16+ generated types), build-time complexity, and runtime configuration via appsettings becomes architecturally hard. Reconsider only if benchmarks reveal vtable indirection becoming non-negligible at extreme message rates. - *Cost:* very high. - *Future flexibility:* low — every feature change is generator surgery. - **Per-message wire-marker byte ranges with magic-byte detection** (rejected — earlier draft iteration): each feature would emit a wire-marker byte in `0x10..0xFF` ranges, with the receiver dispatching via first-byte detection. Ruled out because (a) handshake-based negotiation is the industry-standard pattern and avoids fragile per-message heuristics; (b) per-feature wire-marker byte ranges add up to 4 bytes / message overhead in the 4-feature stack, vs the chosen 1 byte for `IsCompressed` plus handshake-decided everything else; (c) backward-compat fail-closed via handshake mismatch is cleaner than per-message disambiguation. - **`IsCompressed` as message-type-byte value-range extension (semantic B)** (rejected): encode the compressed flag into the unused high bit of the message-type byte, so uncompressed messages carry 0 added byte overhead. Adds branching complexity to receivers; future feature flags would compete for the same byte's bit space; the per-message overhead saving (1 byte) is negligible at typical message sizes. Semantic (A) — standalone byte — chosen for simplicity and forward extensibility. ## Related - Related TODOs: `../SIGNALR_BINARY_PROTOCOL/SIGNALR_BINARY_PROTOCOL_TODO.md` ACCORE-SBP-T-H7M5 (encryption), ACCORE-SBP-T-N9F3 (compression+MinSize), ACCORE-SBP-T-J5W8 (tracing), ACCORE-SBP-T-B3K6 (signing). The `AcHubProtocolDecoratorBase` impl + handshake-mechanism TODO is **not yet created** — see Follow-up #1 for the conditions under which it will be added. - Forthcoming ADRs: 0002 (payload encryption), 0003 (message compression with MinSize), 0004 (OpenTelemetry tracing), 0005 (HMAC signing-only) — all will declare `Depends on: ADR-0001`. - Topic-folder cross-ref: `../SIGNALR_BINARY_PROTOCOL/README.md` to add `## Related ADRs` section in Step 8.