Admin API contract & compatibility policy¶
tailscale2otel's admin server exposes a small, read-only JSON API (#323).
Nothing here is a remote-control surface — every endpoint listed below only
serves state the process already holds; the admin server's one mutating
endpoint (POST /api/rdns/purge) is out of scope for this contract and
carries no schema version.
| Endpoint | Response type | Published schema | schema_version field |
|---|---|---|---|
GET /api/status.json |
statusdata.Status |
schemas/status.schema.json |
.schema_version |
GET /api/config.json |
statusdata.ConfigSummary |
schemas/config-summary.schema.json |
.schema_version |
GET /api/cardinality.json |
statusdata.CardinalityInfo |
schemas/cardinality.schema.json |
.schema_version |
GET /api/flows.json |
flowsdata.Response |
schemas/flows.schema.json |
.schema_version |
GET /api/flows/export.json |
(internal envelope) | schemas/flows-export.schema.json |
.schema_version |
GET /api/flows/export.csv |
CSV | — (no JSON schema; see below) | # schema_version=N comment line |
GET /api/events.json |
eventsdata.Response |
schemas/events.schema.json |
.schema_version |
Status.config and Status.cardinality carry ConfigSummary/CardinalityInfo
verbatim, so those two objects have their own schema_version wherever
they appear — nested inside /api/status.json or served standalone — because
each is also independently exposed and can change on its own schedule.
Reading schema_version¶
Every JSON response body in the table above carries a top-level integer field
schema_version, starting at 1. It exists so external automation can tell
"the shape I already understand" from "something changed I need to look at"
without guessing from field presence. A consumer should:
- Read
schema_version. - If it matches the version the integration was written against, decode normally — new, unfamiliar fields may appear (see Additive below) and should be ignored, not treated as an error.
- If it is higher than expected, either fall back to a defensive/partial decode or refuse and alert — the response may no longer have the shape the integration assumes.
Additive vs. breaking¶
Additive (schema_version does NOT change):
- A new top-level field, or a new field on any nested object.
- A new element appearing in an existing array (e.g. a new row kind, a new
collector name in
collectors[]). - A previously-always-present field becoming conditionally omitted only
when the underlying feature it describes is disabled/unavailable (this
repo's existing
omitempty/omitzeroconvention — e.g.tls_certificatesis omitted entirely when no listener runs TLS). This is additive because a consumer that already handles "feature off" gracefully sees no change; one that does not was already relying on undocumented behavior. - A documentation-only change: a clearer description, an added
docs/api/page, a doc comment.
Breaking (requires a schema_version bump on the AFFECTED response, in the
same change that makes it):
- Removing a field.
- Renaming a field's JSON key (equivalent to removing the old key).
- Changing a field's JSON type (e.g. a duration string becoming a raw integer, an object becoming an array).
- Changing what an EXISTING field's value means without renaming it (units, scale, or semantics) — this is the one class of break the automated gate below cannot detect by shape alone, because the JSON type is unchanged. It must be called out explicitly in the PR/issue and treated as breaking by policy even though the tooling stays silent.
- A field that was always present becoming conditionally omitted for a reason OTHER than the feature-disabled case above (e.g. omitting it under normal operation to save bytes) — this removes a guarantee a consumer may depend on, unlike the additive case.
When in doubt, treat it as breaking: bumping a schema_version is cheap, and
a downstream integration silently misinterpreting a field is not.
How CI enforces it¶
Enforcement is two independent, deliberately different mechanisms, both
living beside the response types they describe
(internal/app/apicontract, plus one file in internal/app for the one
unexported response type). Both run inside the normal go test -race ./...
gate — no separate workflow step.
- Drift gate (
TestSchemasInSync/TestFlowsExportSchemaInSync) — the schema published underdocs/api/schemas/*.schema.jsonmust equal what reflecting over the LIVE Go response type produces, right now. ANY shape change — additive or breaking — fails this test until the schema is regenerated and the diff committed:
go test ./internal/app/apicontract -run TestSchemasInSync -update
go test ./internal/app -run TestFlowsExportSchemaInSync -update
This proves the published docs are current. It does not prove a change was safe — an accidental field removal regenerates just as cleanly as an addition. That's gate 2.
- Compatibility gate (
TestSchemasAreBackwardCompatible/TestFlowsExportSchemaIsBackwardCompatible) — a committeddocs/api/schemas/*.baseline.jsonfile records every field path and type the CURRENTschema_versionpromises. This test flattens the live schema and checks every baseline-listed path still resolves to the same type. It is never satisfied by the routine-updateflag above — regenerating it requires the separate, explicit-update-baselineflag:
go test ./internal/app/apicontract -run TestSchemasAreBackwardCompatible -update-baseline
go test ./internal/app -run TestFlowsExportSchemaIsBackwardCompatible -update-baseline
Running -update-baseline is the deliberate, auditable act of
acknowledging a breaking change — it should be done in the same commit
that bumps the response's SchemaVersion constant
(statusdata.StatusSchemaVersion, statusdata.ConfigSummarySchemaVersion,
statusdata.CardinalitySchemaVersion, flowsdata.SchemaVersion,
eventsdata.SchemaVersion, or flowsExportSchemaVersion in
internal/app/admin_flows_export.go). The baseline itself also records the
schema_version it was generated against, and the test fails loudly if
that number and the compiled-in constant disagree — so the two files
cannot silently drift apart.
A removed field, a renamed field, or a changed field type all fail this
test by name (internal/app/apicontract/apicontract_test.go's
TestCompareBaseline_* tests prove the detector catches each of the three
directly; the live Go types were also mutated by hand during development
of #323 and observed to fail this exact test for the exact reason).
- End-to-end wiring (
TestAdminServer_SchemaVersionsAreWiredOnTheWire,internal/app/apicontract_test.go) drives the real admin server (buildAdminServer) over every endpoint in the table above and assertsschema_versionis actually present with the expected value on the wire — not merely correct as a Go struct field nobody assigns.
What this contract does NOT cover¶
- CSV export (
/api/flows/export.csv) has no JSON schema; its column order (csvExportHeaderinadmin_flows_export.go) is its de facto contract, and it carries the sameschema_versionas a#-comment line for consistency, not as an enforced shape. - Cross-field relationships — a field's value being consistent with
another field's — are outside a shape-only contract, the same limitation
internal/config/schema.go'sconfig.schema.jsondocuments for the application config. - Semantic drift with no shape change (see the "changing what an existing field means" bullet above) is a documentation and code-review responsibility, not something JSON-Schema-shape comparison can catch.