Files
suaveplan/docs/network/architecture.md
2026-05-27 23:29:53 +01:00

8.3 KiB

Network Architecture

Canonical capability spec: openspec/specs/network/spec.md (NET-01..NET-125).

The five core abstractions

Abstraction Role Spec
NetworkPlugin The universal extension contract every plugin implements NET-01
NetworkNode The single entry-point — owns lifecycle, bus, router, transports NET-02
MessageEnvelope Typed wire format for every message NET-03
Transport The contract every transport plugin satisfies NET-04
NetworkBus The cross-plugin event broker (no plugin imports another plugin) NET-05

These are the only five things network-core ships. Everything else — even WebRTC — is a plugin.

NetworkPlugin (NET-01)

interface NetworkPlugin {
  readonly name: string;
  install(node: NetworkNode): Promise<void> | void;
  start(): Promise<void> | void;
  stop(): Promise<void> | void;
  dispose?(): Promise<void> | void;
}

Lifecycle is strict: install → start → ... → stop → dispose. The core calls them in that order; plugins must be idempotent across start/stop (you can restart without disposing). Plugins do not import each other. Cross-plugin communication goes through NetworkBus.

NetworkNode (NET-02)

class NetworkNode {
  use(plugin: NetworkPlugin): this;
  start(): Promise<void>;
  stop(): Promise<void>;

  send(targetId: PeerId, envelope: MessageEnvelope): Promise<void>;
  broadcast(envelope: MessageEnvelope): Promise<void>;
  on<E extends NetworkEvents>(event: E, handler: Handler<E>): () => void;

  readonly bus: NetworkBus;
  readonly router: MessageRouter;

  bootstrap(options: BootstrapOptions): Promise<void>;  // NET-113
}

The node owns plugin lifecycle, transport pool, and message routing. Sending is transport-agnostic — the node hands the envelope to whichever active transport can reach the target peer.

MessageEnvelope (NET-03)

interface MessageEnvelope {
  id: string;                    // unique
  type: string;                  // e.g. 'cd:request', 'election:vote'
  senderId: PeerId;
  targetId: PeerId | "*";        // "*" = broadcast
  payload: Uint8Array;           // already-encoded; protocol plugin handles serialisation
  timestamp: number;             // injectable Clock
  headers: Record<string, string>;
}

Validated via Zod before routing. The 13-byte binary header from network-plugin-protocol (NET-15) prefixes this when serialising for the wire: version(1) | flags(1) | messageType(1) | timestamp(8) | payloadLength(2).

Transport (NET-04)

interface Transport {
  connect(peerId: PeerId, address: string): Promise<void>;
  disconnect(peerId: PeerId): Promise<void>;
  send(peerId: PeerId, envelope: MessageEnvelope): Promise<void>;
  onMessage(handler: (env: MessageEnvelope) => void): () => void;
  getConnectedPeers(): PeerId[];
}

Concrete transports: WebRTC (network-plugin-webrtc), WebSocket (network-websockets), HTTPS (planned), libp2p (network-libp2p — pending), Hyperswarm (network-hyperswarm — pending), Trystero (network-discovery-trystero — pending), GunDB (network-discovery-gundb — pending), MemoryTransport (test-only, in network-testing).

network-plugin-transport-manager (NET-23..NET-26) maintains multiple active transports per peer simultaneously, exchanges capability bitmaps during handshake (NET-24), and routes messages by priority across them (NET-25). CRITICAL messages may go redundantly across two transports; BULK uses backpressure-aware selection.

NetworkBus (NET-05)

Typed pub/sub with FIFO delivery, bounded-queue backpressure, and interceptor support. Topics are constants from the frozen NetworkEvents enum (NET-55):

node:created, node:connected, node:disconnected, node:updated, node:removed,
message:sent, message:received, message:routed, message:dropped,
hierarchy:changed, hierarchy:parent-changed, hierarchy:child-added, ...,
plugin:registered, plugin:unregistered,
peer:joined, peer:left, peer:updated, peer:latency,
transport:connected, transport:disconnected,
election:vote, election:completed, election:cancelled,
failover:triggered, failover:completed,
topology:changed, latency:rebalance,
cd:advertise, cd:request, cd:response,
partition:detected, partition:healed,
qos:congestion, monitor:alert, system:error

