Command Transport
The command transport system enables transparent local and remote execution of EDK commands. Every ServiceCommand can be invoked in-process or forwarded to a remote service, the calling code is identical in both cases.
Transport Architecture
Execution Targets
The ExecutionTarget enum determines where a command runs:
enum class ExecutionTarget {
LOCAL, // In-process via SessionScopedCommandRegistry
SERVER, // Remote endpoint via HTTP RPC or gRPC
AUTO // SERVER if transport available, fallback to LOCAL
}
| Target | When to use |
|---|---|
LOCAL | The command's -impl module is on the classpath and you want in-process execution. No serialization overhead. |
SERVER | The domain runs as a separate microservice. Commands are serialized and forwarded. |
AUTO | Useful during migration, tries remote first, falls back to local if the transport module isn't available. |
Configuration
Hierarchical Resolution
Configuration follows a specificity hierarchy. More specific settings override less specific ones:
defaults → module → service → command
sphereon:
transport:
routing:
defaults:
target: LOCAL
transport: HTTP
timeoutMs: 30000
modules:
kms:
target: SERVER
endpoint: "kms.internal:9090"
transport: GRPC
services:
keys:
timeoutMs: 5000 # Override timeout for key operations
commands:
kms.keys.sign:
timeoutMs: 60000 # Signing can take longer
Resolution for kms.keys.sign:
- Start with defaults:
LOCAL,HTTP, 30s - Apply
modules.kms: →SERVER,GRPC, endpointkms.internal:9090 - Apply
modules.kms.services.keys: → timeout 5s - Apply command override: → timeout 60s
- Result:
SERVER,GRPC,kms.internal:9090, 60s timeout
Configuration Properties
data class ServiceCommandsConfig(
val defaults: TransportDefaults = TransportDefaults(),
val modules: Map<String, ModuleConfig> = emptyMap()
)
data class TransportDefaults(
val target: ExecutionTarget = ExecutionTarget.LOCAL,
val transport: TransportType = TransportType.HTTP,
val timeoutMs: Long = 30_000
)
data class ModuleConfig(
val target: ExecutionTarget? = null,
val transport: TransportType? = null,
val endpoint: String? = null,
val timeoutMs: Long? = null,
val services: Map<String, ServiceConfig> = emptyMap()
)
data class ServiceConfig(
val target: ExecutionTarget? = null,
val transport: TransportType? = null,
val endpoint: String? = null,
val timeoutMs: Long? = null,
val commands: Map<String, CommandConfig> = emptyMap()
)
data class CommandConfig(
val target: ExecutionTarget? = null,
val transport: TransportType? = null,
val endpoint: String? = null,
val timeoutMs: Long? = null
)
Null fields at any level mean "inherit from parent." Only set what you want to override.
Configuration Integration
The transport configuration is read through the EDK's ConfigService, so it participates in the standard property resolution chain, environment variables, YAML files, cloud config, and database-backed settings all work:
# Environment variable overrides
export SPHEREON_TRANSPORT_ROUTING_DEFAULTS_TARGET=LOCAL
export SPHEREON_TRANSPORT_ROUTING_MODULES_KMS_TARGET=SERVER
export SPHEREON_TRANSPORT_ROUTING_MODULES_KMS_ENDPOINT=kms.internal:9090
Since ConfigService supports tenant-scoped resolution, different tenants can have different routing configurations, for example, routing one tenant's KMS operations to a dedicated HSM service.
Convention-Based Routing
When a command is routed to SERVER, the transport layer derives the HTTP and gRPC paths from the command ID without any explicit binding configuration:
| Command ID | HTTP RPC Path | gRPC Service | gRPC Method |
|---|---|---|---|
kms.keys.generate | POST /rpc/kms/keys/generate | kms.KeysService | Generate |
kms.keys.sign | POST /rpc/kms/keys/sign | kms.KeysService | Sign |
did.resolution.resolve | POST /rpc/did/resolution/resolve | did.ResolutionService | Resolve |
party.manager.create | POST /rpc/party/manager/create | party.ManagerService | Create |
The convention is:
- Command ID format:
{module}.{service}.{action} - HTTP:
POST /rpc/{module}/{service}/{action} - gRPC:
{module}.{Service}Service/{Action}
New commands work automatically, no binding metadata or registration needed.
HTTP RPC Transport
The HTTP transport sends commands as POST requests to a remote service:
POST {endpoint}/rpc/{module}/{service}/{action}
Content-Type: application/json
X-Command-Id: kms.keys.generate
Authorization: Bearer {token}
traceparent: 00-{traceId}-{spanId}-01
{"algorithm": "EC", "curve": "P-256"}
Features:
- Codec negotiation: JSON by default; CBOR and Protobuf available when
lib-transport-codec-binaryis on the classpath - Auth propagation: Session context (bearer tokens, tenant ID) is forwarded via headers
- W3C Trace Context:
traceparentheaders enable distributed tracing across services - Ktor-based: Uses Ktor's CIO engine (JVM) or Darwin engine (iOS/macOS)
HTTP Client Implementation
The KtorHttpCommandClient handles serialization, codec negotiation, and error mapping:
@Inject
@SingleIn(SessionScope::class)
@ContributesBinding(SessionScope::class, binding = binding<HttpCommandClient>())
class KtorHttpCommandClient(
private val codecRegistry: StreamingCodecRegistry,
private val mediaTypeNegotiator: MediaTypeNegotiator,
private val httpClientFactory: HttpClientFactory,
private val logService: LogService
) : HttpCommandClient
The client is injected automatically when lib-transport-client-api is on the classpath.
gRPC Transport
The gRPC transport uses Protocol Buffers over HTTP/2 for high-throughput, low-latency communication:
Protocol Definition
service CommandService {
rpc Invoke(CommandRequest) returns (CommandResponse);
rpc InvokeServerStream(CommandRequest) returns (stream CommandResponse);
rpc InvokeBidiStream(stream CommandRequest) returns (stream CommandResponse);
}
message CommandRequest {
string command_id = 1;
string content_type = 2;
map<string, string> headers = 3;
map<string, string> metadata = 4;
bytes body = 5;
}
message CommandResponse {
int32 status_code = 1;
string content_type = 2;
map<string, string> headers = 3;
bytes body = 4;
}
The generic CommandRequest/CommandResponse envelope carries any command's serialized input and output, there is no per-command .proto definition needed.
Streaming Patterns
| Pattern | Use Case | Example |
|---|---|---|
| Unary | Standard request/response | Key generation, DID resolution |
| Server streaming | Server sends multiple results | Bulk credential validation, event feeds |
| Bidirectional streaming | Both sides send streams | Real-time audit log forwarding |
Streaming methods return Kotlin Flow<IdkResult<T, IdkError>> for backpressure-aware consumption:
val results: Flow<IdkResult<ValidationResult, IdkError>> =
transport.invokeServerStream(
commandId = "credentials.batch.validate",
input = BatchValidateInput(credentials),
sessionContext = sessionContext,
outputTypeToken = typeToken<ValidationResult>()
)
results.collect { result ->
result.onSuccess { println("Validated: ${it.credentialId}") }
result.onFailure { println("Error: ${it.message}") }
}
gRPC Client Features
- Channel pooling: Connections are pooled by
(host, port, useTls, caCertPath)with 5-minute idle timeout - TLS support: Plaintext, system trust store, or custom CA certificate
- Auth propagation: Session context forwarded as gRPC metadata headers
- Platform: JVM only (Netty-based). Non-JVM platforms use the HTTP transport.
@Inject
@SingleIn(SessionScope::class)
class GrpcCommandClientImpl(
private val codecRegistry: StreamingCodecRegistry,
private val mediaTypeNegotiator: MediaTypeNegotiator,
private val clientInterceptors: Set<ClientInterceptor>,
private val serviceIdentity: ServiceIdentity
) : GrpcCommandClient
Serialization Codecs
The transport layer supports multiple serialization formats. Codecs are registered via Metro multibinding and selected through content-type negotiation:
| Codec | Content-Type | Module | Notes |
|---|---|---|---|
| JSON | application/json | Core (always available) | Default, human-readable |
| CBOR | application/cbor | lib-transport-codec-binary | Compact binary, good for large payloads |
| Protobuf | application/protobuf | lib-transport-codec-binary | Smallest wire size, requires schema |
Codec selection is configured per-endpoint or via content-type negotiation:
sphereon:
transport:
routing:
modules:
kms:
target: SERVER
endpoint: "kms.internal:9090"
transport: GRPC
# gRPC uses CBOR by default for payload encoding
Local Transport
When a command resolves to LOCAL, the LocalServiceCommandTransport bypasses all serialization and network overhead:
class LocalServiceCommandTransport(
private val sessionRegistry: SessionScopedCommandRegistry
) : ServiceCommandTransport {
private suspend fun <T : Any> invokeInternal(
commandId: String,
input: Any
): IdkResult<T, IdkError> {
val command = sessionRegistry.getLocal(commandId)
?: return Err(IdkError.NOT_FOUND_ERROR(
message = "No LOCAL implementation found for command: $commandId"
))
return (command as ServiceCommand<Any, Any>).execute(input) as IdkResult<T, IdkError>
}
}
The getLocal() call ensures that even if a RoutingCommandDelegator sits in the registry, it resolves the underlying local implementation, preventing infinite recursion when config says LOCAL.
Code Generation
The @GenerateRoutedCommands annotation triggers KSP code generation that creates remote-capable wrappers for commands. For each annotated command interface, a {CommandName}Routed class is generated:
// Generated by TransportAdapterProcessor
@Inject
@SingleIn(SessionScope::class)
class GenerateKeyCommandRouted(
private val transportFactory: ServiceCommandTransportFactory,
private val runtimeSessionContext: SessionContext
) : GenerateKeyCommand, RoutableImplementation {
override val executionTarget = ExecutionTarget.SERVER
override suspend fun execute(args: GenerateKeyInput): IdkResult<GeneratedKey, IdkError> {
val transport = transportFactory.getRuntimeBoundTransport(commandId, runtimeSessionContext)
return transport.invoke(commandId, args, outputTypeToken)
}
}
This generated class is contributed to the DI graph alongside the local implementation. The RoutingCommandDelegator receives both via multibinding and selects the right one based on the execution target resolved from configuration.
You don't write this code, the KSP processor generates it from the command interface. Adding lib-transport-routing-api and annotating your command module is sufficient.
Error Handling
Remote errors are mapped to the same IdkError types used by local implementations:
| HTTP Status / gRPC Code | IdkError |
|---|---|
| 400 | ILLEGAL_ARGUMENT_ERROR |
| 401 | UNAUTHORIZED_ERROR |
| 403 | FORBIDDEN_ERROR |
| 404 | NOT_FOUND_ERROR |
| 409 | INVALID_STATE |
| 5xx | UNKNOWN_ERROR |
This means error handling code works identically for local and remote commands:
when (val result = generateKeyCommand.execute(input)) {
is Ok -> handleSuccess(result.value)
is Err -> when (result.error.type) {
IdkErrorType.NOT_FOUND_ERROR -> handleNotFound()
IdkErrorType.UNAUTHORIZED_ERROR -> handleUnauthorized()
else -> handleGenericError(result.error)
}
}
Policy-Aware Transport
The PolicyAwareHttpCommandClient decorator adds pre-flight authorization checks before forwarding commands to remote services:
@Inject
@SingleIn(SessionScope::class)
class PolicyAwareHttpCommandClient(
private val delegate: HttpCommandClient,
private val policyClient: PolicyClient,
private val config: PolicyAwareConfig = PolicyAwareConfig()
) : HttpCommandClient
Before each remote invocation, it evaluates the command against the configured policy engine (e.g., Cedarling/AuthZEN). If the policy denies the request, a FORBIDDEN_ERROR is returned immediately, avoiding the network round-trip.
The failOnPolicyError flag controls behavior when the policy service itself is unavailable:
false(default), fail open, allow the requesttrue, fail closed, deny the request
Deployment Examples
All-in-One Monolith
# No transport config needed — everything defaults to LOCAL
sphereon:
transport:
routing:
defaults:
target: LOCAL
Classpath: all -impl modules included.
Gateway + KMS Microservice
Gateway service:
sphereon:
transport:
routing:
defaults:
target: LOCAL
modules:
kms:
target: SERVER
endpoint: "kms.internal:9090"
transport: GRPC
Classpath: all -impl modules except KMS. Includes lib-transport-client-grpc.
KMS service:
sphereon:
transport:
routing:
defaults:
target: LOCAL
Classpath: only KMS -impl modules. Exposes gRPC CommandService endpoint.
Gradual Migration
Use AUTO target to migrate domains incrementally:
sphereon:
transport:
routing:
defaults:
target: LOCAL
modules:
kms:
target: AUTO # Try remote, fall back to local
endpoint: "kms.internal:9090"
transport: GRPC
With AUTO, if the gRPC transport module is on the classpath and the endpoint is reachable, commands go remote. Otherwise they execute locally. This lets you deploy the microservice first, then remove the local -impl module from the gateway once you've confirmed the remote service is stable.
Module Reference
| Module | Artifact | Description |
|---|---|---|
| Routing API | lib-transport-routing-api | Core routing, config resolution, local transport, code generation annotations |
| HTTP Client | lib-transport-client-api | Ktor-based HTTP RPC client |
| gRPC Client | lib-transport-client-grpc | Netty-based gRPC client (JVM only) |
| Binary Codecs | lib-transport-codec-binary | CBOR and Protobuf serialization |
| Adapter Processor | lib-transport-adapter-processor | KSP processor for @GenerateRoutedCommands |