did-btcr2-js

ADR 020: Aggregation Layered Architecture (Facade + State Machine + Transport)

Status: Accepted

Date: 2026-04-06

Branch / PR: beacon-system

Context

The aggregation subsystem in @did-btcr2/method implements multi-party SMT beacon coordination: a group of DID controllers agree on an aggregated MuSig2 public key (a taproot Bitcoin address), collect update announcements from their members, build a Sparse Merkle Tree root, and jointly sign a single Bitcoin transaction that anchors the root on-chain. This is the most complex protocol in the codebase: it involves identity management, message routing, key aggregation, nonce exchange, and threshold signing.

An earlier attempt put all of this behind a single AggregationCoordinator / AggregationParticipant API modeled after the Resolver state machine (see ADR 016). The reasoning was “we already have a sans-I/O state machine pattern, so use it again.” In practice this produced a terrible API:

The user feedback was: “matching the aggregation state machine to the resolver state machine EXACTLY one-for-one feels like the wrong engineering decision. I don’t want to fall into the trap of ‘everything is a nail because all I have is a hammer.’”

The rewrite goal was to keep the sans-I/O state machine for power users and correctness, but provide a much more intuitive default API that hides the boilerplate.

Decision

We adopted a three-layer architecture for the aggregation subsystem:

Layer 1: Transport (pluggable)

The Transport interface abstracts on-the-wire message delivery between participants. It takes no keys in its constructor (pure passthrough), exposes registerActor, registerPeer, registerMessageHandler, and sendMessage methods, and is implemented by adapters like NostrTransport (NIP-44 encrypted Nostr events) and a stub DIDCommTransport. Multi-actor transports support multiple participants running in the same process (useful for tests and demos).

Keys and identities live outside the transport, in the wrapper classes (layer 2) that register them with the transport at startup.

Layer 2: State Machine (sans-I/O, explicit actions)

Two classes live here:

Both classes are completely I/O-free. They hold in-memory protocol state and expose explicit actions. Power users who want fine-grained control can use these directly, the same way a user could drive the Resolver state machine by hand instead of using DidBtcr2Api.resolve().

Layer 3: Runner facade (default API)

Two wrapper classes provide the high-level default API:

The runner is the default API: 90% of callers will use it. They wire up a transport, pass in a few decision callbacks for business logic, and call run() or start(). Everything else (state transitions, message routing, nonce exchange, signing protocol) is handled by the runner internally.

This is the standard Facade + Strategy + Observer pattern from the Gang of Four:

It’s also the standard shape used by popular Node libraries for long-running protocol clients: http.Server, nats.connect(), WebSocket, pg.Client, mqtt.connect(), etc.

Consequences

Positive:

Negative:

Alternatives considered

Verification

References