did-btcr2-js

ADR 046: Extract the Aggregation Subsystem into @did-btcr2/aggregation

Status: Accepted (implementation pending)

Date: 2026-06-24

Branch / PR: refactor/aggregation-extraction

Implementation status: This record fixes the design ahead of the move on this branch. At the time of writing the aggregation subsystem still lives under packages/method/src/core/aggregation/; the new package and the boundary changes described below are the accepted target, not yet in the code.

References: ADR 001, ADR 008, ADR 020, ADR 028, ADR 045

Context

The aggregation subsystem (packages/method/src/core/aggregation/) is the multi-party coordination layer for aggregate beacons: the AggregationService and AggregationParticipant state machines, AggregationCohort, BeaconSigningSession, the runner facades, the message factories and guards, and the transport adapters (Nostr, HTTP client and server, the in-memory bus, the DIDComm stub). It is highly cohesive and, unlike the rest of method, it is intended to run as a standalone service: aggregation servers will be long-lived processes with their own security and timing profiles operating under monetary contracts. A consumer that only needs the method’s read or single-party broadcast path should not have to pull MuSig2, Nostr, and the HTTP transport into its bundle, and an aggregation operator should be able to depend on the protocol without the rest of method.

A dependency mapping of the current tree establishes the decisive facts:

Because method’s runtime does not consume aggregation, this is a packaging move rather than an API redesign. The work is to sever the four back-edges, lift the subtree into a new package, and have method re-export it so the public surface is preserved.

Decision

1. Extract the aggregation subtree into @did-btcr2/aggregation; method re-exports it

The entire core/aggregation/ subtree moves into a new workspace package @did-btcr2/aggregation. method keeps a single export * from '@did-btcr2/aggregation' line in place of the current per-module barrel, so every aggregation symbol (AggregationService, AggregationParticipant, AggregationCohort, both runners, BeaconSigningSession, TransportFactory, the transport adapters, the message factories and guards, and the recovery, fallback, and condition types) remains importable from @did-btcr2/method unchanged. The api and cli packages import no aggregation symbols, so they are unaffected.

2. Sever the runtime DID coupling with an injected sender-pubkey resolver

The HTTP transport’s two identical Identifier.decode call sites resolve a KEY-DID sender’s public key, and each already tries a peer map first. They are replaced by an injected resolveSenderPk callback on the transport options: given a DID, the callback returns the sender’s public key or nothing. When method wires the transport it supplies a callback that decodes the identifier and returns the genesis key for a KEY-type DID; when no callback is supplied, resolution degrades to the peer-map path that already runs first. This removes the aggregation transport’s only runtime dependency on method and makes the transport DID-method-agnostic: it no longer names did:btcr2’s Identifier and could carry another DID method’s sender resolution.

3. The fee contract moves to @did-btcr2/bitcoin; CASAnnouncement stays method-owned

The two type touchpoints are resolved by ownership that follows where each type genuinely belongs, not by relocating both into aggregation.

A shared @did-btcr2/types package was considered and rejected for this extraction (see Rejected alternatives).

4. The aggregation runner builds its own default fee estimator

The runner can no longer read method’s DEFAULT_FEE_ESTIMATOR. It constructs its own default from @did-btcr2/bitcoin’s StaticFeeEstimator (the same fixed 5 sat/vB rate), so the runner keeps its feeEstimator ?? default ergonomics and existing callers need no change. method retains its own beacon-layer default for the single-party broadcast path.

5. Transport sub-entry-points are deferred

nostr-tools is imported only in transport/nostr.ts; the HTTP transport is pure crypto over the workspace packages. Splitting the package into transport/nostr and transport/http entry points so an HTTP-only consumer never pulls nostr-tools is deferred to a follow-up, because it requires reworking TransportFactory to lazily import the Nostr adapter (an eager factory that statically imports every adapter defeats the split). This extraction ships the whole transport surface from the package root and stays a pure packaging move.

6. The aggregation tests move with the code

The aggregation spec files move into the new package. They exercise aggregation logic and already import only through the public surface, so they re-point from method’s barrel to the new package’s. method keeps a single back-compat smoke test asserting that the re-export barrel resolves the aggregation symbols, which guards the preserved surface without duplicating the suite. The few method-core end-to-end specs that drive aggregation together with Resolver, Updater, and the DidBtcr2 facade keep importing both packages.

7. Build wiring and package shape

packages/aggregation is a composite TypeScript project with references to common, keypair, cryptosuite, smt, and bitcoin. Output is ESM-only (matching method, cryptosuite, api, and cli), producing dist/esm and dist/types. method’s tsconfig.json gains a reference to ../aggregation and a workspace:^ dependency on it; the root solution tsconfig.json adds the new package. After the four touchpoints are severed, tsc -b sees a clean DAG with no reference cycle.

Consequences

Rejected alternatives