OpenAPI Derivation - The Rule and the Tiers

The gateway serves its own OpenAPI document from /api/v1/docs. That document is generated by a running gateway from its own route table, not shipped as a file beside it, so every statement in it is a statement this process is making about itself - and every statement it gets wrong is one a generated client will act on.

This document is the frame: the one rule the mechanisms exist to serve, the four enforcement tiers a mechanism can reach, and which tier each mechanism actually reaches. The mechanisms themselves are documented next to the code that implements them - DTO Contract Layer for the route builder and the DTO layer, REST API Reference for what a client sees, Configuring Authentication for roles. Nothing here restates them; a second copy of a mechanism’s description is the same defect at one remove.

The rule

If a fact about a route can be derived from the handler, it must not be declared separately. Where it cannot, the declaration lives at a seam that also does the work.

Both halves matter, and the second is the one that is easy to skip.

The first half is why a success status is a C++ return type rather than a number typed beside the registration: Created<dto::Trigger> is both what the handler returns and what the document publishes, so there is no second place for the two to disagree. Before that, operations advertised success statuses their handler could not emit - not because anyone was careless, but because the document and the handler were two artefacts with no mechanical relationship between them.

The second half covers everything the return type cannot carry. A feature gate is not a type; a lock check is not a type; a role is not a type. The rule for those is not “declare it” but where to declare it: on the call that already does the thing. gated_on(available, unavailable) installs the availability predicate and declares the status it answers with, in one call, because a gate that is installed without being declared is exactly the state the document was in. The same argument produces lock_guarded() (one call publishes the header, the 409 and the marker) and requires_role() (one call feeds the permission table and the security requirement).

A declaration that sits beside the work rather than on it is a mirror, and mirrors rot. That is the failure this design exists to remove, and it is the reason the residual list at the end of this document is written down rather than left to be rediscovered.

The four enforcement tiers

Tier

Property

What that costs an author who gets it wrong

1 - compile-time

Declaration and behaviour are one expression. A forgotten site does not compile.

A build error, at the site.

2 - mechanically test-enforced

A test derives the expected set from the code or from a run, and fails on divergence.

A red suite, naming the route.

3 - declared once, at a seam that also does the work

One call both acts and declares, so the two cannot be added separately - but a route that never calls it still compiles.

Nothing, if the call is simply absent. Tier 3 needs a Tier 2 companion to say which routes should have called it.

4 - manual, presence checked

The content is human. Its absence is caught; its correctness is not.

Nothing. A wrong description is as green as a right one.

Tier 3 is the tier that is easiest to overstate. A decorator that both wraps behaviour and declares it is materially more durable than a bare declaration next to it, because the two cannot drift once the call exists. It says nothing about a route that never made the call. Every Tier 3 mechanism below therefore names what covers that second half - and where nothing does, it says so.

Where each mechanism sits

Read the tier as the weakest link in the mechanism, not the strongest. Several entries reach different tiers for different halves of what they claim, and those are split into separate rows rather than averaged.

Mechanism

Tier

What holds it to the code

Created<T> / Accepted<T>, status_payload_t

1

declare_derived_response<TResponse> reads the status from dto_alternate_status<TResponse> and the schema from status_payload_t<TResponse>; write_success_body defaults from the same trait. One type names the wire status, the declared status and the body schema.

with_location - the obligation

1

The non-attachments overloads static_assert against kStatusRequiresAttachments<TResponse>: a return type fixing 201/202 on an overload that gives the handler no header channel does not compile.

with_location - the declaration

2

test_openapi_contract.test.py::test_every_created_or_accepted_declares_location walks every operation’s 201 and 202 and fails on a missing Location header, with a non-zero-checked guard.

with_location - the call

4

