Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
3e00979
feat(aggregation): address a grouping entity's resources through one …
bburda Aug 20, 2026
2ecaa4b
feat(aggregation): retain a silent peer's declarations as unreachable
bburda Aug 20, 2026
f4a3cbc
fix(aggregation): a refresh that could not read a peer is not a pictu…
bburda Aug 20, 2026
1308fc1
fix(operations): refuse an id that names more than one operation
bburda Aug 20, 2026
b83ac3d
feat(aggregation): serve a member-qualified request on the gateway th…
bburda Aug 20, 2026
572a681
fix(aggregation): carry the caller's identity to the gateway that jud…
bburda Aug 20, 2026
5ebcce6
fix(operations): address an operation by its ROS path where one provi…
bburda Aug 20, 2026
2cbf1ab
fix(configurations): decide the member half by membership, not by a n…
bburda Aug 20, 2026
ea062c9
fix(operations): resolve an operation id the same way on every route
bburda Aug 20, 2026
df92fc4
fix(aggregation): carry an entity's unreachability across every hop
bburda Aug 20, 2026
0fc6388
fix(operations): reach a peer-owned execution through the aggregate
bburda Aug 20, 2026
b2e8713
test(triggers): start the second gateway when the trigger exists
bburda Aug 20, 2026
e08e67f
fix(subscriptions): refuse a resource path the collection cannot stream
bburda Aug 20, 2026
0e559b6
fix(triggers): keep a restored trigger until its entity has been seen…
bburda Aug 20, 2026
9784473
fix(subscriptions): keep the sampler declaration inside the gateway
bburda Aug 20, 2026
46c214c
test: wait for a mock peer to listen before handing back its port
bburda Aug 20, 2026
3aa545d
test(aggregation): give the grouping suite a budget its own waits fit…
bburda Aug 20, 2026
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
326 changes: 316 additions & 10 deletions docs/api/rest.rst

Large diffs are not rendered by default.

103 changes: 103 additions & 0 deletions docs/config/aggregation.rst
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,16 @@ Combine static peers for known infrastructure with mDNS for dynamic discovery:
configuration. See :ref:`Security Parameters <aggregation-security>`
for details on securing peer communication.

.. note::

``X-Client-Id`` is always forwarded, and is not governed by
``forward_auth``. It names the caller rather than granting it anything:
a lock on a peer-owned entity is held on the peer and judged there, so a
forwarded request that arrived without the name would be a different
caller than the one holding the lock. Authority still travels only in
``Authorization``, which is forwarded when the deployment says the peer
is trusted with it.

Secure Aggregation (TLS + Auth)
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand Down Expand Up @@ -355,6 +365,52 @@ When aggregation is enabled, entities from peers are merged with local entities:
Requests for remote entities are transparently forwarded to the owning peer.
The routing table maps entity IDs to peer names.

An entity that draws its resources from members - an Area, a merged Function, a
hierarchical parent Component - is deliberately absent from that table, because
its members can sit on different gateways and routing it whole would discard
every member the other contributors hold. A request that names one member is
routed instead: it is re-addressed to that member's own entity route on the
gateway that owns it, so

.. code-block:: text

POST /api/v1/functions/vehicle_health/operations/peer_calibration:calibrate/executions

becomes, on the peer that runs ``peer_calibration``,

.. code-block:: text

POST /api/v1/apps/peer_calibration/operations/calibrate/executions

The same routing applies to a single ``/data`` item and to a single
``/configurations`` parameter. A configuration id is ``<app_id>:<param_name>``,
and on the owning gateway the parameter is addressed by its bare name, so

.. code-block:: text

PUT /api/v1/functions/vehicle_health/configurations/peer_calibration:calibration_offset

becomes

.. code-block:: text

PUT /api/v1/apps/peer_calibration/configurations/calibration_offset

A member half is recognised when the text before the first colon names a member
of the addressed entity, so the qualified form works on an aggregating entity
whose members are all owned by peers - a parent gateway that runs no ROS node of
its own - and on one that runs a single node beside peer-owned members. Neither
shape changes the ids the entity's listing offers.

``DELETE /api/v1/{entity_type}/{id}/configurations`` resets the nodes this
gateway runs. A member another gateway runs is not reset by it, and the response
says so: ``207`` instead of ``204``, with that member named and the gateway that
owns it named with it.

Reachability is answered before anything is forwarded, so a member whose gateway
is silent gets ``504 not-responding`` rather than a ``502`` from a failed
connection. A member this gateway owns is served here, unchanged.