Plugins subscribe; plugins publish; the bus enforces ordering and backpressure. No plugin ever imports another plugin's code. That is the wire that lets network-suite compose 21 plugins without circular deps.

MessageRouter

Owns:

  • Per-message type → handler dispatch.
  • TTL decrement (NET-58); drop when ttl <= 0.
  • Loop detection via visitedNodes header (NET-58).
  • Broadcast deduplication via messageId bloom filter.
  • Route caching for frequently-used (source, target) → nextHop mappings (NET-59); invalidated on topology:changed.

How a plugin actually talks to another plugin

Concrete example — drive-mesh wants a chunk from a peer:

drive-mesh.fetchChunk(fileId, chunkId)
   ↓
networkNode.bus.publish('cd:request', { fileId, chunkId, requesterId })
   ↓
network-plugin-content-delivery.onCdRequest(...)
   ↓
   resolves a peer holding the chunk via gossip index
   ↓
networkNode.send(holderPeerId, { type: 'cd:fetch', payload: { fileId, chunkId } })
   ↓
   (transport-manager picks WebRTC, WebSocket, or HTTPS based on capability negotiation)
   ↓
   holder runs network-plugin-content-delivery.onCdFetch and sends back chunk bytes
   ↓
networkNode.bus.publish('cd:response', { fileId, chunkId, bytes })
   ↓
drive-mesh.onCdResponse(...) → verify sig + AEAD via chunks plugin → return to caller

Three plugins coordinated; none imported any other; all communication via bus topics. Replace network-plugin-content-delivery with a different chunk-distribution implementation and the rest of the chain doesn't notice.

BootstrapOptions (NET-113, NET-114)

interface BootstrapOptions {
  mode: 'cold' | 'warm';
  initialPeers?: PeerAddress[];
  bootstrapServers?: string[];
  connectionConcurrency?: number;
  discoveryTimeout?: number;
  prioritizeLocalNetwork?: boolean;
}

NetworkNode.bootstrap(options) brings up the node from a clean state — does discovery, opens initial connections, and (with ConnectionPreWarmer) pre-opens transports to known peers before they are needed.

PluginRegistry and PluginDiscovery (NET-116)

class PluginRegistry {
  register(plugin: NetworkPlugin): void;
  unregister(name: string): void;
  has(name: string): boolean;
  get(name: string): NetworkPlugin | undefined;
  list(): NetworkPlugin[];
}

class PluginDiscovery {
  findPluginsByCapability(capability: string): NetworkPlugin[];
}

Runtime plugin discovery and registration — the same pattern the rest of the monorepo uses (storage factory, db factory, etc.).

TransportTester PDK (NET-115)

Third-party transport plugins are validated by a conformance harness:

class TransportTester {
  testBasicFunctionality(transport: Transport): Promise<TestReport>;
  testReliability(transport: Transport): Promise<TestReport>;
  testBackpressure(transport: Transport): Promise<TestReport>;
  testReconnection(transport: Transport): Promise<TestReport>;
}

Anyone shipping a custom transport (e.g., a corporate VPN tunnel transport) runs TransportTester against it before registering with network-plugin-transport-manager. This is what makes "swap WebRTC for $YOUR_TRANSPORT" a one-line change.

Why this shape

The core's job is to do as little as possible. It owns plugin lifecycle, the bus, the router, the envelope shape, and the transport contract — and stops. Every protocol decision (binary header layout, encryption, signing, election algorithm, partition recovery) lives in a plugin where it can be replaced.

This is the lesson the old planning corpus was groping toward but never quite landed: a "core + plugins" model where the core is genuinely small and the plugins genuinely don't depend on each other. The network-suite change folder finally delivers it; the canonical spec encodes it; this is the design genesis ships.

Reading further