Nothing forces a pair-returning handler to call it, and no check enumerates the handlers that should. test_openapi_contract.test.py::test_created_response_sends_the_location_it_declares drives POST /apps/{app_id}/triggers and asserts both the header and its absolute prefixed form; other 201/202 routes have a wire case of their own - locks, fault-triggers, scripts, operation executions, updates and bulk-data uploads among them. That is a set of hand-written cases, not a rule over the 201/202 operations the document declares.

gated_on

3

The call installs the predicate and routes its status through errors(), so a gate cannot be added without its status. A route that gates inside the handler lambda instead is invisible - which is the state the trigger and update registrations were in.

Emitted-status recorder

2

test_openapi_error_coverage.test.py::test_every_emitted_status_is_declared asserts declared is a superset of observed over a sweep derived from the served document. It maintains no list, so a route added tomorrow is swept tomorrow.

errors() - what a run reaches

2

The recorder, above.

errors() - what no run reaches

4

A provider reporting AccessDenied, a fault store that cannot be read. Declared by hand and marked at the call site.

lock_guarded - the contract

3

One call publishes the X-Client-Id header, the 409 and the x-medkit-lock-guarded marker, so a route cannot publish two thirds of it.

lock_guarded - the set

4

test_openapi_contract.test.py::test_lock_guarded_set_matches_the_handlers pins the marked set against EXPECTED_LOCK_GUARDED, a hand-maintained literal. That catches the document drifting from the list, not the list drifting from the handlers.

fan_out_aware

3 / 4

Same seam shape as lock_guarded, with less behind it: no expected set is pinned anywhere. test_openapi_contract.test.py::test_no_fan_out_header_is_declared_as_a_string checks the header’s shape wherever it is declared, and that at least one route declares it - it cannot see a route that should have and did not.

requires_role - the seam

3

One declaration feeds RouteRegistry::route_permissions() (what AuthManager::check_authorization matches) and the operation’s security requirement.

requires_role - published vs enforced

2

test_rbac_contract.test.py::test_the_declared_role_is_admitted sends a token of the published role to every non-SSE operation in the served document and asserts the middleware admitted it; test_rbac_contract.test.py::test_a_weaker_role_is_refused sends one step down wherever there is a step, and asserts the count so a derivation that granted everything to the weakest role leaves nothing to check.

requires_role - presence

2

validate_completeness() reports a registration that declares neither requires_role() nor public_route() as an error, including on hidden() routes, and test_openapi_contract.test.py::test_shipped_route_set_declares_complete_metadata waits for a zero error count.

Lifecycle role policy

4

test_rbac_contract.test.py::test_lifecycle_transitions_publish_the_roles_the_policy_fixes states the roles as a literal. The transition set and entity-type set are read out of the document, so a new action fails rather than passing in silence; only the roles are written down.

Residual permission list

4

AuthConfig::residual_route_permissions(). See below.

success_schema on a plain JSON GET

2

test_openapi_response_drift.test.py::test_get_responses_match_declared_schema validates a live body against the declared 200 schema.

success_schema anywhere else

4

Drift skips SSE-classified operations and non-JSON media, and covers no POST or PUT at all. Each such use needs a wire assertion written beside it or it rests on a reading.

Media types on a binary download

2