See :doc:`../design/ros2_medkit_gateway/aggregation` for detailed merge logic
and architecture diagrams.

Expand Down Expand Up @@ -389,6 +445,53 @@ action. Individual entity requests for remote entities (e.g.,
``GET /api/v1/apps/{id}``) return ``502 Bad Gateway`` if the owning peer
is unreachable.

Peer Refresh Completeness
~~~~~~~~~~~~~~~~~~~~~~~~~

A cache refresh reads a peer over several requests: the four entity lists, the
nested ``subareas`` and ``subcomponents`` collections, the per-entity detail
that carries a Component's relationships and a Function's hosts, and each app's
``operations``. If any of them cannot be read - connection failure, a status
the route has no other meaning for, an oversized body, unparsable JSON - the
refresh for that peer is discarded whole. A partial picture is never published
as a complete one, and the peer's last complete declaration is left in place.

What clients see then depends on the peer's health check:

- Health check fails: the retained declaration is served with
``x-medkit.available: false`` (and ``x-medkit.is_online: false`` for Apps),
because a request addressed there cannot arrive.
- Health check passes: the retained declaration is served unchanged and the
incomplete refresh is logged at ``WARN``. Availability is untouched - the
peer can still be reached; this gateway merely failed to read all of it.

Two statuses are read rather than treated as failures:

- ``404`` on a nested collection route means the peer runs a gateway version
that does not expose the route. Those members are omitted, the rest of the
peer merges normally, and the absent routes are logged once per refresh at
``WARN``.
- ``504`` with error code ``not-responding`` on any route hanging off an entity
- its detail, or one of its nested collections - means the peer holds that id
and the gateway contributing it has gone quiet, which is the answer an
aggregating peer gives for a declaration it is retaining. In a chain topology
this is how the far end reports a dead leaf, so the entity is kept as the
peer's list named it and marked ``x-medkit.available: false``. A nested
collection answering that way costs only the members that route carries;
treated as a failure it would discard the whole peer on every refresh, so one
unreachable member would freeze this gateway's view of everything that peer
holds. A ``504`` without ``not-responding`` is not a statement about an entity
and still discards the refresh.

Availability is also read back off the wire. ``x-medkit.available`` is emitted
only when false, so an absent field means the entity is reachable, and that is
the default this gateway parses it with. It matters most beyond one hop: an App
also carries ``x-medkit.is_online``, but a Component has no second signal, so
without the read-back the head of a three-gateway chain reports a leaf behind a
dead gateway as reachable. Retention never contradicts what a peer said - it
only ever sets ``available`` to false, and it does so when the peer itself
stopped answering, which already covers everything behind it.

.. _aggregation-breaking-changes:

Breaking Changes (Entity Model Simplification)
Expand Down
4 changes: 4 additions & 0 deletions docs/tutorials/graph-provider.rst
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,10 @@ resource, so a client can receive periodic graph snapshots over Server-Sent
Events instead of polling. See :doc:`/api/rest` for the general
cyclic-subscription API; the graph-specific parts are below.

Each tick carries the whole graph document for the function. The resource URI
must therefore end at ``x-medkit-graph`` - appending a path below it is refused
with 400.

Create the subscription:

.. code-block:: bash
Expand Down
9 changes: 7 additions & 2 deletions docs/tutorials/plugin-system.rst
Original file line number Diff line number Diff line change
Expand Up @@ -339,6 +339,11 @@ call ``fn(entity_id, resource_path)`` on each tick. The function must return
``tl::expected<nlohmann::json, std::string>``. See `Cyclic Subscription Extensions`_
for the lower-level registry API.

A sampler registered through the plugin context streams its whole collection on every
tick. A subscription whose resource URI names a single item of that collection is
refused with 400 ``x-medkit-invalid-resource-uri`` rather than accepted and answered
with everything, so the URI must end at the collection.

.. note::

The ``PluginContext`` interface is versioned alongside ``PLUGIN_API_VERSION``.
Expand Down Expand Up @@ -468,8 +473,8 @@ Plugins can extend cyclic subscriptions by registering custom resource samplers
and transport providers during ``set_context()``.

**Resource Samplers** provide the data for a collection when sampled by a subscription.
Built-in samplers (``data``, ``faults``, ``configurations``, ``updates``) are registered
by the gateway during startup. Custom samplers are registered via ``ResourceSamplerRegistry``
Built-in samplers (``data``, ``faults``, ``configurations``, ``logs``, ``updates``) are
registered by the gateway during startup. Custom samplers are registered via ``ResourceSamplerRegistry``
on the ``GatewayNode``:

