did-btcr2-js

ADR 016: Sans-I/O Resolver State Machine

Status: Accepted

Date: 2026-03-25

Branch / PR: refactor/resolve-session

Context

The original DidBtcr2.resolve() implementation was a monolithic async function that directly performed:

This shape had several concrete problems:

  1. Untestable without mocks. Every test had to stub Bitcoin clients, HTTP executors, IPFS nodes, and then assert against mock call sequences. The mocks tended to diverge from real behavior over time.

  2. Not browser-safe. Helia imports dragged in libp2p and native modules that can’t run in a browser. Consumers wanting to resolve DIDs in the browser had to reimplement resolution themselves.

  3. Not reproducible. Given the same DID and the same on-chain data, the resolver’s output depended on which CAS gateway happened to respond first, whether a connection timed out, and other non-deterministic network effects.

  4. Violated the sans-I/O spec model. The did:btcr2 spec itself is written as a deterministic algorithm that takes a DID and a bundle of already-fetched data (genesis doc, beacon signals, CAS announcements, updates) and produces a resolution result. The implementation was conflating “fetching data” with “applying the algorithm.”

The refactor goal was to make DidBtcr2.resolve() return a state machine that the caller drives: one that embodies exactly the spec algorithm, with zero I/O.

Decision

We rewrote DidBtcr2.resolve() to return a Resolver instance that contains a pure state machine. The state machine is a synchronous, iterative function that progresses through five phases:

GenesisDocument to BeaconDiscovery to BeaconProcess to ApplyUpdates to Complete

At each step, if the state machine needs data it doesn’t have, it emits a typed DataNeed request and suspends. The caller reads the requests, fetches the data via whatever I/O mechanism they choose, and passes the results back via resolver.provide(need, data). Then the caller calls resolver.resolve() again, which picks up where it left off.

The DataNeed discriminated union is:

The caller’s loop looks like:

const resolver = DidBtcr2.resolve(did, options);
let state = resolver.resolve();

while (state.status === 'action-required') {
  for (const need of state.needs) {
    const data = await fetchData(need);   // caller's I/O: could be HTTP, worker message, test fixture, anything
    resolver.provide(need, data);
  }
  state = resolver.resolve();
}

return state.result;  // state.status === 'resolved'

Three significant design choices inside the state machine:

  1. Multi-round beacon discovery. After applying updates, the state machine loops back to BeaconDiscovery to find any new beacon services added by those updates. This is bounded by a planned maxDiscoveryRounds safety cap (see the follow-ups below).

  2. Sidecar data bundle. The ResolutionOptions.sidecar field lets the caller pre-populate the state machine with data it already has (e.g., a signed update fetched out-of-band). The state machine checks the sidecar first before emitting a DataNeed, which means callers that already have the full data bundle never see any DataNeed requests at all.

  3. Structural equality for provide(). The provide() method accepts a DataNeed and its corresponding data. To match needs across reads, the caller doesn’t need to keep the exact DataNeed object reference: they just need to supply the same discriminant + identifier (e.g., the same updateHash). The state machine looks up pending needs by hash.

Consequences

Positive:

Negative:

Alternatives considered

Verification

Follow-ups

Planned future work to harden the resolver:

References