Behavior Services
Behavior services are modular application-layer components attached to
vehicles and RSUs. They exchange typed TransportMessage envelopes, expose
state snapshots, and run in a deterministic per-tick execution order.
This service layer is also the attachment point used by the adversary framework. Attack stages do not patch managers directly; they wrap capability bindings exported by behavior services. See Attack Framework for the attack-side architecture. For the current builtin catalog, see Available Services.
What a Service Is
A behavior service is any object implementing the
BehaviorService protocol from
opencda/core/application/behavior/behavior_service_protocol.py.
Each service must define:
service_type: stable string identifier used in config and message routingpriority: integer execution order, where smaller values run earliercapability_bindings: exported capability-to-callable mapon_attach(owner): owner-specific initialization hookprocess(messages): per-tick message processing entrypointget_state(): immutable state snapshot for runtime inspectionon_detach(): cleanup hook
At runtime, the owner is either a VehicleManager or an RSUManager.
Registration and Discovery
Builtin service packages live under:
opencda/core/application/behavior/services/
The package-level loader imports all builtin service modules on startup, and
concrete services self-register through
@BehaviorServiceRegistry.register. A service becomes instantiable by
declaring a unique service_type and importing its module.
Managers create services from scenario YAML:
behavior_services:
- type: self_informer
- type: aim_client
debug: true
- type: movement_controller
The type field is resolved through BehaviorServiceRegistry and
instantiated via create_service(...).
Manager-Side Lifecycle
VehicleManager and RSUManager use the same lifecycle:
read
behavior_servicesfrom configinstantiate services through the registry
validate that every service implements the protocol
sort services by
prioritycall
on_attach(...)for each serviceon every simulation tick, execute
update_behavior_services(...)on shutdown, call
on_detach()in reverse order
The per-tick execution flow is shared by both managers:
validate incoming
TransportMessageobjectskeep only messages addressed to the current node or valid broadcasts
group messages by
dst_service_typecall
process(...)for each attached service in priority orderimmediately feed self-addressed outputs back into the same tick
store non-local outputs in
behavior_service_resultspersist the latest
get_state()result intobehavior_service_states
That shared pipeline is the main reason new services should follow a common implementation shape. If every service uses ad hoc control flow, it becomes harder to reason about priority, message routing, state collection, and attack interception.
Common Message Model
Services communicate through typed transport envelopes:
@dataclass(frozen=True)
class TransportMessage(Generic[payloadT]):
src_owner_id: str
src_service_type: str
dst_owner_id: str
dst_service_type: str
payload: payloadT
Important routing constants:
BROADCAST_OWNER_ID: broadcast recipient at the node levelBROADCAST_SERVICE_TYPE: broadcast recipient at the service level
This gives services a uniform way to:
publish state-like responses to sibling services
send commands to another local service on the same node
send requests or responses to services on other nodes
Capabilities
Each service exposes a capability_bindings map keyed by the shared
capability vocabulary:
request.observerequest.submitresponse.observeresponse.submitcommand.submitstate.observe
These bindings serve two purposes:
they document the service’s externally visible interaction points
they provide stable hooks for the attack framework to observe or alter service behavior
If a service has no meaningful externally hooked operations, it may expose an
empty binding map, as movement_controller currently does.
Recommended Development Template
New services should be implemented with approximately the same internal template, even when the business logic differs. The goal is not identical code style for its own sake, but predictable execution semantics across the whole service layer.
Recommended package layout:
services/<service_name>/
__init__.py
service.py
messages.py
types.py
utils.py
Recommended class skeleton:
@BehaviorServiceRegistry.register
class ExampleService:
service_type = "example_service"
priority = 50
@property
def capability_bindings(self) -> CapabilityBindings:
return {
Capability.REQUEST_OBSERVE: self._observe_requests,
Capability.RESPONSE_SUBMIT: self._build_responses,
Capability.STATE_OBSERVE: self.get_state,
}
def __init__(self, priority: int = 50, **config: Any) -> None:
self.priority = priority
self._owner_ref = None
self._runtime_state = None
def on_attach(self, owner: Any) -> None:
self._owner_ref = weakref.ref(owner)
def get_state(self) -> ExampleServiceState:
return ExampleServiceState(...)
def process(
self,
messages: Sequence[TransportMessage[ExampleRequest]],
) -> tuple[TransportMessage[ExampleResponse], ...]:
observed = self._observe_requests(messages)
self._update_runtime_state(observed)
return self._build_responses(observed)
def on_detach(self) -> None:
self._owner_ref = None
Stages Every Service Should Follow
New services should follow the same conceptual processing stages on every tick, even if some stages are trivial for a particular implementation:
input filtering
observation or decoding of relevant messages
internal state update or decision making
output construction or command emission
state snapshot exposure
In practice this usually means:
keep message selection separate from business logic
update local runtime fields before building outbound messages
return typed
TransportMessageobjects instead of mutating shared statemake
get_state()reflect the latest meaningful service snapshot
This common stage model is important for three reasons:
it keeps multi-service execution predictable when priorities differ
it makes attack interception consistent because bindings always wrap known stages
it reduces friction when implementing metrics, debugging, or adding new services
Builtins as Reference Implementations
The current builtin services already follow this template with different complexity levels.
The full builtin catalog is documented in Available Services.
self_informer
observes the owner’s current pose and speed
updates cached local fields
emits a broadcast
SelfInformerResponseexposes a compact owner-state snapshot
movement_controller
filters local control requests
picks the latest valid movement command
updates local target state
invokes
owner.control(...)emits no outbound messages
aim_client
observes AIM server responses and local self-informer data
updates its current control trajectory
emits movement-controller commands
emits the next request to
aim_serverexposes its active trajectory as service state
aim_server
observes incoming AIM requests
forwards them to
AIMModelManageremits AIM response messages
exposes tracked-vehicle state through
get_state()
Configuration Examples
Default vehicle pipeline:
vehicle_base:
behavior_services:
- type: self_informer
- type: movement_controller
AIM-style setup with both vehicle and RSU services:
scenario:
rsu_list:
- id: 1
behavior_services:
- type: aim_server
priority: 1
single_cav_list:
- id: 100
behavior_services:
- type: self_informer
- type: aim_client
debug: true
- type: movement_controller
Implementation Rules
When adding a new service, keep these rules fixed unless there is a strong architectural reason not to:
use one unique
service_typeper servicekeep
priorityexplicit and meaningfulexport stable
capability_bindingsfor the stages you want observablekeep
process(...)deterministic for the same inputskeep
get_state()immutable and cheap to consumeprefer helper methods for each processing stage over one monolithic
process(...)clean up all owner-dependent state in
on_detach()
In short, services are expected to differ in logic, not in lifecycle shape. That consistency is what makes the service framework composable and what keeps the attack framework, metrics, and scenario runtime aligned.