From 8f8bec5866adc22073eac0f64a63d9cf70d79804 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 8 Jun 2026 09:50:20 +0200 Subject: [PATCH 1/5] fix(gateway): drop httplib from the plugin-facing DTO/provider headers The typed DTO contract made dto/operations.hpp pull http/alternate_status.hpp -> http/typed_router.hpp -> , leaking httplib into every plugin that includes a provider interface (e.g. operation_provider.hpp). Plugins built against the installed gateway (build-farm / Docker topology, where the vendored httplib is not on the include path) then failed with 'httplib.h: No such file'. Extract the httplib-free handler-result vocabulary (Result, NoContent, Forwarded, ValidatorResult, ResponseAttachments) into a new leaf header http/handler_result.hpp; typed_router.hpp re-exports it and keeps only TypedRequest (the sole httplib-dependent type); alternate_status.hpp includes the leaf. Plugins are now httplib-free across the .so boundary. No ABI, wire, or behaviour change. --- .../http/alternate_status.hpp | 2 +- .../http/handler_result.hpp | 97 +++++++++++++++++++ .../ros2_medkit_gateway/http/typed_router.hpp | 72 +------------- 3 files changed, 103 insertions(+), 68 deletions(-) create mode 100644 src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handler_result.hpp diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/alternate_status.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/alternate_status.hpp index 79293112..9b68506e 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/alternate_status.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/alternate_status.hpp @@ -14,7 +14,7 @@ #pragma once -#include "ros2_medkit_gateway/http/typed_router.hpp" +#include "ros2_medkit_gateway/http/handler_result.hpp" namespace ros2_medkit_gateway { namespace http { diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handler_result.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handler_result.hpp new file mode 100644 index 00000000..5296b90a --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handler_result.hpp @@ -0,0 +1,97 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +// httplib-free handler-result vocabulary. Split out of typed_router.hpp so that +// the DTO/provider header chain (operation_provider.hpp -> dto/operations.hpp -> +// alternate_status.hpp) does not transitively pull into downstream +// plugins. Only TypedRequest (in typed_router.hpp) depends on cpp-httplib. + +#include +#include +#include +#include +#include +#include +#include + +#include "ros2_medkit_gateway/core/models/error_info.hpp" + +namespace ros2_medkit_gateway { +namespace http { + +/// Handler return type. The success branch carries the DTO (or marker like +/// NoContent) the handler wants to send; the error branch carries a fully +/// formed ErrorInfo the framework can serialize as a SOVD GenericError. +template +using Result = tl::expected; + +/// Empty success marker used by handlers that complete with no body (typically +/// DELETE -> 204 No Content). Distinct from `Result` so that the +/// framework can statically dispatch the response writer. +struct NoContent {}; + +/// Sentinel returned by validators when the entity belongs to a remote peer +/// and the validator has already committed the wire response by proxying the +/// request to that peer. Returning Forwarded tells the caller "the response +/// has been written, do not write anything else". +struct Forwarded {}; + +namespace detail { +/// Variant ordering invariant: ErrorInfo must be the first alternative so a +/// default-constructed `std::variant` (the error state +/// callers may inadvertently produce) denotes an "unknown error", not the +/// misleading "request already proxied" Forwarded state. If a future refactor +/// reorders the alternatives, this static_assert breaks compilation. +template +inline constexpr bool kValidatorVariantOrderingOk = + std::is_same_v>, ErrorInfo>; +static_assert(kValidatorVariantOrderingOk, "ErrorInfo must be the first variant alternative"); +} // namespace detail + +/// Return type for HandlerContext::validate_entity_for_route and similar +/// validators that may either succeed locally, fail with an ErrorInfo, or +/// short-circuit by proxying the request to a remote peer (Forwarded). +template +using ValidatorResult = tl::expected>; + +/// Side-channel a handler can attach to its successful response when the +/// default "200 OK + DTO body" is not enough. Examples: +/// - 201 Created with a `Location` header for POST creating a resource. +/// - 202 Accepted for asynchronously processed requests. +/// - 207 Multi-Status for aggregated fan-out responses. +/// - Vendor headers such as `X-Medkit-Local-Only`. +struct ResponseAttachments { + /// HTTP status to use instead of the default 200. Leave empty for 200. + std::optional status_override; + /// Additional headers to append. Order is preserved; duplicate names are + /// allowed (cpp-httplib delivers them as separate Set-Cookie-style headers). + std::vector> headers; + + /// Fluent setter for the status override. + ResponseAttachments & with_status(int status) { + status_override = status; + return *this; + } + + /// Fluent setter that appends a header entry. + ResponseAttachments & with_header(std::string name, std::string value) { + headers.emplace_back(std::move(name), std::move(value)); + return *this; + } +}; + +} // namespace http +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/typed_router.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/typed_router.hpp index 1d2e8170..d1c3a08f 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/typed_router.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/typed_router.hpp @@ -19,79 +19,17 @@ #include #include #include -#include -#include -#include -#include -#include #include "ros2_medkit_gateway/core/http/error_codes.hpp" -#include "ros2_medkit_gateway/core/models/error_info.hpp" +// Re-export the httplib-free handler-result vocabulary so existing includers of +// typed_router.hpp keep seeing Result/NoContent/Forwarded/ValidatorResult/ +// ResponseAttachments. The markers themselves live in this leaf header, which +// the DTO/provider chain includes WITHOUT pulling . +#include "ros2_medkit_gateway/http/handler_result.hpp" namespace ros2_medkit_gateway { namespace http { -/// Handler return type. The success branch carries the DTO (or marker like -/// NoContent) the handler wants to send; the error branch carries a fully -/// formed ErrorInfo the framework can serialize as a SOVD GenericError. -template -using Result = tl::expected; - -/// Empty success marker used by handlers that complete with no body (typically -/// DELETE -> 204 No Content). Distinct from `Result` so that the -/// framework can statically dispatch the response writer. -struct NoContent {}; - -/// Sentinel returned by validators when the entity belongs to a remote peer -/// and the validator has already committed the wire response by proxying the -/// request to that peer. Returning Forwarded tells the caller "the response -/// has been written, do not write anything else". -struct Forwarded {}; - -namespace detail { -/// Variant ordering invariant: ErrorInfo must be the first alternative so a -/// default-constructed `std::variant` (the error state -/// callers may inadvertently produce) denotes an "unknown error", not the -/// misleading "request already proxied" Forwarded state. If a future refactor -/// reorders the alternatives, this static_assert breaks compilation. -template -inline constexpr bool kValidatorVariantOrderingOk = - std::is_same_v>, ErrorInfo>; -static_assert(kValidatorVariantOrderingOk, "ErrorInfo must be the first variant alternative"); -} // namespace detail - -/// Return type for HandlerContext::validate_entity_for_route and similar -/// validators that may either succeed locally, fail with an ErrorInfo, or -/// short-circuit by proxying the request to a remote peer (Forwarded). -template -using ValidatorResult = tl::expected>; - -/// Side-channel a handler can attach to its successful response when the -/// default "200 OK + DTO body" is not enough. Examples: -/// - 201 Created with a `Location` header for POST creating a resource. -/// - 202 Accepted for asynchronously processed requests. -/// - 207 Multi-Status for aggregated fan-out responses. -/// - Vendor headers such as `X-Medkit-Local-Only`. -struct ResponseAttachments { - /// HTTP status to use instead of the default 200. Leave empty for 200. - std::optional status_override; - /// Additional headers to append. Order is preserved; duplicate names are - /// allowed (cpp-httplib delivers them as separate Set-Cookie-style headers). - std::vector> headers; - - /// Fluent setter for the status override. - ResponseAttachments & with_status(int status) { - status_override = status; - return *this; - } - - /// Fluent setter that appends a header entry. - ResponseAttachments & with_header(std::string name, std::string value) { - headers.emplace_back(std::move(name), std::move(value)); - return *this; - } -}; - /// Thin, type-safe wrapper around `const httplib::Request &` so handlers do /// not see raw cpp-httplib types. A future commit will extend the constructor /// with a RouteEntry parameter to support named path-parameter lookup; for now From 86aca1a54bebb4ec96e52ee250915b77b868abd0 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 8 Jun 2026 09:50:20 +0200 Subject: [PATCH 2/5] test(gateway): add plugin-facing header httplib-purity scan g++ -M -MG dependency scan over the provider interfaces and plugin base headers; fails if any reaches . Registered as the gateway_plugin_header_purity linter ctest. Fast and distro-independent. --- src/ros2_medkit_gateway/CMakeLists.txt | 7 ++ .../scripts/check_headers_httplib_free.sh | 77 +++++++++++++++++++ 2 files changed, 84 insertions(+) create mode 100755 src/ros2_medkit_gateway/scripts/check_headers_httplib_free.sh diff --git a/src/ros2_medkit_gateway/CMakeLists.txt b/src/ros2_medkit_gateway/CMakeLists.txt index f5596f3f..7737c39e 100644 --- a/src/ros2_medkit_gateway/CMakeLists.txt +++ b/src/ros2_medkit_gateway/CMakeLists.txt @@ -354,6 +354,13 @@ if(BUILD_TESTING) ) set_tests_properties(gateway_core_purity PROPERTIES LABELS "linter") + add_test( + NAME gateway_plugin_header_purity + COMMAND bash "${CMAKE_CURRENT_SOURCE_DIR}/scripts/check_headers_httplib_free.sh" + WORKING_DIRECTORY "${CMAKE_CURRENT_SOURCE_DIR}" + ) + set_tests_properties(gateway_plugin_header_purity PROPERTIES LABELS "linter") + # ─── gateway_core link-time smoke test ──────────────────────────────────── # Compiles a translation unit including a sampling of core/ headers and # links exclusively against gateway_core + GTest. No ament_target_dependencies diff --git a/src/ros2_medkit_gateway/scripts/check_headers_httplib_free.sh b/src/ros2_medkit_gateway/scripts/check_headers_httplib_free.sh new file mode 100755 index 00000000..a989754c --- /dev/null +++ b/src/ros2_medkit_gateway/scripts/check_headers_httplib_free.sh @@ -0,0 +1,77 @@ +#!/usr/bin/env bash +# Copyright 2026 bburda +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Asserts that plugin-facing public gateway headers do NOT transitively depend +# on . Plugins consume these headers through the provider/DTO +# interface; if any pulls httplib, a plugin built against the INSTALLED gateway +# (ROS build-farm / Docker topology, where the gateway's vendored httplib is not +# on the include path) fails to compile. Preprocessor-only scan (g++ -M -MG): +# fast and distro-independent. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +INCLUDE_DIR="$(cd "${SCRIPT_DIR}/../include" && pwd)" +CXX="${CXX:-g++}" + +# Plugin-facing surface: ALL provider interfaces (globbed, so new providers are +# covered automatically) + the explicit plugin base headers a GatewayPlugin +# subclass includes. Deliberately excludes gateway-internal HTTP-plumbing +# headers (rest_server.hpp, handler_context.hpp, plugin_manager.hpp, ...): no +# plugin includes those, and the build farm propagates httplib to the gateway's +# own consumers via its declared libcpp-httplib-dev. +mapfile -t HEADERS < <( + cd "${INCLUDE_DIR}" && \ + ls ros2_medkit_gateway/core/providers/*.hpp 2>/dev/null +) +HEADERS+=( + "ros2_medkit_gateway/core/plugins/gateway_plugin.hpp" + "ros2_medkit_gateway/core/plugins/plugin_context.hpp" + "ros2_medkit_gateway/core/plugins/plugin_http_types.hpp" + "ros2_medkit_gateway/plugins/ros_plugin_context.hpp" +) + +status=0 +for header in "${HEADERS[@]}"; do + if [[ ! -f "${INCLUDE_DIR}/${header}" ]]; then + echo "SKIP (not found): ${header}" + continue + fi + # -M: full dependency list incl. system headers; -MG: also emit unresolved + # angle-bracket includes as bare tokens (covers the httplib-withheld case). + # -MG tolerates ALL missing includes, so a non-zero exit means a genuine + # error (e.g. a syntax error in the header). Fail loud rather than silently + # reporting "ok" on empty/partial output. + if ! deps="$("${CXX}" -std=c++17 -I "${INCLUDE_DIR}" -M -MG -x c++ \ + <(printf '#include "%s"\n' "${header}") 2>&1)"; then + echo "FAIL: ${header} could not be preprocessed:" + echo "${deps}" + status=1 + continue + fi + # Match both the resolved path form (".../httplib.h") and the bare -MG token. + if grep -qE '(^|[[:space:]/])httplib\.h([[:space:]]|$)' <<<"${deps}"; then + echo "FAIL: ${header} transitively depends on " + status=1 + else + echo "ok: ${header}" + fi +done + +if [[ "${status}" -ne 0 ]]; then + echo "" + echo "Plugin-facing public headers must not depend on httplib. See" + echo "http/handler_result.hpp for the httplib-free handler vocabulary." +fi +exit "${status}" From 3b51b97481fbace52b38f1194823d37b8d20fbbf Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 8 Jun 2026 09:50:20 +0200 Subject: [PATCH 3/5] test(ci): add local build-farm-isolation repro for plugin builds Build the gateway into a throwaway prefix, then build each plugin library against the installed gateway with httplib withheld (poisoned -isystem shadow header + -DBUILD_TESTING=OFF). Plugins overlay into one isolated prefix so deps resolve; parallelism is capped (override MEDKIT_ISO_JOBS) to avoid OOM on high-core hosts. --- scripts/check_isolated_build.sh | 76 +++++++++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100755 scripts/check_isolated_build.sh diff --git a/scripts/check_isolated_build.sh b/scripts/check_isolated_build.sh new file mode 100755 index 00000000..bdf6f98e --- /dev/null +++ b/scripts/check_isolated_build.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +# Copyright 2026 bburda +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Reproduce the ROS build-farm topology locally on Jazzy: build + install the +# gateway into a throwaway prefix, then build each plugin LIBRARY against ONLY +# the installed gateway with httplib genuinely WITHHELD (a poisoned shadow +# injected via -isystem ahead of /usr/include). A leaked +# in a plugin-facing public header (masked in normal CI by the colcon overlay + +# system libcpp-httplib-dev) trips the poison's #error here, exactly like the +# build farm / Docker Publish. The leak is distro-independent, so Jazzy alone +# catches this class. Run before pushing changes to gateway public headers or +# plugins. +set -euo pipefail + +DISTRO="${ROS_DISTRO:-jazzy}" +# The ament setup scripts reference optional env vars, so relax nounset while +# sourcing (otherwise `set -u` aborts on AMENT_TRACE_SETUP_FILES et al.). +set +u +# shellcheck disable=SC1090 +source "/opt/ros/${DISTRO}/setup.bash" +set -u + +# Cap build parallelism. The gateway has many heavy TUs (PCH + httplib + +# nlohmann + jwt); compiling all of them at once on a high-core / low-memory +# host (e.g. a CI container) can exhaust RAM and get OOM-killed. Build one +# package at a time (--parallel-workers 1) and cap per-package compile jobs. +# Override MEDKIT_ISO_JOBS for faster builds on high-memory hosts. +NPROC="$(nproc)" +MEDKIT_ISO_JOBS="${MEDKIT_ISO_JOBS:-$(( NPROC < 6 ? NPROC : 6 ))}" +export MAKEFLAGS="-j${MEDKIT_ISO_JOBS}" +export CMAKE_BUILD_PARALLEL_LEVEL="${MEDKIT_ISO_JOBS}" + +ISO="$(mktemp -d /tmp/medkit_iso.XXXXXX)" +POISON="$(mktemp -d /tmp/medkit_poison.XXXXXX)" +trap 'rm -rf "${ISO}" "${POISON}"' EXIT + +# A shadow httplib.h that fails loudly if any plugin TU includes . +cat > "${POISON}/httplib.h" <<'POISONEOF' +#error "httplib.h leaked into a plugin build: a plugin-facing gateway public header transitively includes . See ros2_medkit_gateway/http/handler_result.hpp." +POISONEOF + +echo "==> Stage 1: build + install gateway into ${ISO} (httplib available here)" +colcon build --parallel-workers 1 --build-base "${ISO}/build" --install-base "${ISO}/install" \ + --packages-up-to ros2_medkit_gateway \ + --cmake-args -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=OFF + +# Stage 2 sources ONLY the isolated gateway install (NOT the dev overlay +# install/, which would re-expose the gateway build-tree httplib include path). +# Each plugin overlays into the SAME isolated prefix so it resolves the gateway +# and ros2_medkit_cmake from the install, never from the source overlay. +set +u +# shellcheck disable=SC1091 +source "${ISO}/install/setup.bash" +set -u + +for plugin in ros2_medkit_graph_provider ros2_medkit_sovd_service_interface ros2_medkit_opcua; do + echo "==> Stage 2: isolated lib build ${plugin} (httplib poisoned)" + colcon build --parallel-workers 1 --build-base "${ISO}/build" --install-base "${ISO}/install" \ + --packages-select "${plugin}" \ + --cmake-args -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=OFF \ + "-DCMAKE_CXX_FLAGS=-isystem ${POISON}" +done + +echo "OK: all plugin libraries build against the installed gateway with httplib withheld." From 13fe0f53c139bb868f5a65ebd48b2de949dd6551 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 8 Jun 2026 09:50:20 +0200 Subject: [PATCH 4/5] ci(gateway): run plugin-header httplib-purity gate in pre-push hook --- .pre-commit-config.yaml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a0e051c3..4fde4d4f 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -71,6 +71,13 @@ repos: language: script pass_filenames: false always_run: true + - id: gateway-plugin-header-purity + name: plugin-facing headers must not depend on httplib + entry: ./src/ros2_medkit_gateway/scripts/check_headers_httplib_free.sh + language: script + stages: [pre-push] + pass_filenames: false + always_run: true # ── Incremental clang-tidy (pre-push only) ──────────────────────── # Requires: pre-commit install --hook-type pre-push From 39cfbb7648edadd00f3d51a7d2d3c07751127423 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 8 Jun 2026 09:50:20 +0200 Subject: [PATCH 5/5] docs(gateway): document plugin-facing header httplib-purity contract --- src/ros2_medkit_gateway/README.md | 4 +++ .../design/dto_contract.rst | 33 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/src/ros2_medkit_gateway/README.md b/src/ros2_medkit_gateway/README.md index 8a4bcff7..75d32374 100644 --- a/src/ros2_medkit_gateway/README.md +++ b/src/ros2_medkit_gateway/README.md @@ -1472,6 +1472,10 @@ The `gateway_node` executable and existing test targets link `gateway_ros2`, so - `gateway_core_purity` (linter label) - greps `core/` for any ROS-package include and fails on any match. - `test_gateway_core_smoke` (unit label) - compiles a translation unit that includes a sampling of `core/` headers and links exclusively against `gateway_core` + GTest with no `ament_target_dependencies`. Build failure indicates a transitive ROS coupling that the grep guard might miss. +A third purity gate keeps cpp-httplib out of the plugin ABI. Plugin-facing public headers - the provider interfaces, the `GatewayPlugin` base headers, and the DTOs they include - must not depend on ``. cpp-httplib is a gateway-internal detail; plugins exchange `nlohmann::json`, typed `dto::` structs, `tl::expected`, and the opaque `PluginRequest`/`PluginResponse` shim across the `.so` boundary, so the gateway and its plugins need not share an httplib version. The httplib-free handler-result vocabulary (`Result`, `NoContent`, `Forwarded`, `ValidatorResult`, `ResponseAttachments`) lives in `http/handler_result.hpp`; only `http/typed_router.hpp` and the handler-internal headers touch httplib. The invariant is enforced by: + +- `gateway_plugin_header_purity` (linter label) - runs `scripts/check_headers_httplib_free.sh`, a preprocessor-only scan (`g++ -M -MG`) over the plugin-facing surface that fails on any transitive `httplib.h` dependency. Also wired into the pre-push hook. The build-farm topology (installed gateway, no vendored httplib on the include path) is reproduced locally by `scripts/check_isolated_build.sh`. + ### Components - **Gateway Node**: Main ROS 2 node that runs the REST server diff --git a/src/ros2_medkit_gateway/design/dto_contract.rst b/src/ros2_medkit_gateway/design/dto_contract.rst index daff6556..af00242a 100644 --- a/src/ros2_medkit_gateway/design/dto_contract.rst +++ b/src/ros2_medkit_gateway/design/dto_contract.rst @@ -493,6 +493,39 @@ interface directly. Out-of-tree plugins that previously returned raw ``nlohmann::json`` must wrap their response in the matching envelope type; the conversion is mechanical (``Result.content = std::move(json_payload)``). +Header Purity: No httplib Across the Plugin Boundary +---------------------------------------------------- + +Plugin-facing public headers - the provider interfaces +(``core/providers/*.hpp``), the plugin base headers a ``GatewayPlugin`` +subclass includes (``core/plugins/gateway_plugin.hpp``, +``plugin_context.hpp``, ``plugin_http_types.hpp``, +``plugins/ros_plugin_context.hpp``), and every DTO header they pull in - +MUST NOT depend on ````. cpp-httplib is a gateway-internal +implementation detail. Across the ``.so`` boundary plugins exchange only +``nlohmann::json``, typed ``dto::`` structs, ``tl::expected``, and the opaque +``PluginRequest`` / ``PluginResponse`` shim. Because no httplib type ever +crosses that boundary, the gateway and its plugins do not need to share an +httplib version: a plugin built against the installed gateway (the ROS +build-farm / Docker topology, where the gateway's vendored httplib is not on +the include path) still compiles. + +The httplib-free handler-result vocabulary - ``Result``, ``NoContent``, +``Forwarded``, ``ValidatorResult``, ``ResponseAttachments`` - lives in +``http/handler_result.hpp``. Only ``http/typed_router.hpp`` (which owns +``TypedRequest`` and the raw-response escape hatch) and the handler-internal +headers downstream of it touch ````; ``typed_router.hpp`` +re-exports the ``handler_result.hpp`` vocabulary so existing includers keep +working without pulling httplib transitively. + +The invariant is enforced by the ``gateway_plugin_header_purity`` ctest +(``scripts/check_headers_httplib_free.sh``, ``linter`` label), which runs a +preprocessor-only scan (``g++ -M -MG``) over the plugin-facing surface and +fails on any transitive ``httplib.h`` dependency, and by the pre-push hook of +the same name. The build-farm topology (installed gateway, no vendored +httplib on the include path) is reproduced locally by +``scripts/check_isolated_build.sh``. + Fan-Out Observability ---------------------