Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions docs/adr/0044-py-trees-typed-ports-adoption.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
---
status: accepted
date: 2026-07-29
deciders:
- vultron maintainers
consulted:
- project stakeholders
informed:
- contributors
---

# 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:

1. **Hidden data contracts**: the keys a node reads or writes are scattered
across `setup()`, `initialise()`, and `update()` — there is no single,
machine-readable declaration.
2. **Late error detection**: a misspelled key or a missing required input
surfaces as a mid-tick `KeyError` or a silent `None`, 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: `NoDataAvailable` on a missing required
input surfaces in `initialise()` (at the start of the first tick) rather than
silently mid-tick as a `KeyError`.
- 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.0` is already installed.
- Compatibility with the existing `BTBridge` flat-key convention: port
remappings (`{"datalayer": "/datalayer", "actor_id": "/actor_id"}`) bridge
the Ports namespace to the keys `BTBridge.setup_tree()` writes.

## Considered Options

1. Keep the imperative `register_key` / direct-attribute convention.
2. Adopt `BehaviourWithPorts` as base for all `vultron/core/behaviors/` nodes.
3. Adopt `BehaviourWithPorts` for 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 tracing `update()`.
- `NoDataAvailable` on a missing required input surfaces when `get_input()` is
called in `initialise()` — at the start of the first tick, not during `setup()`.
- Isolated node tests can call `setup_ports()` + `get_input()` directly
without running a full tree.
- `DataLayerConditionWithPorts` and `DataLayerActionWithPorts` in
`helpers.py` encapsulate 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 `notes/py-trees-ports-adoption.md` and is the subject of the XML
feasibility spike (Issue #1810).

### Implementation pattern

```python
class CheckRMStateValid(DataLayerConditionWithPorts):
@classmethod
def input_ports(cls) -> dict[str, PortInformation]:
# Inherit datalayer + actor_id from base class defaults.
# Override only to add domain-specific ports.
return super().input_ports()

@classmethod
def output_ports(cls) -> dict[str, PortInformation]:
return {}

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`.

## More Information

- `notes/py-trees-ports-adoption.md` — full migration recipe and issue
sequence.
- `specs/behavior-tree-node-design.yaml` BTND-03-009 through BTND-03-011 —
normative requirements for typed port declarations.
- `vultron/core/behaviors/helpers.py` — `DataLayerConditionWithPorts` and
`DataLayerActionWithPorts` base 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).
1 change: 1 addition & 0 deletions docs/adr/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ General information about architectural decision records is available at <https:
- [ADR-0041 CaseActor-Authoritative Case Initialization](0041-caseactor-authoritative-case-initialization.md)
- [ADR-0042 Deliver All Inter-Actor Communication over HTTP; Retire the In-Process ASGI Delivery Shortcut](0042-http-only-inter-actor-delivery.md)
- [ADR-0043 Use the ADR `status` Field as the Confidence Signal (Extend Its Vocabulary Rather Than Add a New Field)](0043-adr-status-as-confidence-signal.md)
- [ADR-0044 Adopt py_trees Typed Ports for BT Node Blackboard Contracts](0044-py-trees-typed-ports-adoption.md)

## Proposed ADRs

Expand Down
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -261,6 +261,7 @@ nav:
- CaseActor-Authoritative Case Initialization: 'adr/0041-caseactor-authoritative-case-initialization.md'
- HTTP-Only Inter-Actor Delivery: 'adr/0042-http-only-inter-actor-delivery.md'
- ADR Status as Confidence Signal: 'adr/0043-adr-status-as-confidence-signal.md'
- Adopt py_trees Typed Ports: 'adr/0044-py-trees-typed-ports-adoption.md'
- About:
- Contributing: 'about/contributing.md'
- FAQ: 'about/faq.md'
Expand Down
100 changes: 99 additions & 1 deletion notes/py-trees-ports-adoption.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: py_trees Ports Adoption — Typed Blackboard Contracts and XML Authoring
status: draft
status: active
description: >
Planning analysis for adopting py_trees 2.5.0 typed Ports in vultron/core/behaviors/:
the concrete wins (typed data contracts, early error detection, isolated node
Expand Down Expand Up @@ -118,6 +118,104 @@ pilot subtree, not pre-committed here:
- **`specs/behavior-tree-integration.yaml`**: update where it describes the
blackboard/bridge contract if Ports change the bridge's setup path.

## Migration recipe (established by #1808)

Follow these steps when migrating any `DataLayerCondition` / `DataLayerAction`
node to typed Ports (required for all new nodes; follow-on sweep in #1809).

### 1. Change the base class

```python
# Before
class MyNode(DataLayerCondition):
...

