Skip to main content
Version: v0.25.0 (Latest)

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

Command 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
}
TargetWhen to use
LOCALThe command's -impl module is on the classpath and you want in-process execution. No serialization overhead.
SERVERThe domain runs as a separate microservice. Commands are serialized and forwarded.
AUTOUseful 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:

  1. Start with defaults: LOCAL, HTTP, 30s
  2. Apply modules.kms: → SERVER, GRPC, endpoint kms.internal:9090
  3. Apply modules.kms.services.keys: → timeout 5s
  4. Apply command override: → timeout 60s
  5. 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 IDHTTP RPC PathgRPC ServicegRPC Method
kms.keys.generatePOST /rpc/kms/keys/generatekms.KeysServiceGenerate
kms.keys.signPOST /rpc/kms/keys/signkms.KeysServiceSign
did.resolution.resolvePOST /rpc/did/resolution/resolvedid.ResolutionServiceResolve
party.manager.createPOST /rpc/party/manager/createparty.ManagerServiceCreate

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-binary is on the classpath
  • Auth propagation: Session context (bearer tokens, tenant ID) is forwarded via headers
  • W3C Trace Context: traceparent headers 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

PatternUse CaseExample
UnaryStandard request/responseKey generation, DID resolution
Server streamingServer sends multiple resultsBulk credential validation, event feeds
Bidirectional streamingBoth sides send streamsReal-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:

CodecContent-TypeModuleNotes
JSONapplication/jsonCore (always available)Default, human-readable
CBORapplication/cborlib-transport-codec-binaryCompact binary, good for large payloads
Protobufapplication/protobuflib-transport-codec-binarySmallest 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 CodeIdkError
400ILLEGAL_ARGUMENT_ERROR
401UNAUTHORIZED_ERROR
403FORBIDDEN_ERROR
404NOT_FOUND_ERROR
409INVALID_STATE
5xxUNKNOWN_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 request
  • true, 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

ModuleArtifactDescription
Routing APIlib-transport-routing-apiCore routing, config resolution, local transport, code generation annotations
HTTP Clientlib-transport-client-apiKtor-based HTTP RPC client
gRPC Clientlib-transport-client-grpcNetty-based gRPC client (JVM only)
Binary Codecslib-transport-codec-binaryCBOR and Protobuf serialization
Adapter Processorlib-transport-adapter-processorKSP processor for @GenerateRoutedCommands