ADR-003: protocol: and client_type: as orthogonal dimensions
Status: Accepted
Context
Safire::Client needs to support multiple healthcare authorization protocols (SMART on FHIR, 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