Adopt py_trees Typed Ports for BT Node Blackboard Contracts
Context and Problem Statement
ADR-0008 established py_trees as the BT library for prototype handler
execution in vultron/core/behaviors/. The current convention for declaring
a node's blackboard dependencies is imperative: each node calls
register_key() in setup() and reads keys via direct attribute access on
self.blackboard in initialise(). This approach works, but has two
weaknesses:
- Hidden data contracts: the keys a node reads or writes are scattered
across
setup(),initialise(), andupdate()— there is no single, machine-readable declaration. - Late error detection: a misspelled key or a missing required input
surfaces as a mid-tick
KeyErroror a silentNone, not at setup time.
py_trees >= 2.5.0 (already pinned in pyproject.toml) provides a typed
Ports API (BehaviourWithPorts, PortInformation, setup_ports(),
get_input()) that addresses both weaknesses.
Decision Drivers
- Explicit, readable data-flow contracts at the class level (one method per
direction:
input_ports()/output_ports()). - Structured early error detection:
NoDataAvailableon a missing required input surfaces ininitialise()(at the start of the first tick) rather than silently mid-tick as aKeyError. - Easier isolated unit tests: a node can be exercised by wiring its ports to known blackboard keys without scaffolding a full tree.
- No dependency upgrade:
py_trees >= 2.5.0is already installed. - Compatibility with the existing
BTBridgeflat-key convention: port remappings ({"datalayer": "/datalayer", "actor_id": "/actor_id"}) bridge the Ports namespace to the keysBTBridge.setup_tree()writes.
Considered Options
- Keep the imperative
register_key/ direct-attribute convention. - Adopt
BehaviourWithPortsas base for allvultron/core/behaviors/nodes. - Adopt
BehaviourWithPortsfor new nodes only; keep legacy nodes unchanged.
Decision Outcome
Option 2 — adopt BehaviourWithPorts as the standard base for all nodes
in vultron/core/behaviors/, piloted on the validate_report subtree.
The pilot validates the migration recipe before the full sweep (Issue #1809).
Consequences
Good:
input_ports()/output_ports()are the single source of truth for each node's blackboard contract — readable without tracingupdate().NoDataAvailableon a missing required input surfaces whenget_input()is called ininitialise()— at the start of the first tick, not duringsetup().- A port's
data_typeis checked at runtime on both sides:_set_output()rejects a wrong-typed write andget_input()rejects a wrong-typed read, each raisingTypeError. This makes the declared type an enforced contract rather than documentation — but only when the declaration names a concrete class.data_type=objectaccepts anything and leaves the check inert, which matters wherever a node narrows a port value withcast()instead of anisinstanceguard. Reserveobjectfor Protocol-typed injections and genuinely polymorphic payloads. - Isolated node tests can call
setup_ports()+get_input()directly without running a full tree. DataLayerConditionWithPortsandDataLayerActionWithPortsinhelpers.pyencapsulate the standard port remappings and_require_*guard helpers, so subclasses need only declare domain-specific ports.
Known consequence (not a blocker):
- The py_trees XML parser instantiates nodes via port remapping only and
has no mechanism for passing constructor arguments (factories, case IDs).
Vultron nodes are heavily constructor-parameterized (BTND-01-001), so
XML-based tree authoring is not yet feasible. This tension is documented
in
plan/history/2609/note/NOTES-py-trees-ports-adoption.md(archived — the migration is complete) and is the subject of the XML feasibility spike (Issue #1810).
Implementation pattern
class CheckRMStateValid(DataLayerConditionWithPorts):
# Inherit datalayer + actor_id from the base class declaration.
# Spread and extend only to add domain-specific ports.
INPUT_PORTS: dict[str, PortInformation] = {
**DataLayerConditionWithPorts.INPUT_PORTS,
}
OUTPUT_PORTS: dict[str, PortInformation] = {}
def update(self) -> Status:
if (f := self._require_datalayer()) is not None:
return f
# ... domain logic using self.datalayer and self.actor_id
DataLayerConditionWithPorts.setup() calls setup_ports() with the
standard BTBridge remappings; initialise() calls get_input() to
populate self.datalayer and self.actor_id.
Amendment — 2026-09-24
py_trees 2.6.0 changed how a ports class declares its ports.
The overridable input_ports() / output_ports() classmethods this ADR originally named became read-only accessors over new INPUT_PORTS / OUTPUT_PORTS class attributes, and a PortsMixin subclass lacking either attribute is now abstract: instantiating it raises TypeError.
A Dependabot bump to 2.6.0 therefore left no behavior tree constructible on main (#3610, #3613).
Every node now declares INPUT_PORTS / OUTPUT_PORTS as class attributes, extending its parent's by spreading the parent attribute ({**DataLayerActionWithPorts.INPUT_PORTS, ...}), and the dependency floor is py_trees >= 2.6.0.
Where this ADR says input_ports() / output_ports(), read the class attributes; the decision itself is unchanged.
The implementation pattern above shows the current form, and test/architecture/test_bt_ports_declarations.py fails if any node falls back to overriding the accessors.
A mixin cannot extend a class attribute through super(), so CaseIdInputPortMixin adds its port from __init_subclass__ instead.
More Information
plan/history/2609/note/NOTES-py-trees-ports-adoption.md— full migration recipe and issue sequence (archived; migration complete).specs/behavior-tree-node-design.yamlBTND-03-009 through BTND-03-011 — normative requirements for typed port declarations.vultron/core/behaviors/helpers.py—DataLayerConditionWithPortsandDataLayerActionWithPortsbase classes.- Pilot subtree:
vultron/core/behaviors/report/nodes/(CheckRMStateValid,CheckRMStateReceivedOrInvalid,EnsureEmbargoExists,TransitionRMtoValid). - Related ADRs: ADR-0008 (py_trees adoption), ADR-0025 (call-out point abstraction).
- Follow-on: Issue #1809 (full migration), Issue #1810 (XML spike).