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 againstVALID_PROTOCOLS; an unknown protocol raisesConfigurationErrorclient_type:is validated againstPROTOCOL_CLIENT_TYPES[@protocol]; ifnil(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_CLASSESand an entry toPROTOCOL_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:publiceven whenprotocol: :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