.. code-block:: cpp
Expand Down
179 changes: 178 additions & 1 deletion src/ros2_medkit_gateway/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ All endpoints are prefixed with `/api/v1` for API versioning.
- `GET /api/v1/components/{component_id}/operations` - List all services and actions for a component
- `GET /api/v1/components/{component_id}/operations/{operation_id}` - Get operation details
- `POST /api/v1/components/{component_id}/operations/{operation_id}/executions` - Execute operation (call service or send action goal)
- `GET /api/v1/components/{component_id}/operations/{operation_id}/executions` - List all executions for an operation
- `GET /api/v1/components/{component_id}/operations/{operation_id}/executions` - List all executions for an operation (empty for a service, which leaves no execution resource)
- `GET /api/v1/components/{component_id}/operations/{operation_id}/executions/{execution_id}` - Get execution status
- `DELETE /api/v1/components/{component_id}/operations/{operation_id}/executions/{execution_id}` - Cancel action execution

Expand Down Expand Up @@ -257,6 +257,183 @@ curl http://localhost:8080/api/v1/areas/nonexistent/components
- Hierarchical navigation (select area → view its components)
- Area-specific health checks

### Item Ids and Their Providers

An entity that draws items from members - an Area, a Function, or a Component
with hosted apps - names the contributing members of every listed item in
`x-medkit.member_ids`.

Ids stay bare. An item id is the ROS name its member uses: the topic path for
`/data`, the service or action short name for `/operations`. That does not
change with aggregation, which is the ordinary case - in runtime discovery
every App hangs off the single host Component.

An id is qualified only when it is ambiguous, meaning more than one item in the
merged collection carries it:

```
<member_id>:<item_id>
```

Ambiguity is decided after the peer fan-out, since neither gateway can see the
collision alone. Two members exposing the operation short name `calibrate` at
different ROS paths are two items with one id, so both are qualified. A topic
path names one topic however many members publish and subscribe to it, so it
stays bare and lists its contributors in `member_ids`; it is qualified only if
two gateways each contribute an item under that path.

An operation's short name is the last segment of its ROS path, so ONE member
can carry it twice - `left/calibrate` and `right/calibrate` are two operations
called `calibrate`. The member half names that member for both copies and
separates nothing, so those items take the ROS path, leading slash stripped, as
their item half:

```
robot/left/calibrate # on the App itself
primary_calibration:robot/left/calibrate # on an entity that aggregates it
```

The form is decided per provider: a short name that its own provider carries
once keeps that short name, whatever any other provider does with it. `/data`
already addresses its items by path, so the split at the first colon is
unchanged - a ROS path carries no colon.

- A bare id that names one item works on every route, which is what the web UI,
the Foxglove panel, the MCP tools and the generated OpenAPI document all send.
- `POST /{entity}/operations/{id}/executions` with a bare id several members
provide is `400 invalid-request`, naming the qualified form and the members.
A short name one member carries twice is `400` too, naming the ROS paths that
collided. Either refusal carries `parameters.operation_ids`: the ids that do
address what collided, as the collection lists them.
- A qualified id is accepted on single-item routes; an unknown member half, an
item half that member does not provide, or a member half followed by nothing,
is `404` - which is what tells an absent item apart from one that exists and
carries no data.
- `GET /{entity}/operations/{id}` and `GET /{entity}/operations/{id}/executions`
refuse exactly what the execution refuses, with the same body. An unambiguous
bare id reads and lists unchanged.

`GET /{entity}/operations/{id}/executions` resolves the id by the same rule, so
a member-qualified id and a ROS-path id both work, and has three distinct
answers: an action returns its goals, a service returns `200` with an empty
`items` array (a service call completes inside its `POST` and leaves no
execution resource), and an id naming no operation is `404`. Goals live on the
gateway that sent them, so an id naming a peer-owned member is dispatched to
that member's own route exactly as the `POST` was.

A member-qualified request is served by the gateway that owns that member, on
the member's own entity route. An aggregating entity holds nothing itself and
its members can belong to different gateways, so

```
POST /api/v1/functions/vehicle_health/operations/peer_calibration:calibrate/executions
```

becomes, when `peer_calibration` belongs to a peer,

```
POST /api/v1/apps/peer_calibration/operations/calibrate/executions
```

