ADR-003: protocol: and client_type: as orthogonal dimensions

Status: Accepted


Context

Safire::Client needs to support multiple healthcare authorization protocols (SMART, UDAP) and, within SMART, multiple client authentication methods (public, confidential symmetric, confidential asymmetric). There are two ways to model this:

Option A — flat enum: a single parameter enumerating every combination.

Safire::Client.new(config, auth: :smart_public)
Safire::Client.new(config, auth: :smart_confidential_symmetric)
Safire::Client.new(config, auth: :udap_b2b)

Option B — two orthogonal keyword arguments: one for the protocol, one for the client type within that protocol.

Safire::Client.new(config, protocol: :smart, client_type: :public)
Safire::Client.new(config, protocol: :smart, client_type: :confidential_symmetric)
Safire::Client.new(config, protocol: :udap)  # client_type not applicable

The key structural difference: SMART has three client authentication methods; UDAP has none — UDAP always authenticates via signed JWT assertions (AnT) with an X.509 certificate chain, and this is not user-configurable. Mixing them into one flat enum would create invalid combinations (:udap_public, :udap_confidential_symmetric) and make client_type= mutation impossible to express cleanly.


Decision

Two orthogonal keyword arguments: protocol: selects the protocol implementation class; client_type: is a SMART-specific parameter that controls the token endpoint authentication method.

VALID_PROTOCOLS = %i[smart udap].freeze

PROTOCOL_CLIENT_TYPES = {
  smart: %i[public confidential_symmetric confidential_asymmetric],
  udap:  nil   # not user-configurable; AnT with x5c always used
}.freeze
  • protocol: is validated against VALID_PROTOCOLS; an unknown protocol raises ConfigurationError
  • client_type: is validated against PROTOCOL_CLIENT_TYPES[@protocol]; if nil (UDAP), validation is skipped and the setter logs a warning and no-ops rather than raising
  • Changing client_type= on a SMART client updates the underlying protocol client in place — already-fetched server metadata is preserved and no re-discovery occurs

Consequences

Benefits:

  • No invalid combinations — UDAP has no client type choices at all; this is enforced at the type level, not with runtime checks
  • client_type= mutation is clean and natural for the “discover first, then select client type” pattern
  • Adding a new SMART client type requires only adding a symbol to PROTOCOL_CLIENT_TYPES[:smart]
  • Adding a new protocol requires adding a class to PROTOCOL_CLASSES and an entry to PROTOCOL_CLIENT_TYPES

Trade-offs:

  • Two keyword args instead of one — a caller needs to know which dimension belongs to which kwarg; mitigated by clear documentation and validation errors that name the invalid parameter
  • client_type: defaults to :public even when protocol: :udap — the value is ignored for UDAP, but setting it is technically a no-op with a warning rather than an error; this is intentional for resilience in generic caller code

Back to Top ↑

This site uses Just the Docs, a documentation theme for Jekyll.