# After
class MyNode(DataLayerConditionWithPorts):
...
```

Use `DataLayerConditionWithPorts` for condition (read-only) nodes and
`DataLayerActionWithPorts` for action (mutating) nodes.

### 2. Add `input_ports()` and `output_ports()`

Declare every blackboard key the node uses. The base classes already declare
`datalayer`, `actor_id`, and (for actions) `trigger_activity_factory`.
Override `input_ports()` to **add** domain-specific ports; call
`super().input_ports()` first if you want to extend rather than replace:

```python
@classmethod
def input_ports(cls) -> dict[str, PortInformation]:
ports = super().input_ports() # inherit base ports
ports["report_id"] = PortInformation(data_type=str, required=True)
return ports

@classmethod
def output_ports(cls) -> dict[str, PortInformation]:
return {} # or declare output ports here
```

If the node has **no** domain-specific ports beyond the base class defaults,
omit `input_ports()` and `output_ports()` entirely — the base class
definitions are abstract but already implemented.

### 3. Remove `setup()` overrides that only call `register_key()`

The base class `setup()` calls `setup_ports()` with the standard BTBridge
remappings. Only override `setup()` if you need to register **additional**
domain-specific ports:

```python
def setup(self, **kwargs: Any) -> None:
super().setup(**kwargs) # registers datalayer, actor_id
# register additional domain ports here if needed
```

### 4. Update `initialise()` to use `get_input()`

```python
# Before
def initialise(self) -> None:
self.datalayer = self.blackboard.datalayer
self.actor_id = self.blackboard.actor_id

# After — base class initialise() already does this; only override if
# you need to read additional domain ports:
def initialise(self) -> None:
super().initialise() # sets self.datalayer, self.actor_id
self.report_id = self.get_input("report_id")
```

### 5. Leave `update()` unchanged

The `update()` logic itself does not change — it still uses `self.datalayer`,
`self.actor_id`, and the `_require_*` guard helpers.

### 6. Add isolated-node tests

Add at least two tests per migrated node:

- **Happy path**: wire required ports, tick once, assert SUCCESS.
- **Missing required port**: call `setup_ports()` without the required port
written to the blackboard; assert `NoDataAvailable` is raised at setup time.

Use the `BTTestScenario` harness from `test/core/behaviors/bt_harness.py` for
the happy-path test. For the isolated-port test, call `setup_ports()` and
`get_input()` directly:

```python
from py_trees.ports import NoDataAvailable
import pytest

