# 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<IHubProtocol>(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-<RAND>` 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`.
- Repo-level ADR (cross-cutting): [`AyCode.Core/docs/adr/0003-acbinary-streaming-receive-architecture.md`](../../../docs/adr/0003-acbinary-streaming-receive-architecture.md) — receive-side `AsyncPipeReaderInput` primitive (replaces `SegmentBufferReader` + `SegmentBufferReaderInput`) that this decorator stack operates on top of in AsyncSegment mode (per-chunk decoration constraint, see Decision §"AsyncSegment per-chunk decoration constraint" above). The repo-level ADR's number `0003` is unrelated to this folder's project-scoped numbering — separate sequences per ADR folder.
- Topic-folder cross-ref: `../SIGNALR_BINARY_PROTOCOL/README.md` to add `## Related ADRs` section in Step 8.
