Skip to main content

D96 — Ceremony-less custody: drop the hosted co-sign/enrolment URL; signer/quorum changes are the MPC API-vote model (RESOLVED)

Architecture decision record. Status, thematic clusters, and how to record a new ADR: the decision log index. Program context + the custody build ladder live in docs/tracking/multi-provider/20-custody.md (§4 the neutral abstraction; §5a the increment ladder).

The custody signer/quorum DTOs were traced from Conduit's hosted-ceremony model — a human visits a provider-hosted verificationUrl to enrol a signer or approve a quorum change, and a RosterCeremony { ceremonyId } handle carries that redirect. The S0.5 API-schema spike (spike/20-custody.md) proved that model dead for the committed custody vendor and its two generalization comparators: Utila (the chosen MPC custodian), Fireblocks, and Fordefi all sign via API-driven MPC — a transaction is initiated over the API, a policy engine evaluates a quorum, approvals are collected as an API vote (VoteOnTransactionRequest / Approve-Transactions), and an automated key-share co-signer signs. There is no URL a human visits to co-sign or to enrol a signer. The hosted-ceremony surface has no neutral representation across providers.

The decision (increment 2.2b): drop the hosted URL from the neutral contract; represent a signer/quorum change as ChangeStatus + an optional opaque approvalRequestRef. Concretely:

  • SignerAddResult drops verificationUrl?ExternalRef approvalRequestRef.
  • RosterCeremony is DELETED; promote/demote return RosterChangeResult { ExternalRef signerRef, ChangeStatus status }.
  • QuorumChangeResult drops verificationUrl{ ChangeStatus status, ?ExternalRef approvalRequestRef }.
  • ChangeStatus (new neutral enum): applied | pending_approval | rejected.
  • WalletStatus (new neutral enum): pending | active | disabled | frozen — replaces the raw status string on the Wallet DTO. Wallet.chain and RegisteredAddress.chain adopt the existing Foundation Chain enum (D68's vocabulary), not a new one.

On a pending_approval outcome the client takes no direct action — there is no URL to redirect to; it observes the mirror (wallet_signers / signing_quorums) reconcile asynchronously from provider truth (the signer.* webhook, or the D59 stale-mirror sweep where no webhook exists). The optional approvalRequestRef is an opaque provider request id the operator/reconciler can correlate against — never a client-actionable link.

Fail-loud is the safe custody posture. Chain and status mapping fails loud at the adapter boundary: an unknown provider chain throws a neutral ProviderValidationException (PROVIDER_ VALIDATION_FAILED, 422) rather than silently dropping to a default, and the SDK-status matches are exhaustive (no default arm) so a future provider status the neutral set does not model is a compile-time (PHPStan) failure, not a runtime silent-hide. Both client-input chain WRITE paths (createWallet in accounts, registerAddress in payments) validate chain via Rule::enum(Chain:: class) at the controller, so an unknown chain is a client 422, never a provider 5xx. The mirror columns stay STRING-backed (->value writes); the enums live only on the neutral DTO.

This is a reversible pre-product decision Utila inherits (2.2c). 2.2b re-derives the shapes on the still-live Conduit adapter — the Conduit hosted verificationUrl/ceremonyId are simply dropped (approvalRequestRef is null on the Conduit path, a Conduit roster change is always pending_approval). The Utila adapter (2.2c) is built directly against this ceremony-less contract. Because there is no production data or live tenant, no migration is in scope; the contract iterates freely until the v1 guard.

Documented gap (acceptable pre-product). On the still-routed Conduit path the hosted signer-enrolment / quorum-approval URL is dropped with nothing replacing it — signer self-enrolment/approval is not re-surfaced to the client until the Utila API-vote flow lands. Acceptable because no live tenant relies on it; named explicitly in the custody tracker §5b go-live gates.

This decision builds on the router substrate (D94 — the (capability group × jurisdiction) routing this custody group resolves through) and the accounts signer/quorum management ceremonies (the D64 Conduit-era Phase-14 hosted-ceremony surface it supersedes); it does not restate them.


← Decision log index