Fidelity to the real APIs is the point of this project, and a divergence is a bug. That policy
only catches what someone goes looking for. backlot diff runs the same measurement on a
schedule, so a vendor changing its schema in March is noticed in March.
Backlot's own schema against the vendor's own schema, in both directions, over arguments as well as fields:
For a GraphQL source, the schemas themselves — field by field, argument by argument. Backlot's side is the SDL the server builds its engine from; the vendor's side is a live introspection response, which needs a credential.
For a source compared against a document its vendor publishes, the request surface — which
operations exist, which query parameters each accepts, and, for Google, the batch endpoint its
batchPath names. Backlot's side is the app's own /openapi.json. The vendor's side comes in two formats, read by two parsers, because Google does
not publish OpenAPI: OpenAPI documents for GitHub, Jira, Confluence, Notion and HubSpot, and
Google API Discovery documents for Gmail and the Drive family. A source is
compared against as many documents as its vendor publishes for the surface Backlot serves — Jira's
two REST versions, Drive alongside Docs, Sheets and Slides, HubSpot's CRM and associations. All
are public, so these comparisons run with no credential, no quota and no account. Response
bodies are out of scope here: a vendor spec describes them through deep $ref chains that
Backlot's response_model set does not mirror shape-for-shape, so a body diff would report how two
documents are written rather than how two servers answer.
For Slack, which maintains no machine-readable description, its reference documentation:
which methods exist, which arguments each takes and at which type, and which fields each answer
carries and at which type, in both directions. Backlot's side is /openapi.json for the arguments
and a local Backlot's own answers for the fields. See Slack is read off its reference
documentation.
For Microsoft Teams, the type system Microsoft Graph publishes as CSDL at
https://graph.microsoft.com/v1.0/$metadata, anonymously and from the live service. A CSDL
enumerates neither Backlot's paths nor the vendor's: it declares entity types, which of their
properties may be null, whether a type accepts properties it does not name, and the members of
every enum. So Backlot's side is not a document either — it is the responses a running server
actually sends. See Teams is asked against its type
system. Its operations come from the OpenAPI document
Microsoft generates its Graph SDKs from (openapi/v1.0/openapi.yaml in
microsoftgraph/msgraph-metadata), cut to the Teams surface — teams, chats, teamwork, and /me
and /users/{id} with their joined teams, chats and teamwork — and compared by method and path
alone: Graph's parameters are the OData system query options on every collection, which
tests/test_msteams.py holds as measured.
Path templates are compared with placeholders flattened. The vendor calling a segment {userId}
and Backlot calling it {user_id} is not a divergence.
A one-direction, fields-only comparison is worse than none: run against Linear once, it reported a clean schema while ten fields and four arguments were missing, because every one of them was on the side it did not walk.
backlot diff --source slack # no credential: a published document is public
backlot diff --source fireflies # reads FIREFLIES_API_KEY from the environment
backlot diff --source fireflies --credential api_key=… # or pass it, see the caveat below
backlot diff --source fireflies --update-baseline # accept what it found
backlot diff --source fireflies --json # the same result, machine-readableOutput is coloured and wrapped for a terminal — bold for identity, colour for severity, dim for the supporting sentence — and the codes are dropped when anything else is reading, so a redirected run or a CI log stays plain text.
--json prints one object on stdout and nothing else there, so it pipes:
backlot diff --source linear --json | jq '.new[] | select(.severity == "breaking") | .path'It carries source, endpoints (a list, one per compared contract — a published document, or
the URL introspection was read from), total, new and resolved — or, with
--update-baseline, acknowledged, unacknowledged and the baseline path. Exit codes are the
same either way.
Each source declares the credentials it needs, by a logical name and the environment variable
it is read from, and --credential NAME=VALUE repeats for as many as a source declares. A single
--token would not stretch: a vendor authenticated with SigV4 needs an access key and a secret,
and a Google service account needs a client id, a client secret and a private key. Declaring them
also means a name a source does not take is refused rather than ignored — ten of the twelve
sources need no credential at all, and that is exactly where a silently accepted option goes
unnoticed.
Prefer the environment. A value passed on the command line is visible to any process that can run
ps, and it lands in shell history.
Exit codes are distinct on purpose: 1 is "the measurement and the baseline disagree" — a new
divergence, or a baseline entry for one the vendor no longer has — 2 is "the vendor's contract
could not be read", and 3 is "a credential this source declares is not set anywhere". A vendor
outage is not a fidelity finding, and a credential nobody set is this repository's problem rather
than either — the scheduled run fails on 3 for that reason, because warned about and left green
it would leave the two introspection sources uncompared night after night.
Introspection is the contract: a field absent from it is absent from the API. A published OpenAPI
document only describes the contract, and it lags, omits paid tiers, and documents one verb where
the server accepts two. Every extra_operation this has reported so far was the spec being
incomplete rather than Backlot being wrong:
- GitHub's spec declares a
GETfor neithercontentswithout a path nor the legacy per-sha statuses read, and describes both in the prose of the operations they belong to. Measured against api.github.com, both answer200. - Atlassian's published Confluence v1 document no longer describes the content and space reads
Backlot serves:
content,content/{id}with itschild/page,child/comment,child/attachmentandlabel,spaceandspace/{key}. It still describes 65 reads —content/search,content/{id}/descendant,group,labelanduser/currentamong them — and the writes. The v1 reads Backlot serves are deprecated in favour of Confluence REST v2, and each 200 names a removal date already past, so on that source this comparison currently covers almost nothing.
So on a REST source, read missing_* as reliable and extra_* as undocumented by the vendor —
verify by hand, never as proof of a bug. Those measurements are what the baseline notes carry.
Slack's OpenAPI document, slackapi/slack-api-specs, is archived, its last content change dated
2020-10-06: it declares 174 methods where the reference lists 340, canvases.*, bookmarks.*
and assistant.threads.* among those it lacks. The copy api.slack.com/specs/openapi/v2/ still
serves is the same version, 1.7.0, with 170. What Slack maintains is the reference at
docs.slack.dev, which serves every page as markdown (docs.slack.dev/llms.txt). Markdown is
prose, not a schema, so each part is read only where the page gives it one fixed spelling, and a
page that stops spelling it that way is a contract that could not be read (exit 2), never a
divergence. Measured 2026-10-21 over all 340 method pages and the nine object pages:
- Methods are the index's table rows, each a method's name linked to its own page. Every row
but the header must read that way. A method's own page is read only where Backlot serves it,
and its
method_namefrontmatter must be the name the index linked. - Arguments are the lines of a page's
## Argumentssection: 1516 lines of one shape, a bolded code name, an optional type (string968,boolean131,array129,integer72,object16,number6, none 194) andRequiredorOptional. Every bolded code name anywhere in a section is one of those lines, and every page butadmin.audit.anomaly.allow.getItem, which Backlot does not serve, reads at least one, so a served page that reads none is refused. An argument with no type is compared by name only;integerandnumberare one JSON type. - Fields are read off the JSON examples in a page's
## Responsesection, success examples only ("ok": true). 608 fenced blocks there open as JSON: 596 are JSON as written and 3 more once an elision (...,// ...) and a trailing comma outside every string are taken out. The 9 left, single-quoted strings, missing commas and one JSON Lines file, are all on methods Backlot does not serve, whose pages are not read. A key that is not a field name (a profile's custom-field ids, a file's per-channelshares, a huddle's per-personparticipants_events) collapses to{}. - An example shows one sample, so where the section links an object page
(
reference/objects/user-object.md), that page's Field/Type/Description tables add every field the object may carry. The first table names the object's own fields, and the object goes where an example carries the container sharing the most of them (members[]onusers.list,useronusers.info); a later table goes under the container its rows share the most names with (the user object's second table is itsprofile). A type cell reads as a JSON type by its first word —String,Boolean,Integer,Unix timestamp,Array of …, a backticked id such as`user_id`— which every one of the 513 typed rows' cells does butlast_read'sTimestamp, a string or a number as Slack's timestamps go, which types nothing. So do the 25 rows of the conversation object's booleans table, which has no Type column.
Backlot's side of the fields is what a local Backlot answers on a corpus slack_docs supplies,
over every channel, thread and person it holds and with the arguments the reference's examples
were sent where an answer echoes them (api.test's foo, conversations.history's latest, a
page with more to follow), so a finding reproduces with backlot.serve(records=…).
A field Backlot answers under a list the documentation never looks inside, or the documentation
under one Backlot answered empty, is compared against nothing and not reported; an empty object
Backlot answers is looked inside, and an example's empty object is not. null beside a type is not
another type.
The answer is not read off slack.com. Measured 2026-09-23, which fields a live workspace's answer
carries depends on what it holds and who asks: one channel holding only join messages left out
eleven conversations.history fields a threaded one carries, two threads differed by thirty-two
conversations.replies fields, has_2fa comes back only to an admin and profile.email only to a
token with users:read.email. A comparison against that would move with the workspace. Nor is a
model asked to read the pages: every part compared reads deterministically as above, and a reader
that answers differently run to run would rewrite the baseline with it.
A method is one operation, named as Slack names it (conversations.list): a page names one
http_method, and slack.com answers every method Backlot serves over GET and POST alike.
The reference documents less than slack.com serves. A message's thread, reaction, edit and file
fields are absent from conversations.history's examples and documented only in prose elsewhere,
so Backlot serving them reads as extra_field; each such entry in the baseline carries a note with
the vendor evidence for it.
breaking
: Backlot contradicts the vendor — a field or argument the vendor does not have, or the same name
at a different type. Code written against Backlot compiles and then behaves differently against
the real service, which is the failure this project exists to prevent. Always a bug.
gap
: The vendor has surface Backlot does not. Backlot serves a deliberate subset, so most of these are
scope. A gap on a type Backlot does serve is worth reading: it is where a real client's query
fails against Backlot.
Types the vendor declares and Backlot never mentions are not reported one per type — the ninety-odd
of them would bury everything else. They surface where they matter, as a missing_field on a type
Backlot does serve.
S3 dispatches on the query string, not the path: GET /{Bucket} is ListObjects,
?list-type=2 is ListObjectsV2, ?location is GetBucketLocation, and ninety more operations sit
at the same path. Backlot serves them from four catch-all routes that read the query string
themselves, so a path-and-parameter diff pairs every S3 operation with the same route and reports a
clean match every time — a green check that means nothing.
So S3 is compared by asking a running server instead. Backlot starts on a free port, every
operation botocore declares for the S3 endpoint is sent to it signed, and the answer is classified.
A write is refused when it is answered with any error, which is what it is owed where data enters
only through backlot import, and answered_write, breaking, when it is not. An operation is
implemented unless it is refused or a case below makes it missing. A read is:
- refused with
NotImplemented— the honest answer for an operation Backlot does not serve. Agap, and the operation ismissing. - answered with the error real gives that operation — the 404
NoSuchCORSConfigurationfor a bucket nobody configured, for one;REAL_ERRORSinbacklot.fidelity.s3_probeholds each, as measured. No finding. - answered with any other error — one real does not give that operation, whether real answers
it with a 200 or with another error.
unexpected_error, and breaking. - answered distinctly — implemented. No finding.
- answered with the body the same path returns when nothing selects an operation — Backlot
neither implements the operation nor refuses it, so the caller parses another operation's body
under a 200, with no error and no log line.
silent_fallthrough, and breaking, and the operation ismissing. Only an operation a query string selects is compared: a bare form such as ListObjects is that body.
Two operations are not asked, because the model sends each only to another endpoint, which Backlot
does not serve: ListDirectoryBuckets to the S3 Express control endpoint, signed for s3express,
where its GET / at the S3 endpoint is ListBuckets, and WriteGetObjectResponse to the S3 Object
Lambda endpoint, signed for s3-object-lambda. Each is missing, and a gap.
"The same body" is the status, the XML root element and the set of direct child elements. The
children are what keep the two listings apart: ?list-type=2 and a bare bucket GET are both
200 <ListBucketResult>, and only KeyCount against Marker says they are different answers.
Requests are signed with backlot.sigv4 — the module that verifies them —
so the probe adds no dependency and a change to signing breaks both sides at once.
Every discovery document names a batch endpoint in its top-level batchPath, and none declares it
under resources. Measured 2026-09-17:
| Document | batchPath |
Host (rootUrl) |
Backlot answers it at |
|---|---|---|---|
gmail:v1 |
batch |
gmail.googleapis.com |
/batch |
docs:v1 |
batch |
docs.googleapis.com |
/batch |
sheets:v4 |
batch |
sheets.googleapis.com |
/batch |
slides:v1 |
batch |
slides.googleapis.com |
/batch |
drive:v3 |
batch/drive/v3 |
www.googleapis.com |
/batch/{api}/{version} |
Each API answers batch at the root of its own host; Drive alone sits on the shared
www.googleapis.com, and the drive/v3 is what discriminates it there. Backlot collapses those
hosts onto one origin, so one served route stands in for several documents.
That is why the endpoint is not a mount. A mount selects paths for the path diff, which pairs
operations — and no document declares this one, so both routes would report as surface Backlot
invented. The methods that carry the word are a different thing and are compared as operations
already, and measured 2026-09-17 there are eleven of them: messages.batchModify and
messages.batchDelete on Gmail, documents.batchUpdate on Docs, seven batch* methods on Sheets,
presentations.batchUpdate on Slides, and none on Drive. Measured across the same five documents,
no path any of them declares is a batchPath value.
So each Google source names the batch routes it speaks for in batch_mount, and the field is
compared against them on every run. Three things are reported:
| Kind | Severity | Fires when |
|---|---|---|
extra_batch_api |
breaking | a document declares no batchPath, and Backlot goes on answering batch for that API |
missing_batch_path |
gap | a declared value no route the source mounts answers — identified by the document and the value, so acknowledging one move does not cover the next |
extra_batch_route |
breaking | a mounted route no document of that source selects |
The third is the direction no single document can answer, and it is why the routes are named per
source rather than matched wherever they fit. Were Drive to move to its own host, every document
would declare batch, every declared value would be answered, and /batch/{api}/{version} would
go on being served for nobody behind a green check. Gmail's source mounts only /batch, so it
reports nothing about a route it never spoke for.
What the batch endpoint does — the multipart envelope, the Content-ID pairing, the outer
credential applying to a sub-request that carries none — is behaviour, and is held by
tests/test_google.py rather than by this comparison.
Graph publishes a machine-readable contract, richer than any other source's, and it is not a path
map. So Backlot's side cannot be /openapi.json: the response models under /msgraph allow extra
fields and name none of them, deliberately, because a Graph response carries thirty keys and
mirroring that in a Pydantic model would be a second declaration to keep in step.
What the vendor's declaration is compared against is a sample of a running server's answers.
Backlot starts on a free port and samples the Teams read surface as the service account. The walk
checks first pages only and expanded replies returned inline; it does not follow collection or
reply continuation links. It is a shape sample, not an exhaustive corpus crawl or pagination check.
Sampled messages and the nested objects the walk enumerates (body, from, channelIdentity,
reactions, attachments and mentions) are checked against their declared types:
- a property served on a closed type that the type does not declare
- a property declared
Nullable="false"served as null - an enum-typed property carrying a value the enum does not list
All three are classified as breaking by Backlot's schema-conformance gate. That classification
is not proof that every official SDK rejects the payload: the Python Graph SDK, for example, can
retain an unknown property in additional_data. Null and enum handling also depends on the client.
The gate reports a declaration mismatch, not a guaranteed deserialization failure.
A property the document declares and the response omits is not reported. Every one of these
APIs projects — Graph returns eleven of user's eighty-one properties unless $select asks for
more — so "declared, not served" is the normal case and reporting it would bury the three above.
Nullable="false" is read as not null when returned, never as always returned.
Two more things the document says are honoured rather than assumed. team and user are
OpenType="true", so a property they do not declare is something the vendor permits, and they
are not held to the first check at all; chatMessage, channel and chatMessageReaction are
closed, and they are. And a type name is keyed by its namespace: user, identity and group are
each declared in more than one of Graph's schemas, and a bare-name lookup answers with whichever
came last.
This is not a probe. A probe is for a contract that cannot be read off a document at all; here every finding names a property, a type or an enum member the vendor declared and quotes what it declared about it. What the two share is only that Backlot's side comes from a running server, which is a consequence of the vendor describing types rather than routes.
Which request answers with which declared type is the one thing no document carries, so
msgraph states it and the source names that walk.
Deriving it from the source's name would turn a rename into an ImportError on a nightly run.
backlot/fidelity/baseline/<source>.json is the surface a source was last measured as, with the
divergences already read and accepted written onto the elements they name, so a run reports what
is new. Without it the first Fireflies run lists fourteen root fields Backlot never claimed to
serve, and the first Linear run lists nearly eight hundred divergences — by the third run nobody
reads the output. The baselines ship inside the package, so an installed copy can be compared
against its vendor without the repository, and can name offline the operation, or root query
field, a request asked for.
The surface takes the shape of the contract:
- A published document or the S3 model —
operations, one entry per operation either side has.statusisimplemented(both sides),missing(the vendor has it and Backlot does not serve it),extra(Backlot's only) or, for S3,refused(a write answered with an error).idis the vendor's own name for it: theoperationIdfor an OpenAPI document, the methodidfor a Discovery document, the botocore operation for S3, and for Slack the method name, which is also the operation itself (conversations.list). A served route with no path item in its document, or a Slack method the reference does not list, carries noid. A parameter appears only where it diverges, underparamsof its operation, and so, for Slack, does a field of a method's answer, underfields. A Google source addsbatch: each document's declaredbatchPathwith whether a route answers it, and each of Backlot's batch routes with whether a document selects it. Each Google operation carries itsresponse, the message the document says it answers with. - A Discovery document a walk reads —
typesbesideoperations, keyed by the document's name and the message's (sheets.Spreadsheet): every message the responses of the methods the walk reads reach, with every field and thetypeit holds, since afieldsmask is read against all of them and no served schema answers that offline. Which reads to make is the document'swalk, one ofbacklot.fidelity.google_walks(sheetsfor Sheets,docsfor Docs). A message one of its answers was isimplemented, each field they carriedimplemented, each they did notmissing, a gap, and each the message does not declareextra, breaking; a message none of them was ismissing, with no finding. There is noroot: the operations are underoperations. - A GraphQL schema —
types, every type Backlot declares with every field, enum value and union member either side has under it, and every argument either side has on a field Backlot serves, each with astatus, andtypewhere only the vendor has the member. A type the vendor declares as another kind lists no members. The vendor's types Backlot does not declare are not listed.root.querynames the type whose fields are the operations. - A CSDL — the same
typesshape, built from what the walk was served.root.queryis the entity container (GraphService), and its fields are the entity sets and singletons the walk's requests start from. Under it, each type the walk checked lists every property it was served:implementedwhere the type declares it,extrawhere it does not. Besidetypessit theoperationsof Graph's OpenAPI document in the first bullet's shape, with Microsoft'soperationIdas each one'sid; telemetry names a Teams call by them.
{ "operation": "GET /repos/{}/{}", "id": "repos/get", "status": "implemented" }Accepting a divergence is a file change, so it goes through review like any other.
--update-baseline writes kind and severity onto the element the finding names; the note beside
them is added by hand in that review. Most are plain — surface the vendor has and Backlot does not —
and a note is what records a decision someone had to make: a family accepted as a whole, or a shape
where a stand-in would break a client rather than merely be absent. Those notes are the written
record of what Backlot does and does not claim about a vendor. Under types.Query.fields in
fireflies.json:
"active_meetings": {
"status": "missing",
"type": "[ActiveMeeting!]",
"kind": "missing_field",
"severity": "gap",
"note": "Meetings in progress right now. Backlot serves a fixed corpus, so there is no live state to report."
}--update-baseline acknowledges gaps freely and never acknowledges a breaking finding, which
is a bug by this project's own rule rather than a gap. It still writes the gaps — leaving them out
would bury the breaking ones under hundreds of repeats every run, which is the same silence by
another route — and exits 1 naming what it left live. The element a breaking finding names is
written all the same, bare.
There is no flag that silences one. Acknowledging a breaking divergence is a hand edit to the
baseline file — kind, severity, the detail being accepted and a note saying why the vendor's
shape is not being matched, added to the element — so what a reviewer sees in the diff is the
reasoning rather than a flag on a command nobody kept. One added that way survives later rewrites:
--update-baseline rewrites the file, it does not re-litigate it.
The element is the one the finding's path names. Transcript.duration is
types.Transcript.fields.duration; an argument, Query.transcripts(limit), is under that field's
args; an enum value or a union member is under its type's values or members. An operation is
the operations entry whose operation is the path, and GET /x?name is params.name in it;
users.info user.profile.email is fields."user.profile.email" in users.info's entry.
severity is breaking or gap, in lower case, and the command refuses a file carrying any other,
or a note or detail on an element with no kind, rather than drop it. The detail is compared
character for character, so copy it from --json (.new[].detail): the terminal form wraps a long
one across lines.
It stops covering a shape that moves, though. A gap is acknowledged by what it names — the vendor
has surface Backlot does not, and the vendor restating it at a new type does not change what was
accepted — so its detail is not kept. A breaking entry is acknowledged by what it names and
by its detail, because there the detail is the contradiction: an entry reading
vendor: Int, Backlot: Float says nothing about a vendor now serving String, and going on
silencing it is exactly the March drift this command exists to catch. Such an entry is reported
again, and left in the file as written — the note is the record of the reasoning, and a rewrite
does not delete it — until someone reads the new shape and rewrites it by hand.
A run also reports resolved entries: an acknowledged divergence the vendor no longer has. The
baseline has gone stale, which is its own kind of drift, and the run exits 1 for it as for a new
finding. A stale gap entry is not inert: it is matched by what it names alone, so it would
acknowledge, unreviewed, whatever the vendor later publishes at that name, and the GitHub, HubSpot,
Notion, Slack, Atlassian and Google routers answer the path of a missing operation's entry as an
operation the vendor publishes. Where Backlot is what changed — a route, a parameter or a field
added while the file still records it missing — tests/test_fidelity.py reads the served surface
off the app and the SDL, and S3's off a local Backlot, with no vendor network, and fails until the
file is rewritten. Rewrite it with --update-baseline. By hand, delete the element's kind,
severity, detail and note, and the element itself only where neither side has it any more: an
element Backlot serves stays in the file, and deleting it fails that test.
.github/workflows/fidelity.yml runs every source daily,
and on demand through workflow_dispatch. Never on a pull request: drift is this project's bug,
but it is never the bug of whichever pull request happens to be open when a vendor ships a change,
and a contributor fixing a typo must not be blocked by it. A new divergence or a stale entry opens
an issue and turns the scheduled run red instead.
A source has one open issue at a time. It is opened under the fidelity label — the vendor API
defines the right answer, so closing one needs a measurement against it — titled with the date the
divergence was first seen, and found again on later runs so triaging one does not produce a
duplicate the next morning. Its body carries the latest run's report, and a comment is posted only
when that report is not the one already there.
Closing it closes the record. The body and the comments stay as the account of what was triaged, and a source still diverging the next morning gets a new issue that links back to the last one — a closed issue is never reopened and never written over.
Sources are named the way the rest of Backlot names them — the source_type a BYO record carries,
which is also what backlot/schemas/ defines. Fidelity keeps no source list of its own; it says how
each of those is compared, and a test fails if the two sets ever drift apart.
| Source | Compared through | Credential |
|---|---|---|
| Fireflies | GraphQL introspection | api_key — FIREFLIES_API_KEY, sent as Bearer <key> |
| Linear | GraphQL introspection | api_key — LINEAR_API_KEY, sent bare |
| Slack | reference documentation — methods, arguments and answers, see Slack is read off its reference documentation | none |
| Gmail | Google Discovery, operations and batchPath |
none |
Google Drive (google_drive) |
Google Discovery — Drive, Docs, Sheets and Slides, operations and batchPath |
none |
| GitHub | published OpenAPI | none |
| Jira | published OpenAPI, v2 and v3 documents | none |
| Confluence | published OpenAPI (v1) | none |
| HubSpot | published OpenAPI, CRM v3 and Associations v4, resolved through the API catalog | none |
| Notion | published OpenAPI | none |
Microsoft Teams (msteams) |
Graph's published OpenAPI for operations, and its CSDL against served responses — see Teams is asked against its type system | none |
| Amazon S3 | botocore service model, probed — see S3 is asked, not read | none |
The credential column is measured, not read off a page: Linear's personal API keys go in bare, and
sending Linear a Bearer prefix is answered 400, not 401.
A source is compared against as many documents as its vendor publishes for the surface Backlot serves. Three need more than one: Jira's v2 and v3 REST APIs are separate documents, a Drive file is also read through Docs, Sheets and Slides, and HubSpot's associations are their own API at their own version.
Jira's /rest/api/2 paths are served because the clients call them — atlassian-python-api
hardcodes api_version = "2" in its Jira constructor, and the jira PyPI client defaults
rest_api_version to "2" and probes /rest/api/2/serverInfo on connect. They are compared
against Atlassian's own v2 document, which it publishes beside the v3 one: the naming is
swagger[-<apiVersion>].<oasVersion>.json, so the suffix-less swagger.v3.json is the v2 API.
Every path Backlot declares in its own /openapi.json is under one of those documents'
mounts, named as a Google source's batch route, probed, or listed in UNCOMPARED with the reason
no document covers it — Backlot's own /health, /oauth2/token and /_meta, none of which a
vendor publishes because none of them is a vendor's. A declared path in none of the four fails the
suite.
Declared, not served: six live routes are include_in_schema=False and so invisible to that check.
Four are FastAPI's own (/docs, /docs/oauth2-redirect, /openapi.json, /redoc). The other two
are the GraphQL endpoints at /fireflies/graphql and /linear/graphql, which their own comparison
covers — introspection, not a path map, so there is no mount to say so. Walking app.routes
instead was ruled out for gen_docs.py, and the same reasoning holds here.
Served, not declared: a HEAD under /github, /atlassian, /notion, /drive/v3, /gmail/v1,
/health or /_meta is answered as the GET with the body left off
(backlot.main.answer_head_as_the_get_without_its_body), and no head operation is written for it,
because real's own description declares none either. Each vendor prefix among those answers a
HEAD that way on every route measured, so what can diverge here is whether the method is served,
not whether it is documented. But a path diff reads methods off the two documents, and neither
mentions this one, so nothing here would catch it going away. The middleware's prefix tuple is the
record of which vendors it covers; a vendor joins it once its own HEAD is measured. On the three
Google editor families a HEAD is their host's empty text/html 400 instead
(backlot.errors.google.head_refusal). What the HEAD declares about the body's length is a second
such gap: which Atlassian and Google answers declare it is
backlot.errors.atlassian.head_content_length's and backlot.errors.google.head_content_length's
to say, and tests/test_atlassian.py and tests/test_google.py are the record.
An OPTIONS is a third. Jira answers a caller it can name 200 with the methods that route takes,
and Confluence answers a 404 or a 403 page by Accept on every route but search, which answers
its own way (backlot.errors.atlassian.confluence_options). No document here declares any of it —
real's own description has no options operation and neither does Backlot's — so the two Allow
tables in backlot.errors.atlassian and the tests beside them are what hold them.
What Accept does is a fourth. Both products refuse one that admits none of the types a route
answers in with a 406 ahead of most of their other refusals, and send several refusals in the shape
it asks for (backlot.errors.atlassian.not_acceptable and rendered). Neither description declares
a 406, nor that a refusal's media type follows Accept, so tests/test_atlassian.py is the record.
The five x-ratelimit-* headers are the same kind of gap. Every /github answer carries them but
the ones backlot.main.report_github_rate_limit names, and the comparison reads parameters and
operations off the two documents and never a response header; real's description declares three of
the five, on GET /rate_limit's 200 alone, and Backlot's document declares none. The tests are the
record here (tests/test_github.py, the rate-limit test), not the baseline. x-github-request-id
is another: every /github answer carries it (backlot.main.put_a_request_id_on_github_answers),
and test_github_every_answer_carries_a_request_id in the same file is its record.
The Allow on S3's sub-resource 405s is another. A HEAD carrying a sub-resource selector is 405
on both sides, and real names the methods that sub-resource takes where Backlot names only GET,
for the selectors its own GET at that path answers (backlot.routers.s3._head_refusal). A GET
or a HEAD naming a selector no GET takes, ?delete and a key's ?select among them, is 405 on
both sides too, and real names the write methods where Backlot sends no Allow at all
(backlot.routers.s3._BUCKET_READ_REFUSED, _OBJECT_READ_REFUSED). The s3 probe reads no
response header, so neither difference is visible to the diff and a hand-written acknowledgement
would come back as "acknowledged but no longer diverging" on the next run. tests/test_s3.py is the
record: the allow column of
test_s3_a_method_this_router_does_not_serve_answers_reals_own_refusal for the GETs, and the
bucket configuration and object sub-resource tests for the HEADs.
Confluence is not yet fully covered: its reads now live in a v2 document whose paths are shaped differently from the v1 ones Backlot serves, so the eight reads Atlassian has removed from the v1 document are acknowledged rather than compared.