Skip to content
Skip to main content
Novel Systems home
Decision log
D-011August 3, 2026Accepted

The published URL is the host that answers, and the spec is generated from the schemas

Affects: public/openapi.json, backend/scripts/generate-openapi.ts, backend/src/schemas/*.ts, lib/api-reference.ts, app/api/cpq/calculate/route.ts, scripts/check-api-reference.mjs, docs-site/*.md, .github/workflows/ci.yml

What was decided

Two things, which are really one thing.

The API is documented at `https://novel-systems-backend.vercel.app`, not at `https://api.novelsystems.ca`. The vanity subdomain is not provisioned. It has no DNS record and no Vercel assignment, and until it has both, publishing it is publishing a hostname that does not resolve.

`public/openapi.json` is generated from the server's Zod validation schemas, and every other published copy of the contract is checked against it. The schemas in backend/src/schemas/ are the ones the request actually passes through. If a field is optional there, it is optional in production, whatever a hand-maintained spec says about it.

The alternative, and why it lost

The vanity domain reads better and it is what a customer would expect on an invoice. The argument for publishing it now is that it will exist shortly and the docs would then be correct without a second edit.

That argument is exactly backwards about which failure is cheap. A documented host that does not resolve fails on the first curl an integrator pastes, with a DNS error that tells them nothing about our product except that it does not work. A documented host that is uglier than the eventual one costs a redirect later. One of these is a bad first ten seconds; the other is a formatting concern.

So the rule is: the published server URL is the host that answers today. When api.novelsystems.ca has a CNAME and a Vercel domain assignment, it becomes the first servers entry and the old host stays as the second, and this entry gets amended rather than deleted.

The alternative to generating the spec was maintaining it by hand next to the code — which is what was happening, and it is worth being specific about what that produced, because the failure was not small:

  • A rate limit table advertising up to 10,000 requests per minute across four plan tiers, with burst depths and a concurrent-routes column. The server runs two limiters: 300 per minute globally, and 10 per fifteen minutes on sign-in. Nothing else exists. An integrator sizing a nightly reconciliation job against the Enterprise row would have been throttled at a twentieth of the published figure on the first run.
  • A documented X-RateLimit-* header set, on a server explicitly configured legacyHeaders: false. A client reading its own limit from those headers would have read undefined and concluded it had none.
  • An idempotency header named X-Novel-Event-Id. The dispatcher sends X-Novel-Delivery. A handler keyed on the documented name would have read undefined on every request, treated every retry as a new event, and duplicated whatever it does downstream — which for an accounting integration is a duplicate invoice.
  • Webhook events — quote.created, invoice.generated, change_order.approved — that no code path fires, with an envelope keyed event_id and occurred_at that the dispatcher does not produce.
  • "Retried with exponential backoff for 24 hours", then auto-disabled with an email to the technical contact. The real figure is five attempts spanning about a minute, and there is no auto-disable, no email, and no replay UI. This overstated the retry window by roughly three orders of magnitude, in the direction that makes a customer's outage plan wrong.
  • Three fictional FSM endpoints under /v1/fsm/jobs, and nsk_test_ / nsk_live_ API key prefixes for an API that issues fifteen-minute JWTs.
  • A GraphQL endpoint. There is no GraphQL server.

Every one of those compiled. Every one type-checked. TypeScript will hold a string constant to being a string; it has no opinion on whether the string is true.

Why the generator reads the router as text

generate-openapi.ts imports the schema modules — which are pure — but extracts the route list by parsing the router files as text rather than importing them. Importing a route module transitively reaches db/prisma.ts and then config/env.ts, which calls process.exit(1) when a required variable is missing. A documentation generator that exits silently on a machine without a database is a generator nobody can run, and worse, one that appears to succeed in the shell that has the variables and fails in CI.

This is also why src/schemas/ is side-effect-free by rule. It is the one directory both the server and the tooling import, so anything with a side effect in it becomes a side effect of generating documentation.

How this is prevented from drifting apart again

Four copies of one contract exist and cannot be collapsed into one: the schemas (the truth), public/openapi.json (machine-readable, served), lib/api-reference.ts (the /developers page), and docs-site/*.md (the GitBook source). The frontend cannot import the backend's schemas — separate dependency trees, separate Vercel projects — so the same technique used for the two pricing engines applies here: where sharing is impossible, assert.

  • npm run openapi:check (backend job) regenerates the spec from the schemas and fails if the committed file differs. This holds the spec to the server.
  • npm run check:api-reference (frontend job, and prebuild) asserts that API_HOST in lib/api-reference.ts and DEFAULT_API_BASE_URL in the homepage sandbox proxy both equal servers[0].url, that every path in API_EXAMPLES exists in the spec with the declared method, and that every URL appearing in the sample code resolves to a documented path. This holds the marketing surface to the spec.

Both read their inputs as text rather than importing them, for the reason check-hosts.mjs does: a check that needs its subject to compile first stops running exactly when it is most needed.

docs-site/*.md is the one copy with no automated check on it, because it is prose and the assertions worth making about prose are not mechanical. It was rewritten by hand against the source and it will drift again. The mitigation is that it is the least load-bearing of the four — a reader who follows it and gets a 404 has the spec and the /developers page to fall back on, both of which are now checked.

What would make this wrong

If the vanity domain is provisioned, the first servers entry changes and the checks follow it automatically — that is an amendment, not a reversal.

The generator's text-parsing of the router is the fragile part. It survives formatting but not a restructure of how routes are registered. If route registration moves to a decorator or a manifest, the generator should read that instead, and the failure mode is loud: the spec loses paths, and check-api-reference.mjs fails on the next example that references one.