did-btcr2-js

ADR 068: versionTime Evaluation Order for Duplicates and Guarded Duplicate Confirmation

Status: Accepted

Date: 2026-07-06

Branch / PR: fix/resolver-duplicate-edges

References: ADR 055, ADR 060, ADR 067, did:btcr2 Resolve, W3C DID Resolution

Context

ADR 067 fixed how a confirmed duplicate update affects the version counter and the update-hash history, and recorded two adjacent pre-existing edges as out of scope. This ADR closes both. Each was reproduced empirically against the published build before being fixed, and each traces back to the specification text, so both extend the erratum conversation ADR 067 opened.

Edge 1: an out-of-order duplicate truncates a versionTime query

The read algorithm sorts the updates array by targetVersionId first, block height second. A duplicate re-announcement of an early version that was mined after the queried versionTime therefore sorts ahead of a genuine later update mined before it. The spec’s “Process updates Array” evaluates the versionTime early-return as step 3, before step 4 dispatches to duplicate-confirmation / apply / late-publishing, so the over-window duplicate ends resolution before the genuine in-window update is ever processed.

Reproduced trace: version 2 applied November 2023; a duplicate of that update re-announced in 2030; a genuine version 3 mined December 2024; versionTime: 2025-01-01. The resolver returned version 2 instead of the correct 3, silently dropping the in-window update, and stamped metadata.updated with the duplicate’s 2030 blocktime, itself past the requested versionTime. No error is raised; the answer is simply wrong.

This contradicts the spec’s own definition of versionTime (“the most recent version of the DID document that was valid for the DID before the specified versionTime”): an announcement mined after the query point cannot change which versions were valid before it. The spec has no language about how versionTime interacts with duplicates; the implementation faithfully transcribed the step order and inherited the defect. The same redundancy and replay patterns that make duplicates reachable (ADR 067’s context: one update announced on two of a DID’s own derivable beacons, or a third-party OP_RETURN replay) make this reachable for any resolver offering versionTime queries.

Edge 2: duplicate confirmation crashes on a crafted targetVersionId

“Confirm Duplicate Update” indexes update_hash_history[targetVersionId - 2] with no bound check, in the spec and in this implementation. The duplicate branch admits any targetVersionId <= current_version_id, and the update data structure never restates that targetVersionId must be an integer of at least 2 (it is only implied by “MUST be one more than the versionId of the DID document being updated”). A crafted update object carrying targetVersionId: 1 (or 0, a negative, or a fractional value below the current version) enters the duplicate branch, reads an undefined history slot, and crashes the byte comparison with a raw TypeError: Cannot read properties of undefined (reading 'length') - not the typed ResolveError resolver callers handle. Reproduced with a sidecar entry plus a single beacon signal carrying the crafted update’s hash; the content-hash binding of ADR 055 is satisfied because the signal commits to the crafted update itself.

Decision

1. Confirm duplicates before evaluating versionTime

The duplicate branch now runs before the versionTime early-return. A tuple that re-announces an already-applied version is confirmed against the update-hash history and skipped whatever its blocktime; the versionTime check gates only the state-changing paths (apply and late-publishing). The reproduced trace now resolves to version 3.

The rule this encodes: versionTime is a view over the history, not an integrity waiver. Confirming over-window duplicates rather than skipping them keeps late-publishing detection intact: a false duplicate (different content claiming an already-applied version) mined after versionTime now fails resolution with LATE_PUBLISHING_ERROR, where the previous order silently hid the equivocation evidence behind the early return. That strengthening is deliberate, and it is scoped to the window: evidence that an already-applied portion of the history is equivocal taints every answer about that portion, including answers about the past. Equivocation confined entirely to announcements after versionTime (competing updates for a version never applied in the window, or an over-window gap update) remains outside the view: only tuples that reach the duplicate branch are integrity-checked past versionTime, and every other over-window tuple still ends resolution cleanly at the early return, exactly as before.

This is a deviation from the current spec step order (versionTime at step 3, dispatch at step 4), taken for the same reason as ADR 067’s increment deviation and pursued in the same erratum conversation: evaluate the versionTime return after (or scoped to exclude) the duplicate branch.

2. Guard the duplicate-confirmation history read

confirmDuplicate now validates before indexing:

Through the resolver’s own loop the slot always exists for conformant duplicates: the apply path records one history entry per version, so any integer targetVersionId between 2 and the current version indexes a recorded hash. The guard converts the crash into the typed errors the API contracts already promise.

3. Reject malformed targetVersionId at the provide() boundary

The provide() shape guard for signed updates now requires targetVersionId to be an integer of at least 2, extending the fail-fast validation of ADR 055. Sidecar-supplied updates bypass provide(), so the confirmDuplicate guard above remains the reliable last line; the boundary check just fails the interactive path earlier with the existing “not a signed BTCR2 update” error.

Verification

Six regression tests cover the reproduced traces and the guards: the over-window-duplicate versionTime query resolves to the correct version; an over-window false duplicate fails as late publishing; crafted targetVersionId values of 1 and 0.5 raise INVALID_DID_UPDATE rather than TypeError; a standalone updates() call with a counter that outruns its history raises LATE_PUBLISHING_ERROR; and provide() rejects the malformed update at the boundary. As a negative control the same six tests were run against the previous release’s resolver: all six fail there (wrong version, hidden equivocation, and raw TypeErrors), confirming they exercise the defects rather than passing vacuously. The full method suite passes unchanged, including the existing test that a genuine update mined after versionTime still ends resolution at the version valid before it.

Consequences

Rejected alternatives

Out of scope

Carrying metadata.updated/confirmations across discovery rounds so they track only real state changes (the follow-up recorded in ADR 067) remains open; this change does not touch the metadata stamping. The cross-round version continuity model (ADR 060) and the duplicate counter/history semantics (ADR 067) are unchanged.