# AsyncPipe — Known Issues & Limitations

Streaming I/O layer for the binary serializer (`AsyncPipeReaderInput` + `AsyncPipeWriterOutput`):
sliding-window buffer, multi-message wire framing (`[201][UINT16][data]...[202]`), producer-consumer
synchronization. Issues on this page cover the **streaming protocol**, NOT the binary serializer core
(see [`BINARY_ISSUES.md`](BINARY_ISSUES.md) for the core). Planned / actionable work tracked in
[`BINARY_ASYNCPIPE_TODO.md`](BINARY_ASYNCPIPE_TODO.md).

## Streaming protocol — open issues

### ACCORE-BIN-I-Q4T8: AsyncPipeReaderInput multi-message reuse not supported

**Status:** Open
**Affects:** `AsyncPipeReaderInput` (both raw mode `multiMessage: false` and framed mode)
**Reach:** any long-lived pipe transport (NamedPipe, FileStream, NetworkStream) that needs to stream multiple AcBinary messages over a single connection without setting up a fresh input per message.

**Symptom:** Calling `AcBinaryDeserializer.Deserialize<T>(input, opts)` repeatedly on the same long-lived `AsyncPipeReaderInput` instance silently corrupts data on the second and subsequent calls. In raw mode the second call re-reads the FIRST message's bytes from buffer position 0; in framed mode the `[202]` CHUNK_END marker leaves `_completed = true` permanently, breaking any further reads.

**Root cause:**

1. `AsyncPipeReaderInput.Initialize(out buffer, out position, out bufferLength)` always emits `position = 0` — no "consumed offset" / "next-message-start" cursor is preserved between calls.