def test_missing_required_port():
node = CheckRMStateValid(report_id="https://example.org/reports/1")
node.setup_ports() # no remappings → default keys; blackboard is empty
with pytest.raises(NoDataAvailable):
node.get_input("datalayer")
```

## Issue sequence

Derived from the #1558 grill-me interview. All Tasks are children of Epic #427.
Expand Down
27 changes: 27 additions & 0 deletions plan/history/2607/implementation/ISSUE-1808.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
---
source: ISSUE-1808
timestamp: '2026-07-29T20:40:49.748458+00:00'
title: Typed Ports base classes, ADR-0044, BTND-03-009–011, validate_report pilot
type: implementation
---

## Issue #1808 — Adopt py_trees typed Ports: base class, convention, ADR/spec, pilot one subtree

Implementation complete. PR: <https://github.com/CERTCC/Vultron/pull/1827>

### Deliverables

- `DataLayerConditionWithPorts` and `DataLayerActionWithPorts` in `vultron/core/behaviors/helpers.py`
- ADR-0044 (`docs/adr/0044-py-trees-typed-ports-adoption.md`) + ADR index + mkdocs nav
- BTND-03-009 through BTND-03-011 in `specs/behavior-tree-node-design.yaml`
- 4 pilot nodes migrated: `CheckRMStateValid`, `CheckRMStateReceivedOrInvalid`, `EnsureEmbargoExists`, `TransitionRMtoValid`
- 25-test `test/core/behaviors/report/nodes/test_typed_ports.py`
- 6-step migration recipe in `notes/py-trees-ports-adoption.md`

### Key fixes during implementation

- YAML spec entries with inline `{key: value}` maps required `>` folded scalars
- py_trees ports registry collision in tests: inline subclasses across two tests with the same name trigger UserWarning → narrowed with unique class names
- mkdocs nav required explicit ADR entry (enforced by `adr-index-sync` pre-commit hook)
- `except Exception` narrowed to `except NoDataAvailable` for optional trigger_activity_factory port
- `NoDataAvailable` timing corrected: surfaces in `initialise()` (first tick), not `setup()`
26 changes: 26 additions & 0 deletions plan/incoming/learnings/20260729-no-data-available-timing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
---
title: NoDataAvailable surfaces in initialise(), not setup()
type: learning
timestamp: 2026-07-29
source: ISSUE-1808
signal: spec-ambiguity
---

The py_trees `BehaviourWithPorts` API raises `NoDataAvailable` when `get_input()`
is called on a port whose key has no value on the blackboard — which happens in
`initialise()` at the start of the first tick, not during `setup_ports()` or
`setup()`.

The initial spec entries (BTND-03-011) and ADR-0044 both stated that the error
surfaces "at setup time" / "at `setup_ports()` time". This is incorrect:
`setup_ports()` only registers key access; the read and the potential raise happen
in `initialise()`.

The spec and ADR were corrected before the PR was opened, but this is a recurring
source of confusion: "early error detection" means "at the start of the first tick"
(earlier than mid-tick `KeyError` from direct attribute access), not "before any tick."

**Implication for tests**: A missing-required-port test should call `setup_ports()`
(with no remappings → default namespace keys; blackboard empty), then call
`get_input("port_name")` and assert `NoDataAvailable`. Calling only `setup_ports()`
and asserting an error there will produce a false-passing test.
44 changes: 44 additions & 0 deletions specs/behavior-tree-node-design.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,50 @@ groups:
relationships:
- rel_type: refines
spec_id: BTND-03-001
- id: BTND-03-009
priority: MUST
kind: project
statement: New BT nodes in `vultron/core/behaviors/` MUST subclass `DataLayerConditionWithPorts` or
`DataLayerActionWithPorts` (from `helpers.py`) rather than `DataLayerCondition` / `DataLayerAction`. The typed
`input_ports()` / `output_ports()` class methods constitute the node's formal blackboard contract and MUST declare
every port the node reads or writes.
rationale: Typed port declarations are machine-readable, appear at the class level, and are the single source of truth
for a node's data-flow contract. The imperative `register_key()` approach scatters declarations across `setup()`,
`initialise()`, and `update()`. See ADR-0044.
relationships:
- rel_type: extends
spec_id: BTND-03-001
- rel_type: refines
spec_id: BTND-03-003
- id: BTND-03-010
priority: MUST
kind: project
statement: >
A `DataLayerConditionWithPorts` or `DataLayerActionWithPorts` subclass MUST call `setup_ports()` in its
`setup()` method with port remappings `{"datalayer": "/datalayer", "actor_id": "/actor_id"}` (plus
`"trigger_activity_factory": "/trigger_activity_factory"` for action nodes). These absolute-path remappings wire
the node's declared ports to the flat keys written by `BTBridge.setup_tree()`.
rationale: >
The `BehaviourWithPorts` default namespace scopes port keys under the node's subtree path, which does not
match the flat `/datalayer` and `/actor_id` keys that `BTBridge.setup_tree()` writes. Explicit absolute-path
remappings are required for compatibility. The base classes `DataLayerConditionWithPorts` and
`DataLayerActionWithPorts` implement this in their own `setup()` so subclasses that do not add extra ports need
not override `setup()`.
relationships:
- rel_type: refines
spec_id: BTND-03-009
- id: BTND-03-011
priority: MUST
kind: project
statement: A `DataLayerConditionWithPorts` or `DataLayerActionWithPorts` subclass MUST read injected values via
`get_input(port_name)` in `initialise()` (not via direct `self.blackboard.<key>` attribute access). A missing
required port raises `NoDataAvailable` when `get_input()` is first called in `initialise()` — at the start of
the first tick, not during `setup()`.
rationale: Direct blackboard attribute access bypasses the typed port layer and its early error detection. Using
`get_input()` preserves type checking and `NoDataAvailable` detection for required ports.
relationships:
- rel_type: refines
spec_id: BTND-03-009
- id: BTND-04
title: Module Ownership
specs:
Expand Down
Loading