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 |
|---|---|---|
|
1 |
|
|
1 |
The non-attachments overloads |
|
2 |
|
|
4 |
Nothing forces a pair-returning handler to call it, and no check
enumerates the handlers that should.
|
|
3 |
The call installs the predicate and routes its status through
|
Emitted-status recorder |
2 |
|
|
2 |
The recorder, above. |
|
4 |
A provider reporting |
|
3 |
One call publishes the |
|
4 |
|
|
3 / 4 |
Same seam shape as |
|
3 |
One declaration feeds |
|
2 |
|
|
2 |
|
Lifecycle role policy |
4 |
|
Residual permission list |
4 |
|
|
2 |
|
|
4 |
Drift skips SSE-classified operations and non-JSON media, and covers no
|
Media types on a binary download |
2 |
|
Operation |
4 |
|
|
4 |
|
|
4 |
Read by |
Path-parameter |
4 / 2 |
The synthesiser table is hand-written; the two bounds it publishes are
driven on every verb that carries them by
|
|
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_statusthrougherrors(). A gate written as an inlineif (!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-Idrequest header (optional, deliberately), the 409 the route answers when another client holds the lock, and thex-medkit-lock-guardedoperation extension. Three declarations in one call because they are one contract.fan_out_aware()Publishes the
X-Medkit-No-Fan-Outrequest header, as a bare string, because the gateway testshas_headerand never reads the value.requires_role(role)Produces the permission entry the enforcer matches against and the
securityrequirement 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_rolehas 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_ondoes 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_guardedandfan_out_awarehave none. The fact they describe - that the handler reads a request header, several call layers down inHandlerContext::validate_lock_accessor the fan-out helpers - is not visible to a registration, and the document is built from the live route table when/docsis served rather than captured at registration time, so no accessor onTypedRequestwould change that.lock_guardedat least hasEXPECTED_LOCK_GUARDED, which catches the document drifting from the list and not the list drifting from the handlers;fan_out_awarehas 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.
FieldConstraintsSchemaWriteris the only reader.JsonReaderdoes not validate against these keywords, so publishingminimum: 1onAcquireLockRequest.lock_expirationasserts thatLockManagerrejects 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 thedescriptioninstead. 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_400is one. Others have none.EXPECTED_LOCK_GUARDEDA literal committed next to the test, for the reason given above.
EXPECTED_LIFECYCLE_ROLESDeliberately 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
operatorfails 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=ONbuilds;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_errorin 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.hppRouteEntry’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 theirstatic_assertgates, anddeclare_derived_response/declare_location_header.include/ros2_medkit_gateway/http/handler_result.hppCreated<T>,Accepted<T>,NoContent,ResponseAttachmentsandwith_location.include/ros2_medkit_gateway/http/alternate_status.hppdto_alternate_status<T>,status_payload_t<T>andstatus_body.include/ros2_medkit_gateway/http/detail/status_recorder.hppThe emitted-status recorder, behind
MEDKIT_STATUS_RECORDER.include/ros2_medkit_gateway/dto/contract.hppFieldConstraintsand thefield()factories that carry it.src/openapi/capability_generator.hpp/.cppThe
<entity-path>/docsprojection, the serialized document cache and its two bounds.include/ros2_medkit_gateway/core/auth/auth_config.hppAuthConfig::residual_route_permissions().scripts/check_doc_test_citations.pyThe citation resolver described above.