2. The sliding-window reset-to-0 in `AppendToBuffer` (`if (rp > 0 && rp == _writePos) → reset both to 0`) only fires when the consumer has caught up via `TryAdvanceSegment`. For small messages that fit entirely inside the initial buffer capacity (4-8 KB), `TryAdvanceSegment` is **never** called during deserialization — so `_readPos` stays at 0, the reset never fires, and a second `Deserialize<T>(input)` call starts reading the same buffer from offset 0 again (= duplicate of message #1).

3. `Complete()` (called explicitly OR implicitly via the `[202]` CHUNK_END marker in framed mode) sets `_completed = true` irreversibly. There is no Reset / Reopen method.

**Workarounds:**

- **Per-message fresh `AsyncPipeReaderInput` instance** (the canonical pattern, used internally by `DeserializeFromPipeReaderAsync`): `using var input = new AsyncPipeReaderInput(...);` + drain Task + deserialization Task per message. Cost: one ArrayPool rent + one `ManualResetEventSlim` allocation per message + two `Task.Run` per message. Acceptable for typical IPC consumers but precludes any zero-alloc-per-message streaming on long-lived raw transports.

- **Multiplexed wire format** (`SerializeChunkedFramed` + `AsyncPipeReaderInput(multiMessage: true)`): a parser layer above parses `[201][UINT16][data]` frames and feeds individual messages into per-message inputs. The `[202]`-end-of-message rule still applies, so each message logically needs its own input — but the parser can keep the wire-level reader long-lived. Implies pipeline parallelism (multiple in-flight messages), which may not match every consumer's needs.

**Cross-references:**
- ADR `docs/adr/0003-acbinary-streaming-receive-architecture.md` — origin of the streaming-receive architecture
- `AcBinaryDeserializer.DeserializeFromPipeReaderAsync` — reference single-message usage pattern
- `AsyncPipeReaderInput.Initialize` / `TryAdvanceSegment` / `AppendToBuffer` — the three sites that together enforce single-use semantics
- Tracked fix: [`BINARY_ASYNCPIPE_TODO.md#accore-bin-t-r5k2`](BINARY_ASYNCPIPE_TODO.md#accore-bin-t-r5k2-multi-message-reuse-for-asyncpipereaderinput)

### ACCORE-BIN-I-W6K4: Disposal trap — `IDisposable.Dispose()` does not guarantee consumer wakeup

**Status:** Open
**Affects:** `AsyncPipeReaderInput.Dispose()` vs consumer-thread wait-primitive

If the stream lifecycle is torn down without an explicit "session-end" signal first, a consumer thread blocked in the input's wait-mechanism (`TryAdvanceSegment`'s `ManualResetEventSlim.Wait`) may never wake up. The disposed wait-primitive then surfaces as `ObjectDisposedException` on the consumer thread.

**NuGet API surface concern:** the natural .NET pattern `using var input = new AsyncPipeReaderInput(...)` does not by itself protect the consumer — calling `Complete()` before disposal is required by current contract but not enforced or documented at the API surface. Consumers expecting `Dispose()` to be a well-behaved teardown (the .NET convention) silently hit the trap.

**Possible directions** tracked in [`BINARY_ASYNCPIPE_TODO.md#accore-bin-t-m7b3`](BINARY_ASYNCPIPE_TODO.md#accore-bin-t-m7b3-implicit-session-end-signal-in-dispose).

### ACCORE-BIN-I-D8L5: Session-end is irreversible — no reset/reopen primitive

**Status:** Open
**Affects:** `AsyncPipeReaderInput.Complete()`

The session-end signal flips an internal flag (`_completed`) that cannot be undone; reusing the input instance after a `Complete()` requires constructing a fresh instance.

**Reach:** any long-lived transport where failover / reconnect logic might want to reuse the input object across reconnect cycles. Currently the consumer must allocate a fresh instance per session, paying ArrayPool rent + MRES allocation overhead even when the previous instance was already cleanly drained.

**Related to** ACCORE-BIN-I-Q4T8 (which surfaces the same irreversibility from the multi-message-reuse angle); fix directions explored in [`BINARY_ASYNCPIPE_TODO.md#accore-bin-t-r8h4`](BINARY_ASYNCPIPE_TODO.md#accore-bin-t-r8h4-reset-reopen-primitive-for-a-stream-after-session-end).

### ACCORE-BIN-I-J2X7: Multi-pattern documentation gap on the public API surface

**Status:** Open
**Affects:** `AsyncPipeReaderInput` class summary

The streaming input type can be used by consumers across several distinct producer-consumer patterns:

- **Strictly-sequential single-thread**: producer and consumer interleave on one thread (typical sync IPC loop). Buffer-cycle reset and message boundary detection happen on the same call-stack.
- **Multi-thread feed + deserialize**: dedicated drain thread feeds bytes; dedicated worker thread deserializes. Buffer-cycle reset and consumer-progress signalling cross thread boundaries.
- **Push / event-driven**: external code calls `Feed` from arbitrary callbacks (network event handlers, etc.); deserialization is scheduled by the consumer when convenient.
- **One-shot single-message**: a single `Feed` sequence + `Complete` lifecycle, no reuse needed.

The current class summary implicitly assumes one of these patterns; the public API surface lacks an explicit pattern catalogue with recommended low-level vs. higher-level API guidance per pattern. Without this, NuGet consumers may pick an API combination that does not match their threading model and run into latent correctness issues only visible under load.

**Possible documentation direction** tracked in [`BINARY_ASYNCPIPE_TODO.md#accore-bin-t-s5n2`](BINARY_ASYNCPIPE_TODO.md#accore-bin-t-s5n2-pattern-catalogue-in-the-public-class-summary).

### ACCORE-BIN-I-B7K9: AsyncPipeWriterOutput single-value writes > MaxChunkSize data bytes not supported

**Status:** Open
**Affects:** `AsyncPipeWriterOutput.AcquireChunk` (and indirectly any `BinarySerializationContext.EnsureCapacity` call where the request exceeds per-chunk data capacity)
**Reach:** any single serialized value whose worst-case byte-count exceeds `MaxChunkSize - HeaderSize` (~65 532 byte in framed mode). Typical trigger: a `string` property with > ~16 383 chars (writer pre-reserves `4 * charLength + 9` bytes for worst-case UTF-8 encoding); also `byte[]` or other values larger than the chunk-data capacity.

**Symptom:** `BinarySerializationContext.EnsureCapacity` now surfaces a clear `InvalidOperationException` ("Output.Grow did not satisfy the requested capacity: N bytes requested, only M bytes available after Grow") instead of the previous downstream `ArgumentOutOfRangeException` from `_buffer.AsSpan(start, length)` / `CopyTo` at corrupt offsets. The exception is intentional — surfaces the chunk-limit-vs-request mismatch at the precise call site, no silent wire corruption.

**Root cause:** The multi-message wire format uses `[201][UINT16 size][data]...[202]` framing. The `UINT16` cap (`MaxChunkSize = 65 535` total = 65 532 data bytes in framed mode) is a wire-format-level constraint. Currently `AcquireChunk` clamps any single contiguous request to this cap; values requiring more contiguous space in one Grow-event cannot be served by chunk-pool memory.

**Why intentional limit (UINT16 size field):** The wire-format `UINT16 size` field is a deliberate compactness trade-off — 1-byte overhead per chunk header is minimal vs UINT32 (would cost +2 bytes per chunk overhead, measurable on small-chunk transports). The cap is intrinsic to the framing format; the FIX is at the producer side (transparent split-on-commit), not the wire format.

**Workarounds (current — pre-fix):**
- **Avoid single string values > ~16 383 chars** in serialized graphs (4-byte/char worst-case keeps `4 * charLength + 9 ≤ 65 532`). Real workloads with mostly-ASCII content can practically reach ~65K chars before hitting the cap, but the safe-by-construction limit is 16K.
- **External chunking**: pre-split very large strings (file contents, blobs) into multiple smaller properties or use a dedicated `byte[]` array property with consumer-side chunking.
- **Use a non-Pipe transport for the affected message types** — `byte[]` / `BufferWriter` output mode has no chunk-cap (`ArrayBinaryOutput.Grow` / `BufferWriterBinaryOutput.Grow` size to whatever is needed).

**Related semantic fix in same session (off-by-headerOffset):** `AsyncPipeWriterOutput.Grow` previously passed `Math.Max(needed, _chunkSize)` to `AcquireChunk`, mixing units (`needed` is data-bytes, `_chunkSize` is chunk-on-wire = header + data). `AcquireChunk` subtracts `headerOffset` from the request internally to size the data region — so every Grow request was short by `headerOffset` (3) bytes. Fixed: `Math.Max(needed + headerOffset, _chunkSize)` aligns both terms to chunk-on-wire scale. This closes the off-by-3 chunk-shortage band (1021..16383 char range was affected even though < cap); the `> MaxChunkSize` band remains a separate concern tracked here.

**Possible fix direction (transparent split-on-commit):**

Implement an owned-buffer fallback inside `AsyncPipeWriterOutput`:

1. `AcquireChunk` detects `requestSize > MaxChunkSize - headerOffset` → force `_ownedBuffer = ArrayPool<byte>.Shared.Rent(requestSize)` path (instead of `_pipeWriter.GetMemory`). Returns the contiguous owned buffer; `_hasOwnedBuffer = true`.
2. Hot-path writers (`WriteStringWithDispatch`, `WriteByteArray`, etc.) write into the owned buffer unchanged — no API contract change, no caller-side awareness.
3. `CommitCurrentChunk` (or `Flush`) on a `_hasOwnedBuffer = true` state splits the owned buffer into `MaxChunkSize - headerOffset`-sized data chunks, each framed with its own `[201][UINT16 size]` header into the `_pipeWriter`'s memory, and emits them sequentially. The `[202]` end marker remains owned by `Flush` (only DATA chunks are split — the message-end marker is per-message, not per-chunk).
4. Receiver side (`AsyncPipeReaderInput.Feed` framing-state-machine) is **not affected** — the wire still arrives as a stream of `[201]`-framed data chunks; the sliding-window buffer reassembles bytes for the deserializer regardless of how the producer split them.

Allocation profile: 1× ArrayPool rent + 1× return per `>MaxChunkSize` event. ArrayPool slab reuse keeps per-event overhead near-zero on repeated occurrences. Hot path (`≤ MaxChunkSize` values) entirely unchanged.

**Acceptance** (for the future TODO entry):
- New test: serialize an entity with a string property whose UTF-8 byte length > 65 532 bytes over `AsyncPipeWriterOutput`; round-trip equality holds.
- Test: serialize a `byte[]` property > 65 532 bytes; round-trip equality holds.
- Test: mixed message — small + large + small string properties on the same message → all wire frames valid, deserializer reconstructs each in order.
- Wire format unchanged — receiver code untouched.
- Allocation profile: 1 rent + 1 return per >cap event; no allocation regression on ≤cap path.

**Cross-references:**
- Sanity guard added: `BinarySerializationContext.EnsureCapacity` → `GrowAndValidate` → `ThrowGrowFailedToSatisfy` (clear diagnostic instead of silent under-provisioning).
- Off-by-headerOffset semantic fix: `AsyncPipeWriterOutput.Grow` line ~287 (see "Related semantic fix" above).
- Wire format: `BINARY_FORMAT.md` (framing constants).

## Streaming protocol — resolved issues

### ACCORE-BIN-I-H4G2: Chunk-on-wire size = `chunkSize + HeaderSize` caused page-fragmentation

**Status:** Resolved
**Affects:** `AsyncPipeWriterOutput.AcquireChunk` (multi-message wire format)
**Reach:** any page-aligned transport buffer (kernel pipe / file / network) — not transport-specific.

Previously, the `chunkSize` parameter was treated as the data-payload size and the chunk-on-wire became `chunkSize + HeaderSize` bytes. For page-aligned `chunkSize` values (4 KB / 16 KB / 64 KB), every chunk-on-wire overran the matching kernel page boundary by `HeaderSize` bytes, forcing two kernel-page transfers per chunk. The producer-side `Stream.WriteAsync` saw page-fragmentation on every chunk; the consumer-side `Feed` saw chunk-headers split across boundaries.

**Resolution:** the `chunkSize` parameter now denotes the **chunk-on-wire total size** (header + data); the data payload is `chunkSize - HeaderSize` in framed mode. Page-aligned `chunkSize` now lands exactly in one kernel-buffer slot, no fragmentation. Wire format unchanged. Documentation follow-up tracked in [`BINARY_ASYNCPIPE_TODO.md#accore-bin-t-g3w8`](BINARY_ASYNCPIPE_TODO.md#accore-bin-t-g3w8-transport-buffer-alignment-best-practice-doc-section).

### ACCORE-BIN-I-N3P7: `Initialize` snapshot of stale `_writePos` in multi-message reuse

**Status:** Resolved
**Affects:** `AsyncPipeReaderInput.Initialize` / `AppendToBuffer` / `MessageDone` interaction

Previously, when the `[202]` end-of-message marker reset only the framing state (NOT the buffer-position counters), a subsequent `Initialize` call on the calling thread could snapshot `_writePos` while it still pointed at the previous message's tail. The consumer would then re-read previous-message bytes from buffer offset 0, or — worse — observe a transient mixed state where the producer-thread's first `AppendToBuffer` for the next message had partially overwritten the buffer's start while the consumer held the stale `bufferLength`. Symptom: a later message header-byte appeared as garbage (`0xEE` in version slot, etc.), surfacing as `AcBinaryDeserializationException`.

**Resolution:** `MessageDone()` (called by the consumer after a message is fully read — automatically from `Deserialize<T>`'s finally-block) atomically resets BOTH `_writePos` AND `_readPos` to 0 on the calling thread, BEFORE the next `Initialize` runs. The next `AppendToBuffer` from the producer thread then writes from offset 0 without needing a buffer-cycle, and the consumer always sees a fresh `bufferLength` snapshot.

## Wire format / Multi-message protocol limits

The multi-message wire format (`AsyncPipeWriterOutput` + `AsyncPipeReaderInput` with `multiMessage = true`) is designed for **a single sequential producer + a single sequential consumer** sharing one long-lived stream. The format is `[201][UINT16 size][data]...[202]` per message, with no stream-id, no message-id, and no out-of-band recovery primitive. Real-world deployments may need patterns the format does not natively support — these limits are not bugs but **architectural decisions documented as constraints**.

### ACCORE-BIN-I-M9P3: No native chunk-multiplexing for concurrent writers

**Status:** Open (intentional limit)
**Affects:** `AsyncPipeWriterOutput` (multi-message wire format `[201][UINT16][data]...[202]`)
**Reach:** any deployment that wants multiple producers (or multiple logical streams) to interleave on a single long-lived `PipeWriter`.

**Symptom:** If two threads concurrently call `AcBinarySerializer.SerializeChunkedFramed(..., sharedPipeWriter, ...)` on the same `PipeWriter` without external synchronization, their `[201][UINT16][data]` chunks interleave on the wire. The receiver's `AsyncPipeReaderInput` framing-state-machine has no way to demultiplex — the chunks all flow into one `_buffer`, and the next `Deserialize<T>` reads a corrupted blend of both messages.

**Root cause:** The wire format has no **stream-id** field in the chunk header. `[201][UINT16 size][data]` only encodes the size, not which logical stream the chunk belongs to. A receiver assumes a strictly sequential producer; concurrent producers must serialize externally (e.g. a `SemaphoreSlim` around `SerializeChunkedFramed`).

**Why intentional:** Adding a stream-id would widen the chunk header (extra UINT8/UINT16 per chunk) — measurable wire overhead for the typical case (single producer / sequential RPC pipeline / hub-style message dispatch). The cost-benefit favours keeping the header lean and pushing concurrency to the application layer (one writer per logical stream).

**Workarounds:**
- **External lock around the writer**: `lock` / `SemaphoreSlim` ensuring at most one in-flight `SerializeChunkedFramed` call per `PipeWriter`. Producer-side concern.
- **One PipeWriter per logical stream**: each stream gets its own connection / pipe instance. Best fit for transport-level multiplexing (e.g. HTTP/2 streams, multiple WebSocket frames).
- **Out-of-band protocol layer**: build a stream-id wrapper above `SerializeChunkedFramed` (`[stream-id-byte][serialized-bytes]`); the receiver dispatches to per-stream `AsyncPipeReaderInput` instances. Possible but currently no built-in support.

**Cross-references:**
- Wire format definition: `AsyncPipeWriterOutput.cs` (`ChunkDataMarker = 201`, `ChunkEndMarker = 202`)
- The `[200] = 0xC8` CHUNK_START marker is currently reserved/tolerated with no semantics — the consensus mux-mode design (see `M2X7`) activates this code point as the mux-data marker (`[0xC8][streamId][UINT16 size][data]`), making each chunk self-identify its mode without needing a session handshake. Single-mode wire-format (`[0xC9]`) stays unchanged → SignalR protocol compat preserved.
- Symmetric reader-side limit: `ACCORE-BIN-I-C4N7`
- Tracked direction (additive wire-format extension on the reserved `[0xC8]` byte): [`BINARY_ASYNCPIPE_TODO.md#accore-bin-t-m2x7`](BINARY_ASYNCPIPE_TODO.md#accore-bin-t-m2x7-multi-stream-multiplexer-support-marker-byte-mode-distinction)

### ACCORE-BIN-I-C4N7: AsyncPipeReaderInput single-consumer constraint

**Status:** Open (intentional limit)
**Affects:** `AsyncPipeReaderInput` (consumer-side state machine)
**Reach:** any deployment that wants multiple consumers to read concurrently from the same long-lived input — e.g. parallel-dispatch RPC over a single NamedPipe where responses for in-flight calls return out of order.

**Symptom:** Two threads concurrently calling `AcBinaryDeserializer.Deserialize<T>(input, opts)` on the same `AsyncPipeReaderInput` race on the sliding-window buffer cursor (`_readPos` / `_writePos`), the framing-state machine, and the `Initialize` snapshot. Outcomes range from `AcBinaryDeserializationException` (mid-message state corruption) to silent graph corruption (buffer-position aliasing where consumer A reads bytes consumer B was meant to receive).

**Root cause:** The reader's API contract assumes **one consumer thread** per input. `Initialize` / `TryAdvanceSegment` / `MessageDone` operate on shared mutable state without lock-free safety; the drain task (producer) is the only thread cleared to `Feed` concurrently with the consumer, and only via the buffer-handoff MRES protocol — not via a multi-reader-safe primitive.

**Why intentional:** Adding consumer-side concurrency primitives inside the reader would impose per-call thread-safety overhead (locks or atomic state transitions) on the 99% single-consumer use case. The wire format today has no stream-id field on `[0xC9]` chunks (see `ACCORE-BIN-I-M9P3` — symmetric writer-side limit), so demultiplexing requires either a coordinator above the existing class **or** an opt-in mux-mode marker (the `M2X7` direction activates `[0xC8]` for exactly this purpose).

**Workarounds:**
- **External serialization at consumer**: `lock` / `SemaphoreSlim` around `Deserialize<T>` calls — works but kills the parallelism benefit
- **One AsyncPipeReaderInput per logical stream**: each stream gets a separate input + drain task pair — costs one ArrayPool buffer + one MRES + one Task per stream, but isolates state cleanly
- **Marker-distinguished mux-mode** (consensus future direction): the producer emits `[0xC8][streamId][UINT16 size][data]` for mux chunks; the reader's framing-state-machine reads the streamId byte and dispatches each chunk to the matching per-stream child input. Single-stream wire-format (`[0xC9]`) stays unchanged. See [`BINARY_ASYNCPIPE_TODO.md#accore-bin-t-m2x7`](BINARY_ASYNCPIPE_TODO.md#accore-bin-t-m2x7-multi-stream-multiplexer-support-marker-byte-mode-distinction).

**Cross-references:**
- `ACCORE-BIN-I-M9P3` — symmetric writer-side limit (concurrent writers interleave on the wire)
- `ACCORE-BIN-I-Q4T8` — distinct concern: multi-message reuse on a single input, not multi-consumer concurrency
- Tracked direction: [`BINARY_ASYNCPIPE_TODO.md#accore-bin-t-m2x7`](BINARY_ASYNCPIPE_TODO.md#accore-bin-t-m2x7-multi-stream-multiplexer-support-marker-byte-mode-distinction)

### ACCORE-BIN-I-T6V2: Single fixed type per long-lived stream

**Status:** Open (out of framework scope — consumer responsibility per CLAUDE.md Rule #7)
**Affects:** `AcBinaryDeserializer.Deserialize<T>(AsyncPipeReaderInput input, AcBinarySerializerOptions options)`
**Reach:** any consumer that wants a long-lived `AsyncPipeReaderInput` to receive **mixed-type messages** (e.g. `Request` then `Response` then `Heartbeat` on the same connection — the typical RPC/Hub pattern).

**Symptom:** The `Deserialize<T>` overload is parameterized on a single `T`. If consecutive messages on the same long-lived input are different types, the consumer has no way (within the AcBinary API surface) to know which `T` to pass for the next call.

**Root cause:** AcBinary's wire format does **not** include a type-discriminator before the payload. The serializer writes the object graph directly, and the deserializer must know the target type up-front. This is by design — the format is optimized for size, not for self-description.

**Why this stays out of scope (firm framework doctrine):**

The framework explicitly does NOT provide type-dispatch infrastructure (no wire-format type-id, no registry, no dispatcher, no handshake). This aligns with:

1. **CLAUDE.md Rule #7** — *"Tag-based transport... Request types are conventionally identified by `int` tags"* — dispatch is the consumer's concern, not the serializer's.
2. **Framework-First Design Principle** — *"Generic (reusable across any consumer)? → belongs HERE. Consumer-specific (business logic, ...)? → NOT HERE"* — type-dispatch policy (which types are registered, which handler runs for them) is inherently consumer-aware.
3. **Industry precedent** — Protobuf, MessagePack, MemoryPack, System.Text.Json, Cap'n Proto all keep their core as a pure (T ↔ bytes) serializer; type-dispatch sits in the consumer/RPC layer above (gRPC service methods, MagicOnion handlers, ASP.NET routing, SignalR `IInvocationBinder`, etc.).
4. **Existing planned Layer-1 hosts** (`AyCode.Services.SignalRs` and the planned `AyCode.Core.AspNetCore` formatter package — see [`BINARY_ASYNCPIPE_TODO.md#accore-bin-t-a8r5`](BINARY_ASYNCPIPE_TODO.md#accore-bin-t-a8r5-aspnet-core-mvc-formatters-acbinaryinputformatter--acbinaryoutputformatter)) **do not need** wire-format type-id — they get type info from their respective dispatch infrastructure (`IInvocationBinder.GetParameterTypes()`, HTTP routing → controller `[FromBody] T`).
5. **No third Layer-1 host is planned** — if a future consumer needs raw-IPC type dispatch, they build their own using one of the workarounds below. Speculative framework-side infrastructure for hypothetical consumers would violate the layer-purity doctrine.

This is a permanent architectural decision, not a "TODO not yet done." Do not file new TODOs proposing wire-format type-id, registry, or session/handshake infrastructure inside `AyCode.Core`.

**Workarounds (canonical patterns the consumer implements):**
- **Tag-based dispatch above AcBinary** (recommended — matches CLAUDE.md Rule #7): prefix each message with an `int` (or enum) tag the consumer reads first to choose `Deserialize<TypeForTag>`. The consumer encapsulates the tag-read + type-dispatch in its own deserialization wrapper. Reference example: `AyCode.Services/SignalRs/AcSignalRDataSource` uses `SignalRCrudTags`.
- **Polymorphic envelope type**: define a single `T = Envelope` containing a discriminator field + raw payload bytes; the consumer deserializes the envelope, switches on the discriminator, and re-deserializes the payload as the concrete type from the inner `byte[]`. Adds a small layer of indirection but works on top of fix-T `Deserialize<Envelope>`.
- **One input per type-stream**: separate streams per message-class. Practical when the type-set is small and the transport can afford multiple connections.

### ACCORE-BIN-I-Z2X9: Wire-format has no built-in cancel/timeout recovery

**Status:** Open (intentional limit)
**Affects:** `AsyncPipeReaderInput` framing state machine (multi-message mode)
**Reach:** any deployment that wants to recover a long-lived stream from a partial/aborted message (sender crash mid-message, network timeout mid-chunk, application cancellation mid-`SerializeChunkedFramed`).

**Symptom:** If a sender starts writing a multi-chunk message — `[201][UINT16=N][partial-data-of-K-bytes-where-K<N]` — and then aborts (no `[202]` follows), the receiver's framing state machine is stuck in `AwaitingData` with `_bytesRemainingInChunk = N - K`. When the next message's `[201]` header byte arrives, the state machine interprets it as **data** (part of the unfinished chunk's payload), not as a new chunk header — silent corruption follows.

**Root cause:** No marker in the wire format means "abort current message, restart fresh". `[200]` CHUNK_START is tolerated but skipped, with no state-reset semantics. Plain `[201]` cannot force-reset because **`[201]` is a legitimate data byte value** — a payload struct field could naturally contain `0xC9` (= 201). Distinguishing "header byte 201" from "data byte 201" requires the state-machine context, and the state machine itself is what's stuck.

**Why intentional:** The format trades cancel-recovery for compactness and unambiguous-state semantics. Reserving an "out-of-band reset escape sequence" would either widen every byte (escape encoding) or cost a multi-byte sentinel that conflicts with legitimate data; both impose wire overhead on the 99.9% normal case.

**Workarounds:**
- **Transport-layer recovery is the contract**: when a sender aborts mid-message, the established practice is to close the underlying pipe / connection (`pipeWriter.CompleteAsync()` / NamedPipe disconnect / WebSocket close). The receiver's drain loop sees `result.IsCompleted = true`, calls `input.Complete()`, the deserializer either returns the partial graph (if enough bytes were read) or throws. The consumer then reconnects with a fresh `AsyncPipeReaderInput`. **This is the only safe recovery path with the current wire format.**
- **Application-level heartbeat / sequence-number**: out-of-band liveness check that detects a stuck stream and forces transport-level reconnect before the framing-state-machine corrupts. Recommended for long-lived production deployments.
- **Hard timeout on individual messages**: if a partial chunk takes too long to complete, the consumer cancels the deserialize and tears down the stream rather than waiting indefinitely.

**Anti-pattern**: do NOT add a force-reset on `[201]`-in-`AwaitingData`. It would break the wire format (legitimate data bytes containing `0xC9` would silently restart the parser → catastrophic corruption far worse than the current symptom).

**Cross-references:**
- `AsyncPipeReaderInput.Feed` framing-state-machine — see `_framingState` transitions
- ACCORE-BIN-I-Q4T8 — the multi-message reuse pattern this issue qualifies (cancel-recovery is a separate concern from the steady-state reuse fix)