on that peer, and the peer's answer is what the client gets. The ROS service or
topic behind the id lives on the owner's graph and nowhere else. A locally owned
member is served here as before. This covers `GET` and `PUT` of one `/data` item,
`POST` of an `/operations` execution, and `GET`, `PUT` and `DELETE` of one
`/configurations` item.

`/configurations` keeps its own id scheme, `<app_id>:<param_name>`, whose member
half is the app id. Nothing on the owning gateway is aggregating, so the
parameter is addressed there by its bare name:

```
PUT /api/v1/functions/vehicle_health/configurations/peer_calibration:calibration_offset
```

becomes

```
PUT /api/v1/apps/peer_calibration/configurations/calibration_offset
```

and the write lands on the ROS node that declares the parameter. The
`GET /{entity}/configurations` listing is unchanged - peer parameters reach it
through the collection fan-out, and the ids it offers are the ids the
single-item routes accept.

The member half is recognised when the text before the first colon names a
member of the addressed entity. How many ROS nodes this gateway resolves for the
entity does not enter into it: a member another gateway runs reports no ROS
binding here, so an entity whose members are all peer-owned resolves none and an
entity running one node of its own can still have peer-owned members beside it.
Both take the same id form as an entity with several local nodes, and neither
changes the ids the listing offers. A prefix naming no member is part of the
parameter name.

`DELETE /{entity}/configurations` resets the nodes this gateway runs, so a
member another gateway runs is not reset by it. That is reported rather than
implied: `207` instead of `204`, with the member named and the gateway that owns
it named with it.

Reachability is settled first, so a
member whose gateway has gone silent answers `504 not-responding` instead of a
`502` from a connection that could not be made. `X-Medkit-No-Fan-Out` bounds the
collection fan-out and does not change where a member-qualified request is
served - it already names its owner and is one hop.

Ambiguity is decided from the declared tree, which includes a peer's declared
operations held locally. The answer therefore does not change with who is
reachable, costs no network call, and cannot be altered by a client-supplied
header.

A refresh describes a peer or it does not. Reading one takes several requests -
the four entity lists, the nested `subareas` and `subcomponents` collections,
the per-entity detail that carries a Component's relationships and a Function's
hosts, and each app's `operations` - and if any of them cannot be read, the
whole refresh is dropped instead of published with the missing branch silently
absent. The peer's last complete declaration stands for another cycle. Two statuses
carry a meaning of their own and are read instead: a `404` on a nested
collection route means the peer runs a gateway that predates that route, so
those members are omitted, the rest of the peer merges normally and the absent
routes are logged once per refresh; a `504 not-responding` on any route hanging
off an entity - its detail, or one of its nested collections - is the peer
saying it holds that id and whoever contributes it has gone quiet, which is what
an aggregating peer answers for a declaration it is retaining, so the entity is
kept as its list named it and marked `x-medkit.available: false`. A nested
collection answering that way costs only the members that route carries; read as
a failed request it would discard the whole peer, so one unreachable member
anywhere behind it would freeze this gateway's view of that peer. A `504`
without `not-responding` says nothing about an entity and still drops the
refresh.

`x-medkit.available` is read back off a peer's response with a default of
`true`, since it is emitted only when false. Beyond one hop that is the only
thing carrying the fact: an App also has `is_online`, a Component has nothing
else, so without it the head of a three-gateway chain reports a leaf behind a
dead gateway as reachable.

When a peer stops answering, the entities it declared in its manifest are
retained and marked unavailable (`x-medkit.available: false`,
`x-medkit.is_online: false`); the ones it only discovered at runtime disappear.
`available: false` answers "can a request get there", so it is earned by a
failed health check alone - a peer that still answers `/health` keeps its
entities as they were last read even when a refresh against it came back
incomplete.
A retained member keeps the operations it last reported: they stay listed on the
aggregating entity marked `x-medkit.available: false`, and still count towards
ambiguity, so a qualified id never degrades back to a bare one that execution
would refuse. A request addressed to a retained entity answers
`504 not-responding` naming the member, instead of being forwarded to the silent
peer as a `502`. `/health`
still reports the peer itself as `offline` - entity availability and peer
health are separate questions.

`/configurations` predates this rule and keeps its own: on a multi-node entity
every parameter id is `<app_id>:<param_name>`, a bare id is refused on write,
and items carry `x-medkit.source` rather than `member_ids`. The node count
decides which ids the listing offers; which ids it accepts is decided by the
member set, so the qualified form works on an entity whose members are all
peer-owned too.

### Component Data Read Endpoints

#### GET /api/v1/components/{component_id}/data
Expand Down
Loading
Loading