Files
alknet/docs/architecture/decisions/002-protocol-handler-trait.md
glm-5.2 c62a6adc7b docs(architecture): resolve review #002 Tiers 1-3 — mechanical and consistency fixes
Governance (Tier 2):
- Advance ADR-022 and ADR-023 from Proposed to Accepted (specs already
  depend on their types as source of truth)
- Amend ADR-015: mark Decision 3 and Assumption 6 as superseded by ADR-022;
  update handler_identity type to CompositionAuthority
- Amend ADR-002: note handle() signature revised by ADR-007 (BiStream → Connection)
- Amend ADR-004: note 'enrich/replace' AuthContext language superseded by
  ADR-011's immutability model; update to describe set_identity on Connection
- Update main README ADR table to show ADR-022/023 as Accepted

Spec-ADR consistency (Tier 3):
- Add abort_policy: AbortPolicy field to OperationContext struct (ADR-016
  Decision 6 mandated this but the spec omitted it)
- Define AbortPolicy enum (AbortDependents | ContinueRunning) with Default impl
- Add abort_policy to build_root_context and LocalOperationEnv::invoke()
- Define the OperationEnv trait explicitly with invoke() and
  invoke_with_policy() methods (was referenced as 'must remain a trait'
  but never defined)
- Specify From<StreamError> for HandlerError impl with exact variant mapping
- Add Connection::from_quinn() / from_iroh() constructors (was referenced
  as Connection::new() but never defined)
- Remove undefined CertAuthorityEntry placeholder from AuthPolicy v1 (will
  be added additively when alknet-ssh lands)
- Fix config.md key-differences table: rate limits are in DynamicConfig,
  not StaticConfig

Mechanical fixes (Tier 1):
- overview.md: 'closes the QUIC stream' → 'closes the connection' (stale
  from pre-ADR-007 model)
- overview.md: OQ-04 entry updated from stale 'defer to implementation'
  to 'resolved: static at startup'
- mnemonic-derivation.md: remove duplicate helper functions block (incomplete
  first copy, complete second copy)
- ADR-003: add iroh (feature-gated) to alknet-core dependency list, added
  by ADR-010
- ADR-021: fix ambiguous 'W1 drift issue from the vault review' cross-reference
- ADR-022: rephrase FromCall 'leaf locally' to 'leaf in the local registry'
- ADR-017: add error_schemas to from_call mirror list and services/schema
  step (inconsistency with ADR-023)
- ADR-016: fix self-referential citation ('ADR-016 Assumption 5' → 'Assumption 5')
- Add ScopedOperationEnv::empty(), allows(), new() and
  CompositionAuthority::none(), new() impl blocks (referenced but undefined)
- Add call.completed clarification for non-subscription calls
- Add services/schema leading-slash normalization note
- Crate README ADR tables: add missing ADR-013 (call), ADR-015 (core),
  ADR-006 + ADR-010 (vault)
- Vault README: add consolidated 'Known Source Drift' table tracking all
  four drift items (OsRng, unwrap, CURRENT_KEY_VERSION, spawn bug) in one
  place, including the two previously missing from README
2026-06-22 05:46:37 +00:00

4.2 KiB

ADR-002: ProtocolHandler Trait

Status

Accepted

Context

The previous architecture had two separate interface traits: StreamInterface (for byte-stream protocols like SSH, raw TCP) and MessageInterface (for message-based protocols like DNS, HTTP). This split created complexity — each interface type needed its own listener configuration, its own dispatch path, and its own framing assumptions. The ListenerConfig enum had three variants. The server accept loop handled three different listener types.

In practice, the distinction between "stream" and "message" protocols is artificial at the handler level. SSH starts as a byte stream but internally multiplexes channels and messages. DNS over QUIC is message-based but arrives as a stream of frames. HTTP/2 is both — bidirectional streams with message semantics. Every protocol can be modeled as "receive a byte stream, manage your own wire format."

iroh's ProtocolHandler trait demonstrates this: it takes a bidirectional QUIC stream and the handler is responsible for its own protocol. One trait, one dispatch point.

Decision

A single ProtocolHandler trait replaces both StreamInterface and MessageInterface:

Note

: The signature below was revised by ADR-007. The handle() method now receives a Connection (not a BiStream) — see ADR-007 for the current authoritative signature. The original signature is retained here for historical context.

#[async_trait]
pub trait ProtocolHandler: Send + Sync + 'static {
    /// The ALPN string this handler claims (e.g. b"alknet/ssh")
    fn alpn(&self) -> &'static [u8];

    /// Handle an incoming connection (revised by ADR-007 to receive
    /// `Connection` instead of `BiStream`)
    async fn handle(&self, connection: Connection, auth: &AuthContext) -> Result<(), HandlerError>;
}
  • alpn() returns a static byte string — the handler's ALPN identifier
  • handle() receives a Connection (revised by ADR-007 from the original BiStream) and an AuthContext carrying the authenticated identity, and returns HandlerError on failure
  • Every handler manages its own wire format — no shared framing, no StreamInterface/MessageInterface split
  • The ListenerConfig enum is eliminated — ALPN advertisement configuration replaces it

AuthContext resolution is hybrid (see ADR-004, OQ-02 resolution): the endpoint resolves what it can before calling handle() (e.g., TLS client certificate fingerprint), and the handler resolves what it must inside handle() (e.g., AuthToken in the first frame of a call stream). The AuthContext passed to handle() may contain partial identity information — the handler is responsible for completing authentication if the endpoint didn't have enough information.

Consequences

Positive:

  • One trait, one dispatch point — eliminates the StreamInterface/MessageInterface split and ListenerConfig enum
  • Each handler owns its wire format — no shared framing assumptions that constrain protocol design
  • Adding a new protocol is implementing one trait with two methods
  • Testable in isolation — give a handler a mock BiStream and AuthContext
  • WASM-compatible in principle — handlers that don't need tokio runtime features compile to WASM

Negative:

  • Every handler must implement its own framing — no shared "read a length-prefixed message" utility (mitigated: common utilities can live in alknet-core without mandating their use)
  • Handlers that want message semantics must build them (mitigated: alknet-call provides this as a handler, not a mandatory layer)
  • AuthContext resolution is hybrid — the endpoint resolves what it can (TLS-level auth), but handlers that need protocol-level credential extraction must do so inside handle(). This means AuthContext may be partial when handle() is called. Handlers must not assume AuthContext is fully resolved.

References

  • Pivot proposal: docs/research/pivot/alpn-service-architecture.md
  • ADR-001: ALPN-based protocol dispatch
  • ADR-004: Auth as shared core (IdentityProvider)
  • ADR-007: BiStream type definition (revised this ADR's signature from BiStream to Connection)
  • iroh ProtocolHandler pattern: docs/research/references/iroh/
  • Replaces StreamInterface, MessageInterface, and ListenerConfig