test_scenario_bulk_data_download.test.py::test_04_rosbag_media_type_is_named_in_the_document and test_scenario_bulk_data_upload.test.py::test_21_download_serves_a_media_type_the_document_declares download real artifacts and assert the served Content-Type against the document, distinguishing a named type from the */* catch-all.

Operation description

4

test_openapi_contract.test.py::test_every_operation_has_a_description gates presence on every operation. The words themselves are unchecked.

body_example

4

test_openapi_contract.test.py::test_non_trivial_request_bodies_carry_an_example gates presence for the operations named in EXAMPLE_BODIES, a hand-maintained set, with a guard that fails if one of those operations stops existing.

FieldConstraints

4

Read by SchemaWriter only. JsonReader does not enforce it, so a published minimum is a claim about the handler. See below.

Path-parameter maxLength

4 / 2

The synthesiser table is hand-written; the two bounds it publishes are driven on every verb that carries them by test_configuration_api.test.py::test_06b_every_verb_rejects_an_oversized_config_id and test_faults_api.test.py::test_both_verbs_reject_an_oversized_fault_code.

<entity-path>/docs sub-documents

n/a

Not declared at all - a projection of the served routes. See Derivation with nothing left to declare.

Tier 1: the type system

One mechanism reaches it outright, and it is the one that mattered most: the success status. dto_alternate_status<TResponse> maps the return type to a status and status_payload_t<TResponse> unwraps it to the payload. Two functions read that pair: the single-response registration entry points all funnel through declare_derived_response, and the variant-returning post_alternates / del_alternates helpers fold add_alternate_response over each member of the variant. Writing .response(201, ...) beside a handler that returns 200 is no longer possible to mean anything: the derived response is already there, and a hand-attached 2xx would publish a second one.

with_location reaches Tier 1 for half of its contract. The registry declares a Location header on every derived 201 and 202, because the return type already fixed the status - so the obligation to send one is created by the same type. The overloads that give a handler no way to set a header refuse that return type at compile time rather than shipping a route which advertises a header it structurally cannot send. What the type system cannot see is a pair-returning handler that simply forgets the call, and no check derives the set of handlers that owe one; that half is Tier 4 and the table above says so.

Full detail: Success Status Lives in the Return Type in DTO Contract Layer.

Tier 2: checked against a run

The recorder is the mechanism that notices what nobody wrote down. In test builds only - gated on MEDKIT_STATUS_RECORDER, which CMakeLists.txt sets exactly when BUILD_TESTING is on - RouteRegistry::register_all wraps every mounted handler in a scope that records the status that actually reached httplib::Response, keyed by the route’s own identity. The assertion is declared is a superset of observed, and the sweep that drives it is derived from the served document rather than from a list, so a route added tomorrow is swept tomorrow without an edit.

Three companion assertions stop it passing vacuously: the set of operations the sweep leaves unreached must equal a stated literal, at least one observed status must be outside the blanket 400/404/500 set, and a minimum number of error-construction sites must have been reached.

What a shipped gateway compiles depends on how it was configured, and the honest statement is about the configuration rather than about every build. The in-tree Dockerfile passes -DBUILD_TESTING=OFF, so the published container has make_error() byte-identical to what it was and register_all mounting the handler directly. A build that leaves BUILD_TESTING on serves the recorder’s /api/v1/x-medkit-status-coverage endpoint - which is why that endpoint is covered by the residual permission list as ADMIN-only rather than left to fail closed by accident.

The recorder is not the only Tier 2 mechanism, and the table above lists the others: response drift for plain-JSON GET bodies, the RBAC probes, the media types on binary downloads, and the start-up metadata report that test_openapi_contract.test.py::test_shipped_route_set_declares_complete_metadata waits on.

Full detail: Emitted-status recorder in DTO Contract Layer.

Tier 3: seams that act and declare

Four mechanisms are Tier 3, and each is one call that does two things:

gated_on(available, unavailable)

Installs the availability predicate on the route and declares unavailable.http_status through errors(). A gate written as an inline if (!handlers_) return tl::unexpected(...) inside the handler lambda is invisible to the generator - that was the state of the trigger and update registrations before this existed.

lock_guarded()

Publishes the X-Client-Id request header (optional, deliberately), the 409 the route answers when another client holds the lock, and the x-medkit-lock-guarded operation extension. Three declarations in one call because they are one contract.

fan_out_aware()

Publishes the X-Medkit-No-Fan-Out request header, as a bare string, because the gateway tests has_header and never reads the value.

requires_role(role)

Produces the permission entry the enforcer matches against and the security requirement the document publishes.

One of the four has a Tier 2 companion covering the “did every route that should have called it, call it?” half. The other three do not, for two different reasons:

  • requires_role has one, twice over - validate_completeness() makes the declaration mandatory and reports its absence as an error, and the RBAC contract test probes the published role against the enforced one on every non-SSE operation.

  • gated_on does not need one in the same sense: the gate has no effect unless the call is made, so a missing call is a missing feature, not a missing declaration.

  • lock_guarded and fan_out_aware have none. The fact they describe - that the handler reads a request header, several call layers down in HandlerContext::validate_lock_access or the fan-out helpers - is not visible to a registration, and the document is built from the live route table when /docs is served rather than captured at registration time, so no accessor on TypedRequest would change that. lock_guarded at least has EXPECTED_LOCK_GUARDED, which catches the document drifting from the list and not the list drifting from the handlers; fan_out_aware has no expected set at all. Adding a lock check to a handler means editing that literal by hand, and adding a fan-out read means remembering unaided. No build or test failure will remind anyone in either case.

Tier 4: what a person has to get right

Tier 4 is not a euphemism for unchecked. Presence is gated everywhere it can be: an operation with no description fails, a route with no tag or no role is reported at start-up, an EXAMPLE_BODIES entry naming an operation the document lacks fails. What is not gated is whether the content is true.

Three Tier 4 items are worth naming individually, because each is a value written by hand that a reader could mistake for something derived.

FieldConstraints

SchemaWriter is the only reader. JsonReader does not validate against these keywords, so publishing minimum: 1 on AcquireLockRequest.lock_expiration asserts that LockManager rejects a non-positive expiration - and nothing ties the two together. The rule at the call site is therefore “only declare a bound the handler enforces unconditionally”, and a bound that comes from configuration (locking.default_max_expiration) belongs in the description instead. Where a published bound is driven on the wire it is by a hand-written case: test_logging_api.test.py::test_app_put_logs_configuration_zero_max_entries_returns_400 is one. Others have none.

EXPECTED_LOCK_GUARDED

A literal committed next to the test, for the reason given above.

EXPECTED_LIFECYCLE_ROLES

Deliberately a literal, and the one place in the RBAC suite where that is a feature rather than a compromise. Every other assertion there compares the document with enforcement, and both come from one declaration - so changing that declaration moves both sides together and no test notices. A value derived from nothing is what turns the assertion into a statement about policy. The transition set and the entity-type set beside it are read out of the served document, so a sixth destructive action added as operator fails rather than passing in silence.

The derived permission table and its residual

RouteRegistry::route_permissions(api_prefix) walks the registrations and emits the "<METHOD>:<pattern>" entries AuthManager::check_authorization matches against. Patterns come from the route’s cpp-httplib regex, not from its OpenAPI path - ([^/]+) becomes * and (.+) becomes ** - which is what keeps the slash-spanning parameters and the <entity-path>/docs catch-all reachable. Roles are expanded upward, because AuthConfig stores no inheritance and check_authorization looks up exactly one role’s set.

What the registry cannot see needs a residual, and AuthConfig::residual_route_permissions() is the whole of it: ADMIN’s four wildcards, GET / POST / PUT / DELETE on /api/v1/**. Three families of route are covered only by those:

  • routes a plugin mounts through PluginManager::register_routes;

  • the Swagger UI pages, in -DENABLE_SWAGGER_UI=ON builds;

  • the emitted-status recorder’s endpoint, in test builds.

The residual is deliberately short and deliberately ADMIN-only. * stops at a segment boundary, so no weaker role’s entry reaches a plugin path such as /api/v1/functions/<id>/x-medkit-graph - only GET:/api/v1/** does, which is why a plugin-served operation publishes admin as its role. Widening it would hand that role every plugin route a deployment happens to load, sight unseen. test_rbac_contract.test.py::test_a_plugin_route_stays_admin_only drives that against a running gateway with the graph provider loaded.

One deployment-shaped consequence, settled deliberately rather than by default: the document is served by a running gateway, so with auth.enabled false CapabilityGenerator::generate_impl strips every per-operation security requirement from the assembled document. Publishing a role on a deployment that admits everyone would assert something the gateway does not honour. The scheme definition stays either way, because a definition asserts nothing.

Derivation with nothing left to declare

The <entity-path>/docs sub-documents are the limit case of the rule: not a declaration held close to the work, but no declaration at all.

Every scoped document is a projection of served_paths() - the registry’s own to_openapi_paths() merged with the paths loaded plugins describe - sliced to the requested prefix, with the ids the caller named substituted into the templates and the in: path parameters those substitutions answered removed. What a scoped document says about an operation is therefore what the root document says about it, because it is what the root document says about it.

There is exactly one exception, and it exists because the fact it carries is not in any registration: a concrete data or operation item path carries the ROS 2 payload schema for one topic, service or action, which comes from the entity cache. add_cache_derived_items is the whole of that exception, and because those items are built rather than projected they are narrower than the templated sibling beside them - REST API Reference measures the difference.

This replaced four hand-written producers - one per resolved path category - and replacing them surfaced a defect in what they had been shipping. Their path items referenced named schemas through SchemaBuilder::ref, while none of the four added a single schema to the document it built, so those $ref entries resolved to nothing. The producers emitted something that looked like a document and was not one.

Both halves of that are now closed by construction. referenced_schemas ships the transitive closure a slice reaches, rather than the whole AllDtos pool on every entity page or - as before - nothing at all, and CapabilityGeneratorTest.SubDocumentCarriesTheSchemasItReferences fails on a $ref that resolves to nothing. And because a projected operation carries whatever security its registration declared, build_subtree_document registers the bearerAuth scheme those requirements name: an operation cannot reference a scheme its own document does not define. The hand-written producers had no such problem only because they published no security at all - they advertised no roles for the same reason they advertised no schemas.

The cost the projection made visible

A projected sub-document is larger than the hand-written one it replaced, because it carries everything the root document says rather than a summary. That turned the document cache from a convenience into a memory question, and the answer changed the cache rather than the projection.

CapabilityGenerator stores each document serialized, exactly as dump(2) produced it and exactly as the /docs routes write it. A parsed DOM of the same document costs several times its serialized size resident - one separately allocated node per value - and, when lookup_cache returned by value, every hit deep-copied that DOM before serialising it again. generate_serialized is what serving code calls; generate parses on every call and exists for tests that inspect structure.

The cache is bounded twice, and both bounds are needed:

  • kDocsCacheMaxBytes (16 MiB) is the bound that matters, because an entry’s size is a function of how large the ROS 2 graph is, so an entry count alone leaves the cache unbounded in bytes;

  • kDocsCacheMaxEntries (256) bounds the per-entry hash node the map allocates, which the byte budget does not account for.

Eviction is clear-all, the key carries the entity cache generation so a graph change invalidates everything, and a document larger than the whole byte budget is served but not cached. None of this is observable from a response: both /docs routes answer application/json with the same body whether it came from the cache or was just generated.

What is deliberately not derived

The rule has a boundary, and naming it is part of honouring it. Everything below is left undeclared or hand-declared on purpose, so the next reader does not have to re-derive which half is which.

Statuses no finite set describes.

  • A plugin-clamped status. make_plugin_error in the data, fault, lifecycle and operation handlers passes a provider-supplied status clamped only to 400-599, and every value in that range is a status a plugin may pick.

  • A healthy peer’s status on a fan-out. The gateway copies it through verbatim. No errors({...}) describes “whatever the peer said”, and choosing what the document should promise is an aggregation-contract question, not a documentation one.

Their counterpart is declared, which is what makes the boundary a decision rather than an omission: the statuses with a finite first-party range are derived. handlers::parameter_error_statuses() runs the classifier over every ParameterErrorCode in kAllParameterErrorCodes, so a new enumerator widens the declaration with no edit at any registration - once it is added to that array, which is hand-written and is the one step still on the author.

Two compiler checks sit on that step, and it took both. -Werror=switch-enum with a default-less switch beside the array fails the build when an enumerator is added, but only until a case is written for it; adding the cases and leaving the array short compiled cleanly, and the four registrations then quietly stopped declaring a status the new code produces. The enum therefore ends in a COUNT sentinel and the array’s length is static_assert-ed against it, so the omission is a build failure rather than a convention. Adding an enumerator now fails with the comparison reduces to (10 == 11) until the array lists it.

The lock verbs have no enum, so their range is pinned behaviourally by LockManagerTest.extend_and_release_answer_only_400_403_404.

Statuses no handler produces. The rate limiter’s 429 and the auth middleware’s 401 and 403 are answered ahead of routing. No return type can describe them and no RouteEntry can carry their headers, so each is declared once as a shared component response - Unauthorized with WWW-Authenticate, Forbidden, and RateLimited with Retry-After and the X-RateLimit-* trio - and referenced on a route only while the middleware that owns it is live.

cpp-httplib’s 416 for an unparseable Range is the fourth of that kind and the only one gated on nothing: the parse happens in Server::process_request before routing, on any path, including one that does not exist. It is therefore declared on every operation rather than on the six download routes where sending a Range is useful, and it references the GenericError body that RESTServer::setup_global_error_handlers fills the empty response with on the way out. Reading only the vendored header would have published a body-less 416; test_openapi_contract.test.py::test_range_rejection_is_answered_on_a_route_that_declares_it is what settled it, deliberately against /health rather than a download so that the universality is the thing being proven.

Sets a registration cannot see. EXPECTED_LOCK_GUARDED, and the fan_out_aware set which is not written down anywhere, for the reason given under Tier 3.

Prose. Descriptions, examples and SOVD judgments are human. Presence is gated; correctness is not.

This document’s own citations

The tables above are load-bearing precisely because they name checks. A test renamed in the tree would falsify every sentence that cites it, at once, with nothing to notice - which is the same defect shape this whole document is about.

scripts/check_doc_test_citations.py, registered as the gateway_doc_test_citations linter test, resolves every test citation in src/ros2_medkit_gateway/design/*.rst and docs/api/*.rst against the test tree. It recognises the three forms these documents use: a file-qualified Python case, resolved against the def in that file; a GTest suite-and-case pair, resolved against TEST / TEST_F / TEST_P; and a bare test name, resolved against a test source file, a Python case or a GTest case. The direction is one-way - a cited test must exist; a test nothing cites is not a defect - because only the first direction can make a document false.

One consequence to know before writing about the checker itself: a literal example of a citation is a citation as far as the parser is concerned, so describe the forms rather than spelling a fictional one in double backticks. That is not a weakness to work around - a parser that could tell an example from a claim would be one that could be talked out of checking.

Key files

src/openapi/route_registry.hpp

RouteEntry’s fluent knobs (gated_on, lock_guarded, fan_out_aware, requires_role, errors, only_status, success_schema, body_example, response_header), the typed registration entry points and their static_assert gates, and declare_derived_response / declare_location_header.

include/ros2_medkit_gateway/http/handler_result.hpp

Created<T>, Accepted<T>, NoContent, ResponseAttachments and with_location.

include/ros2_medkit_gateway/http/alternate_status.hpp

dto_alternate_status<T>, status_payload_t<T> and status_body.

include/ros2_medkit_gateway/http/detail/status_recorder.hpp

The emitted-status recorder, behind MEDKIT_STATUS_RECORDER.

include/ros2_medkit_gateway/dto/contract.hpp

FieldConstraints and the field() factories that carry it.

src/openapi/capability_generator.hpp / .cpp

The <entity-path>/docs projection, the serialized document cache and its two bounds.

include/ros2_medkit_gateway/core/auth/auth_config.hpp

AuthConfig::residual_route_permissions().

scripts/check_doc_test_citations.py

The citation resolver described above.