did-btcr2-js

ADR 071: A CAS Publication Policy for the API Update Path (publishToCas), Writable-CAS Capability Detection, and Enriched Update Results

Status: Accepted

Date: 2026-07-07

Branch / PR: feat/cas-first-broadcast

References: ADR 023, ADR 069, ADR 070

Context

The api package’s read path already treats a CAS as a first-class resolution source: DidMethodApi.resolve fulfills NeedGenesisDocument, NeedCASAnnouncement, and NeedSignedUpdate by fetching canonical JCS blocks from the configured CasApi. The write path, however, was fully unwired:

The did:btcr2 spec does not require CAS publication (sidecar-only distribution is expressly permitted), so the wiring must be optional and policy-driven, not mandatory.

Decision

  1. CasExecutor gains an optional capability flag, canPublish?: boolean, where undefined MUST be treated as true. Existing custom executors remain writable-by-default with no code change. The read-only HttpGatewayCasExecutor declares canPublish = false. CasApi exposes the derived writable getter.

  2. DidMethodApi.update (and UpdateBuilder, and DidBtcr2Api.updateDid) gain publishToCas: 'auto' | 'always' | 'never', defaulting to 'auto':

    Policy Writable CAS Read-only / no CAS
    'auto' publish update (+ announcement for CAS beacons) Singleton/SMT: skip silently. CAS beacon: throw up-front
    'always' publish update (+ announcement for CAS beacons) throw up-front, all beacon types
    'never' publish nothing publish nothing

    The CAS-beacon asymmetry under 'auto' is deliberate: a Singleton or SMT update that skips CAS publication is still resolvable via sidecar with no surprises, but a CAS beacon signal points at an announcement that must be retrievable somewhere, so silently publishing it nowhere manufactures resolution failures. Callers who intend sidecar-only distribution state it explicitly with 'never' and receive the announcement in the result. The policy check runs before the update is constructed or signed, so a misconfiguration costs nothing.

  3. Publication order is update, then announcement, then transaction broadcast. The api publishes the canonical signed update, hands the beacon a casPublish callback (CAS beacons only) that publishes the announcement, and only then does the beacon spend the UTXO (pre-spend ordering per ADR 070). Content addressing makes every step idempotent under retry.

  4. update() returns a DidUpdateResult instead of the bare SignedBTCR2Update: { signedUpdate, txid, announcement?, proof?, publishedToCas: { update, announcement } }. Sidecar-only users capture the artifacts they must distribute; auditing callers see exactly what reached the CAS.

  5. broadcastOptions (fee estimator, change address) pass through update(), UpdateBuilder.broadcastOptions(), and updateDid() to the beacon transaction, closing the api-cannot-set-fees gap.

  6. resolve() handles NeedSMTProof and unknown needs by failing fast. SMT proofs are nonce-blinded and not content-addressed by anything on-chain, so no CAS fetch can fulfill the need; the error directs the caller to options.sidecar.smtProofs. A default case guards against future need kinds an older api cannot fulfill. Both replace an infinite loop.

  7. The cli passes publishToCas: 'never' explicitly. Its CAS configuration is currently gateway-only (read-only), so 'auto' would make CAS-beacon updates fail with advice (set 'never') the cli offers no flag for yet. With 'never', cli CAS-beacon updates keep working sidecar-only and now print the announcement, txid, and proof for manual distribution. A follow-up change adds writable-CAS configuration and a --publish-to-cas flag, at which point the explicit 'never' is replaced by the exposed knob.

Consequences

Rejected alternatives