docs(architecture): resolve one-way doors, clean up Phase 0 specs

Resolve blocking one-way door decisions:
- ADR-007: BiStream is a trait, handlers receive Connection not BiStream
- ADR-008: Secret service is CLI-embedded, exposed via call protocol
- ADR-009: One-way door decision framework (classify by reversal cost)

Update existing documents:
- overview.md: add design principles, revise ProtocolHandler signature,
  update shared types, add WASM as design constraint
- open-questions.md: add door-type classifications, resolve OQ-01/OQ-08,
  move OQ-09/OQ-10 to deferred section, mark two-way doors as impl-deferred
- README.md: reflect resolved questions, remove crate spec stubs from index
- ADR-002: cross-reference ADR-007 for signature revision

Clean up premature artifacts:
- Remove 11 empty crate spec stubs (16-28 lines each, no unique content)
- Specs will be created when each crate enters Phase 1
This commit is contained in:
2026-06-16 10:43:31 +00:00
parent f77b515968
commit b47a6fe70b
18 changed files with 357 additions and 348 deletions

View File

@@ -53,5 +53,6 @@ pub trait ProtocolHandler: Send + Sync + 'static {
- 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

View File

@@ -0,0 +1,119 @@
# ADR-007: BiStream Type Definition
## Status
Accepted
## Context
OQ-01 asked whether BiStream should be a concrete type wrapping quinn's `SendStream` + `RecvStream`, a trait `BiStream: AsyncRead + AsyncWrite + Send + Unpin`, or a type alias/newtype. This is a one-way door decision: if BiStream is a concrete type bound to a specific QUIC library, WASM targets and alternative transports cannot implement it. If it's a trait, the door stays open.
### iroh's pattern
iroh's `ProtocolHandler::accept` receives a `Connection`, not a stream. The handler calls `connection.accept_bi()` to get `(SendStream, RecvStream)` pairs. This means iroh's handlers own the entire connection lifecycle and can open/accept multiple streams.
### Alknet's pattern differs
Alknet's handlers are different from iroh's for two reasons:
1. **One ALPN per connection** (ADR-006). An incoming connection is already dispatched to exactly one handler by ALPN. The handler receives the connection and can manage streams however it wants.
2. **Some handlers need connection-level ownership**. SSH multiplexes channels over multiple streams within a single connection. The call protocol opens a new stream per operation. These handlers need the connection, not just a single stream.
### WASM constraint
If alknet-core defines BiStream as `quinn::SendStream + quinn::RecvStream` joined via `tokio::io::join`, then:
- WASM targets cannot implement it (quinn doesn't compile to WASM)
- WebTransport clients in browsers cannot participate as full peers
- The cost of making BiStream a trait later would require changing every handler's signature
If BiStream is a trait, WASM targets implement it over WebTransport streams. Native targets implement it over quinn streams. The cost is minimal — a trait vs a concrete type adds a small amount of indirection and trait object overhead that is negligible compared to I/O latency.
### Testing constraint
A BiStream trait allows test implementations (in-memory channels, mock streams) without requiring a running QUIC connection. A concrete quinn type requires mocking at a higher level (connection mocking) which is more complex.
## Decision
### BiStream is a trait
```rust
pub trait BiStream: AsyncRead + AsyncWrite + Send + Unpin {}
```
Handlers receive a `Connection` (not a single BiStream) in their `handle` method. This differs from the original ADR-002 signature and aligns with iroh's proven pattern.
### Revised ProtocolHandler signature
```rust
#[async_trait]
pub trait ProtocolHandler: Send + Sync + 'static {
fn alpn(&self) -> &'static [u8];
async fn handle(&self, connection: Connection, auth: &AuthContext) -> Result<(), HandlerError>;
}
```
Where `Connection` wraps a QUIC connection (or, in test contexts, a mock) and provides:
```rust
pub struct Connection {
// Private: wraps the underlying QUIC connection or test mock
}
impl Connection {
pub async fn accept_bi(&self) -> Result<(SendStream, RecvStream), StreamError>;
pub async fn open_bi(&self) -> Result<(SendStream, RecvStream), StreamError>;
pub fn remote_alpn(&self) -> &[u8];
// Additional methods as needed: close, remote_addr, etc.
}
```
`SendStream` and `RecvStream` are concrete types that implement `AsyncWrite` and `AsyncRead` respectively. They wrap the underlying QUIC stream types.
### Why Connection, not BiStream, as the handler parameter
The original ADR-002 specified `handle(&self, stream: BiStream, auth: &AuthContext)`. This was modeled on the idea that a handler receives a single bidirectional stream. But:
- **SSH** needs to open/accept multiple streams (channels) on one connection
- **Call protocol** opens a new stream per operation
- **HTTP** maps requests to streams within an HTTP/2 or HTTP/3 connection
- **iroh** already uses this pattern successfully
Passing a single BiStream would force handlers that need multiple streams to somehow obtain the Connection through other means, which is awkward. Passing the Connection directly is simpler and more flexible.
Handlers that only need a single stream (simple protocols) call `connection.accept_bi().await` once and work with that stream. Handlers that need multiple streams (SSH, call) use the Connection to open/accept as needed.
### Why BiStream is still defined as a trait
Even though handlers receive a `Connection` rather than a single `BiStream`, the BiStream trait is still useful:
1. **Client-side**: A client connecting to an alknet endpoint needs a way to represent "I have a bidirectional stream to speak my protocol on." That stream should be implementable over WebTransport in WASM.
2. **Testing**: Mock BiStream implementations for unit tests.
3. **Portability**: If alknet later supports transports other than QUIC (raw TCP, iroh P2P), those transports need to produce BiStream-compatible streams.
The BiStream trait is a thin convenience — `AsyncRead + AsyncWrite + Send + Unpin` — that can be implemented by any byte transport. It does not mandate tokio or quinn.
## Consequences
**Positive:**
- WASM door stays open: browser clients can implement BiStream over WebTransport streams
- Testing is straightforward: mock BiStream implementations without QUIC infrastructure
- Handlers that need multiple streams (SSH, call) have direct access to the Connection
- Handlers that need a single stream call `accept_bi()` once — simple case stays simple
- Aligns with iroh's proven ProtocolHandler pattern
- Alternative transports (TCP, iroh P2P) can implement Connection and BiStream traits
**Negative:**
- Slight runtime overhead from trait dispatch vs concrete types (negligible compared to I/O)
- Two concepts (Connection and BiStream) instead of one (BiStream alone) — more types in alknet-core
- ADR-002's `handle` signature changes from `(BiStream, AuthContext)` to `(Connection, AuthContext)` — this is a revision to the original trait signature
- Handlers must call `accept_bi()` explicitly even for simple protocols — one additional line of code per handler
## References
- ADR-002: ProtocolHandler trait (signature revised by this ADR)
- ADR-003: Crate decomposition
- ADR-006: ALPN string convention and connection model
- OQ-01: BiStream type definition (resolved by this ADR)
- iroh ProtocolHandler pattern: `docs/research/references/iroh/iroh/`
- Pivot proposal: `docs/research/pivot/alpn-service-architecture.md`

View File

@@ -0,0 +1,63 @@
# ADR-008: Secret Service Integration Point
## Status
Accepted
## Context
alknet-secret is a standalone crate with zero alknet crate dependencies. It provides BIP39 mnemonic generation, SLIP-0010 Ed25519 HD key derivation, AES-256-GCM encryption, and an irpc-based `SecretProtocol` service. It is already implemented and stable.
The question (OQ-08) is: how does the rest of the alknet system access alknet-secret's capabilities? The options are:
1. **irpc service over `alknet/call`**: Other services call SecretProtocol operations through the call protocol on `alknet/call`. The secret service is just another set of operations registered in the call protocol's operation registry.
2. **ALPN handler on `alknet/secret`**: alknet-secret implements `ProtocolHandler` and gets its own ALPN. Remote nodes call it over a dedicated QUIC connection.
3. **Direct library dependency**: alknet-core or handler crates depend on alknet-secret directly, breaking its independence.
4. **CLI-embedded with call protocol exposure**: The CLI binary instantiates SecretServiceHandle locally and registers secret operations in the call protocol's registry.
This is a one-way door because if alknet-secret gets pulled into alknet-core as a dependency, its independence is permanently lost. The standalone property is valuable — alknet-secret has no QUIC, no tokio runtime requirement (the handle works without it), and no alknet crate dependencies. It can be used in contexts where QUIC networking doesn't exist (CLI tools, test harnesses, WASM key derivation).
## Decision
**Option 4: CLI-embedded with call protocol exposure.**
The CLI binary (the `alknet` crate) is the integration point. It:
1. Instantiates `SecretServiceHandle` locally at startup (or on-demand with Unlock/Lock lifecycle).
2. Registers secret operations (DeriveEd25519, DeriveEncryptionKey, Encrypt, Decrypt, etc.) in the call protocol's operation registry.
3. Other handlers access secret capabilities by calling operations on `alknet/call` — they don't import alknet-secret directly.
alknet-secret remains standalone with no alknet crate dependencies. Its `SecretServiceHandle` is used directly (in-process, no serialization) by the CLI binary. Its `SecretProtocol` irpc service is available for remote access through the call protocol.
**alknet-secret does NOT get its own ALPN.** Here's why:
- `alknet/secret` as a separate ALPN would mean a remote node opens a QUIC connection to access key derivation — this is architecturally wrong. Key derivation is a local operation that should never cross the network in its raw form.
- If a remote node needs derived keys (e.g., for end-to-end encryption), the local node derives them and sends only the public component over `alknet/call` — never the seed or private key.
- The secret service's Unlock/Lock lifecycle (holding the master seed in RAM) is inherently local. There's no safe way to expose Unlock/Lock over the network.
**What if a handler needs a key at runtime?** The handler calls through the call protocol. The CLI registers secret operations in the call registry at startup. The call protocol routes the request to the locally-running SecretServiceHandle. No handler crate depends on alknet-secret.
## Consequences
**Positive:**
- alknet-secret remains fully standalone — no QUIC dependency, no tokio runtime requirement for the handle
- Key derivation and encryption are local-only by default — the master seed never leaves the node
- Remote access to public key material (not secrets) flows through the existing call protocol — no separate ALPN needed
- The CLI binary is the single integration point — clean dependency graph, no circular dependencies
- The `SecretServiceHandle` is used in-process with zero serialization overhead — direct method calls, not irpc messages
- Test harnesses can use `SecretServiceHandle` directly without any QUIC infrastructure
**Negative:**
- Handlers that need keys must go through the call protocol — this adds a hop even for local calls (mitigated: local call protocol calls can be short-circuited to direct method calls via irpc's local dispatch)
- The CLI binary has a larger dependency tree since it imports both alknet-call and alknet-secret (expected: the CLI assembles everything)
- If the call protocol is not yet running when a handler needs a key, the handler must wait for initialization (mitigated: the CLI starts SecretServiceHandle before accepting connections)
## References
- ADR-003: Crate decomposition (alknet-secret is standalone)
- ADR-005: irpc as call protocol foundation
- OQ-08: Secret service integration point (resolved by this ADR)
- alknet-secret implementation: `crates/alknet-secret/`

View File

@@ -0,0 +1,64 @@
# ADR-009: One-Way Door Decision Framework
## Status
Accepted
## Context
Not all architectural decisions carry the same reversal cost. Some decisions are easy to change later — if you pick the wrong data structure, you refactor. Other decisions are nearly impossible to reverse — if you build a type hierarchy that forecloses WASM compatibility, every handler written against that hierarchy must be rewritten.
This distinction matters especially during Phase 0 (exploration) and early Phase 1 (architecture). The project is post-pivot with foundational ADRs in place but no implementation code yet (except alknet-secret). Decisions made now shape the API surface that every handler depends on.
Without an explicit framework, one-way doors can be treated as casually as two-way doors, leading to costly rework. Or conversely, two-way doors can be over-analyzed, blocking progress on decisions that are cheap to reverse.
## Decision
### Classification
Every architectural decision is classified as one of:
**One-way door** — Reversing this decision requires rewriting significant code across multiple crates or permanently closes a capability door. Examples:
- BiStream as a concrete quinn type (closes WASM door permanently)
- alknet-secret pulled into alknet-core as a dependency (loses standalone property permanently)
- ProtocolHandler signature changes (every handler must be rewritten)
**Two-way door** — Reversing this decision is cheap or additive. Examples:
- Static vs dynamic handler registration (can add ArcSwap later)
- Single transport vs multi-transport endpoint (can add transport trait later)
- Call protocol stream model (can add multiplexing later)
### Process
- **One-way doors** require an ADR before implementation. If the right choice is unclear, validate with a POC before writing the ADR. If a POC can't resolve the uncertainty within a reasonable timebox, default to the option that keeps more doors open.
- **Two-way doors** can be decided during implementation. Start with the simplest option and add complexity when needed. Note the decision in a commit message or a brief ADR if the context is worth capturing, but don't block on it.
- When in doubt, classify up. If it's unclear whether a door is one-way or two-way, treat it as one-way until proven otherwise.
### WASM as a design constraint
WASM compatibility is not an immediate implementation goal, but it is a **design constraint on one-way doors**. Decisions that would permanently prevent WASM targets from participating as peers require explicit justification. This means:
- Core types (BiStream, ProtocolHandler, AuthContext) must not assume tokio or quinn
- Protocol parsers that are pure data transformations should remain transport-agnostic
- The cost of keeping the WASM door open is low (trait vs concrete type, abstracted I/O) and the cost of closing it is high (impossible to reverse without rewriting every handler)
This is not "WASM support now." It's "don't close the WASM door accidentally."
## Consequences
**Positive:**
- One-way doors get the deliberation they deserve — ADRs, POCs, explicit justification
- Two-way doors don't block progress — start simple, add complexity when needed
- WASM compatibility is preserved as a constraint, not treated as an active deliverable
- The framework creates a shared vocabulary for discussing decision urgency ("is this a one-way door?")
**Negative:**
- Classification requires judgment — some decisions are genuinely ambiguous (mitigated: classify up when in doubt)
- POC timeboxing can feel constraining on genuine hard problems (mitigated: the timebox is "reasonable," not "arbitrary")
- The framework adds a step to every architectural discussion ("is this one-way or two-way?") — but this step is fast and prevents expensive mistakes
## References
- ADR-007: BiStream type definition (one-way door: WASM compatibility)
- ADR-008: Secret service integration point (one-way door: standalone crate independence)
- SDD process: `docs/sdd_process.md` (Phase 0 exploration, POC specialist)
- Pivot proposal: `docs/research/pivot/alpn-service-architecture.md`