Skip to content

feat(0124): expose the OpenAPI spec through API Gateway - #169

Merged
adamkoot merged 17 commits into
developfrom
feat/0124_expose-openapi-spec-through-gateway
Aug 6, 2026
Merged

feat(0124): expose the OpenAPI spec through API Gateway#169
adamkoot merged 17 commits into
developfrom
feat/0124_expose-openapi-spec-through-gateway

Conversation

@adamkoot

@adamkoot adamkoot commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Summary

  • Map GET /api-docs-json through API Gateway as a keyless Lambda proxy onto the existing api-handler. The route was defined in the axum router but never mapped, so in production the spec was reachable only by running extract_openapi locally. Cached 3600s at the stage cache, matching the handler's Cache-Control — the document is byte-identical for the life of a deployment.
  • Anonymous by design: an API description is public documentation, and gating it behind a key the reader does not have yet is a self-service dead end. /health already set the precedent, and the in-app gate already exempted the path.
  • Stamp servers in production via a new apiBaseUrl config → API_BASE_URL on the handler, validated at synth for the stage-prefix trap. It is configured rather than derived from api.url because ComputeStack is a dependency of ApiGatewayStack — reading the gateway's URL there closes a cycle.
  • Declare the x-api-key security scheme document-wide with explicit opt-outs on the two anonymous routes, and document the 401/403 that seven key-gated operations previously omitted (ErrorEnvelope is now a published schema).
  • Add two CI gates: npm run openapi:lint (Redocly recommended-strict over the extracted document) and npm run openapi:verify-routes, which compares the synthesized CloudFormation template against the extracted spec so neither side can drift. Both confirmed to fail on a seeded regression.

Notes

Two acceptance criteria are about the deployed API — the live GET …/production/api-docs-json fetch and confirming the advertised servers URL serves a route. Both need a deploy, so task 0124 stays active rather than moving to archive. docs/scf/api-endpoints.md carries the verification curl.

info.license is deliberately left empty and its lint rule turned off: the repo has no LICENSE file or Cargo license field, and declaring one in a public API document is a business decision. Spawned as task 0155 (renumbered from 0144, which PR #168 had already claimed).

The document is OpenAPI 3.1.0, not 3.0 as the task's AC wording says — utoipa 5 has no 3.0 emit mode, and reaching it would mean downgrading the crate.

`/api-docs-json` was defined in the axum router but never mapped by API
Gateway, so in production the spec was reachable only by running
`extract_openapi` locally. Map it as a keyless Lambda proxy onto the
existing api-handler, cached 3600s at the stage cache to match the
handler's Cache-Control — the document is byte-identical for the life of
a deployment.

Anonymous by design: an API description is public documentation, and
gating it behind a key the reader does not have yet is a self-service
dead end. `/health` already set the precedent; the in-app gate already
exempted the path.

`servers` is now stamped in production. `apiBaseUrl` is new config
(validated at synth for the stage-prefix trap) passed to the handler as
`API_BASE_URL`, rather than derived from `api.url` — ComputeStack is a
dependency of ApiGatewayStack, so reading the gateway's URL there would
close a cycle.

Linting the document surfaced two real gaps, both fixed: `extract_openapi`
emitted a `servers`-less variant of the spec (now shares `stamp_servers`
with the served one), and seven key-gated operations documented no
401/403 at all (now documented, with ErrorEnvelope as a published schema).
The `x-api-key` scheme is declared and required document-wide, with
/health and /api-docs-json opting out explicitly.

`npm run openapi:lint` extracts the served document and runs Redocly's
recommended ruleset over it; wired into the rust CI job.
Review of the previous commit found two guards that looked like they
worked and did not.

`redocly lint` exits 0 on warnings, and under `recommended` most checks —
including operation-4xx-response, the rule that found seven key-gated
operations documenting no 401/403 — are warnings. The CI step would have
accepted the exact regression it was added to catch. Extend
recommended-strict instead, and pin that rule to error so a switch back
to recommended cannot silently demote it.

"Route coverage, both directions" was enforced against a hand-written
list mirroring the CDK source, so it could not catch a route added to
axum with a plain .route() call and never mapped — which is precisely how
/api-docs-json went unroutable. Add verify-openapi-routes.mjs, deriving
both sides from the synthesized template and the extracted document, and
run it in CI after synth. Same reasoning as lambda-assets.sh (task 0077).

Both fixes verified by confirming they fail on a seeded regression.
`npm ci` failed on CI with "Missing: yaml@2.9.0 from lock file". Adding
@redocly/cli was done with npm 11.17.0 on Node 26, which pruned
`node_modules/vitest/node_modules/yaml` — an optional peer entry that
the Node 22.22.0 / npm 10 toolchain in .nvmrc still resolves and
requires. The lock was regenerated under 22.22.0, so the diff against
develop is now purely the @redocly/cli addition.

Verified by running `npm ci` on a clean tree under 22.22.0: the previous
lock reproduces the CI failure, this one exits 0.
The task asked to note the ordering for the custom-domain change. 0126
already said to update `servers` alongside the docs; now that 0124 has
landed it can name the single config value that does it, and the two
gotchas around it.
A second validator, IBM's `ibm-openapi-validator`, reported 13 errors on
the document Redocly passes cleanly. Seven were real and are fixed here;
all seven are cases where the code already had a bound the document did
not state.

- The five ledger-sequence fields are `u64` in Rust because ClickHouse
  returns `UInt64`, but a Stellar ledger sequence is `uint32` in the
  protocol's `LedgerHeader`. The document promised a range four billion
  times wider than reality; `maximum: 4294967295` is a domain fact, not
  a limit we impose.
- `limit` has been rejecting `0` and anything over 200 with a 400 since
  it was written, and said so nowhere a client could read. Now declared.
- utoipa published the whole `/health` rustdoc as `summary` — 223
  characters of maintainer-facing prose where a label belongs. Split
  into `summary` plus `description`.

The remaining six are deliberate: four are OpenAPI 3.1 constructs the
validator judges against 3.0's rules, one is a maximum that does not
truthfully exist (`Candle.trade_count`), and one is the `/api-docs-json`
path, kept rather than renamed. The task file records the full
accounting and the reasoning for each.
PR #169 review. Three of the four gates this task shipped had holes, all
of the same shape as the two already in Issues Encountered — guards that
looked like they worked.

`openapi:verify-routes` did not chain `openapi:extract` the way
`openapi:lint` does, so it compared the template against whatever
target/openapi.json happened to hold. A stale file reads as a pass, or as
drift nobody can reproduce.

The rust paths filter omitted package.json and package-lock.json while
@redocly/cli is a devDependency only this job runs. A PR dropping or
bumping it merged green and broke the next author to touch packages/**.

HTTP_METHODS included options and head, so 0126's addCorsPreflight would
have failed the gate with the remedy "add a #[utoipa::path] for each" for
methods OpenAPI does not conventionally describe. Both are now excluded
from both sides — excluding one side only manufactures drift. ANY is
rejected loudly instead: it can never match an operation key, and
skipping it would hide a mapped route from the check.

The fourth was not a gate. Seven key-gated operations documented 401/403
but not 429 or 500, the two statuses a partner actually meets — the usage
plan throttles, and all seven reach errors::db_error. A generated client
fell into its unexpected-response branch for both. The 403 description
named the usage plan, which is what returns 429; 403 is the key being
missing or unauthorized.

Verified by injection, not by reading: an OPTIONS method added to the
synthesized template is ignored, an ANY method exits 1, and deleting
/v1/prices/batch from the document still reports it undocumented.

cargo test --workspace 223 passed; redocly lint 0 errors 0 warnings;
verify-routes agrees on all 9 routes.
karczuRF added a commit that referenced this pull request Aug 5, 2026
The ID was claimed twice. `0145_BUG_synth-not-run-on-infra-only-prs`
landed on develop with PR #165 (the 0110 won't-do closure), while the
unmerged PR #168 branch had already claimed 0145 for the pre-roll
`argMax(close_usd, ...)` guard.

The pre-roll task keeps the number: it is referenced nine times across
four files, gates both the 0088 pass-2 pre-roll and 0136's 07-21 gap
pre-roll, and develop's own generated index already resolves 0145 to it.
This task is referenced three times and moves.

0152 is deliberately left free for the OpenAPI license task on PR #169,
which collides with 0144 the same way and is not ours to renumber.

Nothing about the synth work itself changed. lore/README.md is not
regenerated here — it already points 0145 at the pre-roll task, and
regenerating on this branch would conflict with PR #168's index.
Reverses a decision from this task's own validator accounting, where
`Candle.trade_count` was left unbounded as "no truthful maximum exists".
The premises were right — a trade count has no protocol bound, `u64::MAX`
overflows JSON's safe-integer range, and a domain figure would be
invented — but the conclusion did not follow. The ceiling is the
safe-integer range itself.

`2^53 - 1` is the largest integer an IEEE 754 double represents exactly,
and JSON has no integer type, so above it a client's parser silently
rounds. Publishing it states a fact about the wire format rather than a
limit we impose: values above it cannot be delivered correctly whatever
ClickHouse holds. Same kind of claim as the ledger-sequence bound, taken
one layer down — that one is a protocol fact, this one is transport.

Real Stellar volumes sit ~10 orders of magnitude below it, so it never
binds and cannot make a future response contradict the document, which
was the actual worry behind leaving it out. It remains a published
ceiling rather than a runtime clamp, the same caveat the review raised
against the ledger fields.

ibm-openapi-validator --errors-only: 6 -> 5, the remainder being the four
3.1-vs-3.0 entries and the deliberate path-casing one. Redocly still 0
errors 0 warnings; cargo test --workspace 223 passed; verify-routes
agrees on all 9 routes.
The accounting said what the six remaining errors were but not what to do
about them, and named only the utoipa downgrade as the route to zero.
Two things learned since are worth not re-deriving.

Zero is reachable without touching utoipa: the validator takes a Spectral
ruleset, and switching off the three offending rules produces "passed the
validator". Measured, not assumed. Declined anyway — the document is
already correct 3.1, so the choice is between disabling rules globally
(broader than the two path entries in .redocly.lint-ignore.yaml) and
down-converting to 3.0 before linting, which breaks decision #9 by making
the linted document stop being the served one.

More important, errors are not where the ruleset stops. At warning level
it demands ErrorEnvelope carry `trace` and an `errors` array — IBM's
error-container shape. No toggle removes that honestly, so adopting the
tool means redesigning the error body on every endpoint and breaking
every client. That is an API redesign, not a lint cleanup, and it is now
attached to the open question for Oskar so the cost is visible when the
question gets asked.
@adamkoot

adamkoot commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator Author

Why the IBM validator still reports 5 errors — and why that's the intended end state

npx ibm-openapi-validator target/openapi.json --errors-only went 13 → 5 over this branch. The last commit closed one more (trade_count); the remaining five are deliberate. Since a reviewer will hit this number, here is the reasoning in one place.

What remains

Count Rule Cause
2 ibm-schema-type-format Option<T> renders as oneOf: [{type: "null"}, …]. "type": "null" is valid JSON Schema 2020-12 / OpenAPI 3.1
2 no-$ref-siblings Same construct: {$ref, description}. Legal in 3.1, illegal only in 3.0
1 ibm-path-segment-casing-convention /api-docs-json is not snake_case — a recorded decision, not an oversight

All five are the tool applying OpenAPI 3.0 rules to a 3.1 document. utoipa 5 has no 3.0 emit mode (OpenApiVersion has one variant), so 3.1 is not a choice this branch made.

The distinction being drawn

trade_count was fixed in f544017, because it was a real gap: the field published no maximum, and one genuinely exists — 2^53-1, the largest integer a JSON number carries without precision loss. That is a fact about the wire format, in the same family as the uint32 ledger-sequence bound, just one layer down.

The remaining five are not gaps. Making them disappear from the document would mean dropping Option, deleting field descriptions, or down-converting to 3.0 — each one degrading the API description to satisfy a rule that does not apply to it.

Zero is reachable, and was measured

ibm-openapi-validator -r <ruleset> accepts a Spectral ruleset. Extending @ibm-cloud/openapi-ruleset and switching off those three rules yields passed the validator. Verified, not assumed. Not taken, because:

  1. It is a global rule switch, broader than the two narrow path entries already in .redocly.lint-ignore.yaml.
  2. The alternative — down-converting to 3.0 before linting — breaks design decision research(0022): SDEX filter + decode + bucket spec drafts #9: the linted document would stop being the served document, which is exactly the drift stamp_servers() was written to prevent.

The part that matters most

Errors are not where IBM's ruleset stops. At warning level it also reports ibm-error-response-schemas against ErrorEnvelope, requiring a trace string and an errors array — IBM's error-container shape, not ours. No ruleset toggle removes that honestly, and satisfying it means redesigning the error body on every endpoint and breaking every client.

So "adopt IBM's validator" is not a lint cleanup. It is a utoipa downgrade plus an API redesign.

Acceptance criteria

The AC on this task is "valid OpenAPI, passes a linter cleanly"Redocly recommended-strict, 0 errors / 0 warnings, and the gate is wired into CI. Tranche 3 AC 2 names openapi-validator; that wording is ours (docs/prices-api-general-overview.md:1332), so it is almost certainly generic rather than a procurement of IBM's npm package.

One open question for @okarcz: did openapi-validator in that AC mean IBM's package specifically? If generic, we are done. If IBM's specifically, that becomes its own task — first line utoipa downgrade, second line ErrorEnvelope redesign — and is worth knowing before the M2 evidence package (0124 → 0128) is assembled.

Full accounting, including every fixed and every left error with its reason, is in lore/1-tasks/active/0124_FEATURE_expose-openapi-spec-through-gateway.md under "The openapi-validator result".

@adamkoot
adamkoot requested a review from karczuRF August 5, 2026 13:54
@karczuRF

karczuRF commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Review

Mapping /api-docs-json through the gateway, apiBaseUrlservers, the x-api-key scheme with explicit opt-outs, and the two CI gates all look right. A few notes below, one of them blocking.

The "Issues Encountered" section catching the lint gate for exiting 0 on warnings is the right instinct, and deriving both sides of the route check from artifacts rather than a hand-maintained mirror is the correct lesson from 0077. The vacuous-pass guard and the loud failure on ANY are both good.


🔴 Blocking — task ID 0144 collides with an active task

lore/1-tasks/backlog/0144_DOCS_declare-api-license-in-openapi.md is new here, but 0144 is already taken by lore/1-tasks/active/0144_BUG_be-0199-usd-read-surface-defects/ on #168. The ID didn't show in the tree when this branch was cut because it was unmerged.

The BUG side is far more referenced (0145–0151 and 0154 all cite [[0144]], plus the BE-facing reply and the phase plan), so this side should renumber. Next free ID is 0155 — 0152 is claimed by #172, 0154 exists.

Four places:

🟠 The route-drift gate never runs on the PRs most likely to cause drift

npm run openapi:verify-routes lives in the rust job, whose paths filter is (ci.yml:22-42):

'packages/**', 'Cargo.toml', 'Cargo.lock', '.github/workflows/ci.yml',
'redocly.yaml', '.redocly.lint-ignore.yaml', 'infra/envs/production.json',
'package.json', 'package-lock.json', 'tools/scripts/**'

infra/** is not there — only infra/envs/production.json. So a PR that adds a route in infra/src/lib/stacks/api-gateway-stack.ts and nothing else skips the rust job entirely, and the gateway→spec direction ("API Gateway maps routes the OpenAPI document does not describe") is never checked. The typescript job runs on infra/** but has no cargo, so it can't fill in.

Same hole class the PR already fixed twice for package.json — and it's the more likely drift direction, since adding a gateway route is a pure-infra edit while adding an axum route touches packages/**.

Narrowest fix, avoiding a full ARM Rust build on every infra PR:

rust:
  ...
  # verify-openapi-routes compares this file's synthesized output against
  # the extracted document; without it, an infra-only route addition
  # skips the only check that would catch it.
  - 'infra/src/lib/stacks/api-gateway-stack.ts'

🟡 LEDGER_SEQ_MAX is a guard that doesn't guard

packages/prices-api/src/backfill/dto.rs:21-28:

const LEDGER_SEQ_MAX: u64 = u32::MAX as u64;
const _: () = assert!(LEDGER_SEQ_MAX == 4_294_967_295);

The comment explains the const is repeated as a literal because attribute macros can't read a const — fair. But the static assert only checks u32::MAX as u64 == 4_294_967_295, which is a tautology. It doesn't tie the five #[schema(maximum = 4_294_967_295u64)] attributes to anything: change one literal to 4_294_967_296 and the build stays green and the published document is wrong.

Given this PR's own theme, worth either deleting the decorative const or replacing it with the real check — a test in tests/openapi.rs asserting each ledger field's maximum in the extracted document equals u32::MAX:

assert_eq!(spec["components"]["schemas"]["SdexStream"]["properties"]
    ["target_ledger"]["maximum"], u32::MAX as u64);

That's the artifact-derived form the rest of the PR argues for.

🟡 Anonymous route, but a Lambda-backed one

The /health precedent is cited repeatedly for the keyless posture, but /health is a MockIntegration — it costs nothing and can't be made to cost anything. /api-docs-json is proxy([]), so a cache miss invokes the api-handler, and it sits outside the usage plan. The only limiter is the stage-wide /* * throttle, which it now shares with paying partner traffic.

Mitigations are mostly in place — 3600 s stage cache with no cache-key parameters (one entry for all callers), and API Gateway's default requireAuthorizationForCacheControl: true blocks anonymous cache-busting. So the residual is small. But worth one line in the stack comment saying so, because "matches the /health precedent" currently reads as "same cost profile", and it isn't.

🔵 Nits

  • Method sets disagree between the two guards. tests/openapi.rs:spec_routes() includes "head" | "options" in its operation filter; verify-openapi-routes.mjs deliberately excludes both from both sides (UNCOMPARED_METHODS / HTTP_METHODS). Harmless today, but the day 0126's addCorsPreflight lands the mjs ignores it and the Rust test would flag it. Align the Rust filter with HTTP_METHODS.
  • fullPath() truncates silently. if (!node) break; and the hops <= nodes.size bound both return a partial path rather than erroring, despite the comment saying "fail loudly instead of hanging CI". The failure does surface — as confusing drift on a path that looks almost right. An explicit throw on an unresolved parent would match the stance taken two lines down for ANY and root-level methods.
  • extract-openapi.sh uses node -p "require('…').apiBaseUrl". Works today; would break if the root package.json ever gains "type": "module" (it has none now). node -p "JSON.parse(require('fs').readFileSync('…','utf8')).apiBaseUrl ?? ''" is immune, or just jq.
  • Double extraction in CI. openapi:lint and openapi:verify-routes each chain openapi:extract, so cargo run --bin extract_openapi runs twice per job. Correct (the chaining fix was the right call) and cheap after the first build — just noting it in case a --no-extract variant is wanted later.

Worth keeping as-is

  • stamp_servers() shared between app() and extract_openapi so the linted bytes are the served bytes — the load-bearing decision in the whole PR.
  • recommended-strict with operation-4xx-response: error pinned redundantly so a future downgrade to recommended can't silently demote it.
  • The 401-vs-403 body asymmetry is correct and easy to get wrong: 401 carries ErrorEnvelope (in-app gate, errors::unauthorized), 403/429 carry no body because API Gateway produces them.
  • security(()) applied at both layers, with a test that exercises the armed-gate path.
  • apiBaseUrl validated at synth for the stage-prefix trap, with the .execute-api.-only carve-out so 0126's custom domain passes unchanged.
  • ledgers_remaining uses saturating_sub, so the published maximum can't be violated by a tip that moves backwards. Checked.

Good to merge once 0144 is renumbered. The CI paths-filter gap is the one substantive engineering issue and I'd fix it here while the reasoning is fresh; the rest are fine as follow-ups.

PR #169 review (okarcz). The task spawned from 0124's future work claimed
an ID that PR #168 had already taken for the BE-0199 USD read-surface
defects. That branch was unmerged when this one was cut, so the collision
was invisible in the tree.

The BUG side keeps 0144: it is cited by 0145-0151 and 0154, the BE-facing
reply and the phase plan. 0152 (#172), 0153 and 0154 are all claimed, so
this moves to the next free ID. 0153's note reserving 0152 for this task
has been overtaken by #172.

References updated here: redocly.yaml's info-license-strict comment (a
fifth site, not in the review's list of four) and the api-endpoints doc.
The 0124 task file's two links follow in the review-record commit.
Four of the review's points are the same shape as the bug this task
exists to fix: something that looks like a check but is not one.

1. openapi:verify-routes never ran on the PRs most likely to trip it.
   It lives in the rust job, whose paths filter had no infra/** entry,
   so an infra-only PR adding a gateway route skipped the only check
   that sees the gateway->spec direction. Adding a gateway route is a
   pure-infra edit while adding an axum route touches packages/**, so
   the uncovered direction was the more likely one. Listing the single
   stack file rather than infra/** keeps unrelated CDK edits off the
   ARM Rust build.

2. LEDGER_SEQ_MAX asserted a tautology. The const restated
   u32::MAX as u64 == 4_294_967_295 and tied the five schema(maximum)
   literals to nothing: retyping one to 4_294_967_296 left the build
   green and published a wrong bound. Replaced with a test that reads
   the bounds back out of the served document, with the field set
   derived from the document so a later ledger field that forgets the
   attribute fails as a missing maximum. Mutation-checked both ways.

3. fullPath() truncated silently, despite its comment promising to
   fail loudly. A partial path still looks like a route, so a broken
   template surfaced as drift on a path that was almost right rather
   than as the parse failure it was. Both exits now throw. The
   root-method check moved above the ANY check so the ANY message
   always has a resolved path to name.

4. The two route guards compared different method sets: the Rust test
   matched head/options, the mjs drops both from both sides so 0126's
   addCorsPreflight does not read as drift. Aligned.

Also switches extract-openapi.sh off require()-ing the env JSON as a
module. The stated hazard does not reproduce (node -p is still CommonJS
under "type": "module", and the root package.json has no type), but the
old form depended on both of those staying true and reading bytes
depends on neither.
The keyless posture cites /health as precedent, and for the posture it
is one. For cost it is not: /health is a MockIntegration and can never
invoke anything, while /api-docs-json is proxy([]), so a cache miss
reaches the Lambda and the route sits outside the usage plan with only
the stage-wide throttle it shares with paying traffic.

The residual stays small for reasons already in the stack -- a 3600s TTL
with no cache-key parameters, so every caller collapses onto one entry,
and API Gateway's default requireAuthorizationForCacheControl blocking
anonymous cache-busting -- but none of that was written down, so
"matches the /health precedent" read as "same cost profile". States it,
and names the lever for a harder bound: a method-level throttle, not a
key requirement.

Also records the full #169 review response in the task file and
completes the 0144 -> 0155 renumber's remaining two links.
All four are cases where a check reads as covering something it does not.
Each is verified by mutating the artifact it reads and confirming it now
fails; three of the four passed that same mutation before.

1. Nothing checked that the deployed handler is configured with the URL
   the document advertises. extract-openapi.sh stamps `servers` from
   infra/envs/production.json and exports API_BASE_URL itself, so it
   never observes ComputeStack putting that variable on the Lambda.
   Rename it to API_BASE_URI in an unrelated refactor and synth, lint and
   the route gate all pass while production serves a document with no
   `servers` block at all. New openapi:verify-servers compares the
   synthesized Compute template against the extracted document, and
   re-asserts the stage-prefix invariant against the stage the template
   actually deploys so deleting the types.ts validation cannot silently
   remove it. The handler is identified by carrying API_BASE_URL, not by
   name, so a rename fails as "no function declares it". compute-stack.ts
   and types.ts join the rust paths filter, which is what makes the new
   check run on the PRs that would break it.

2. Dropping head/options from both route guards fixed the disagreement
   between them by removing the coverage. A documented HEAD was then
   checked by neither guard in either direction — the same unroutable
   documented route 0124 exists to close, reopened for two verbs. HEAD is
   compared normally now; OPTIONS stays skipped on the gateway side only
   (0126's addCorsPreflight), and a documented OPTIONS is refused outright
   by both guards instead of ignored.

3. fullPath() still truncated silently. The rewrite threw at two exits,
   but the truncation happened earlier: any ParentId that was not `{Ref}`
   became null, and null is the walk's "reached the root" signal. An
   imported RestApi or a cross-stack split (0126) emits Fn::ImportValue
   and would have produced `/status` for `/v1/backfill/status` — drift
   reported on a path that is almost right, or worse, genuine drift
   passing if the truncation collides with a documented path. ParentId is
   now classified into ref/root/unresolved, and PathPart and HttpMethod
   are rejected unless they are literal strings.

4. The ledger-ceiling test matched a `_ledger` name suffix, so its own
   promise — "a ledger field added later without the attribute fails" —
   held only for that name shape, and the count assertion could not see a
   field the filter never matched. Replaced with two rules: by type over
   the schemas reachable from the /v1/backfill/status response $ref (every
   integer there is a ledger sequence), and by name over the whole
   document using `contains`, not a suffix.
The lint exception for /health and /api-docs-json claimed they "genuinely
have no 4xx to document". The same branch's own stack comment says
otherwise: neither route is in the usage plan, but both sit under the
stage-wide `/*` `*` throttle, so API Gateway can 429 either one.
/api-docs-json can also 5xx, because unlike /health it is a Lambda proxy
and a cache miss reaches the handler.

Left as it was, a partner generating a client from this document gets no
error branch for either route: a 429 arrives as `{"message": …}` and the
client tries to deserialize it as the OpenAPI document. That is the exact
failure a0b9b29 fixed for the seven key-gated operations; these two were
excepted rather than fixed.

Both responses are documented without a body, because API Gateway
produces them and its shape is not ErrorEnvelope — same asymmetry already
recorded for 403/429 on the data routes.

.redocly.lint-ignore.yaml is now empty and stays in the tree carrying the
reason, so the exceptions cannot quietly come back as the fix.
Both caches on /api-docs-json were 3600s, justified by "the document is
byte-identical for the life of a deployment". True, and beside the point:
the caches outlive the deployment that filled them, and nothing dropped
either one when a build shipped. A partner who fetched the document
minutes before a release kept generating clients from the old one for the
rest of the hour, with no staleness signal — at exactly the moment
integrators go look at it.

Split by who controls the cache:

- Gateway stays 3600s and is now FLUSHED on deploy. `make -C infra
  deploy-production` and `deploy-production-compute` both run
  flush-production-cache, which reads the REST API id from the SSM
  parameter the stack already publishes. API Gateway has no per-route
  flush, so it drops the whole stage cache — harmless, every other TTL
  there is 10-60s on self-correcting data.

- The handler's Cache-Control drops to 300s, because a partner's HTTP
  cache is the one we cannot flush. Revalidating every 5 minutes costs
  nothing: those requests land on the gateway cache, not the Lambda.

This is the one place the cache_control tiers and the stage TTLs
deliberately disagree, so both sides say why.

Also replaces the `{}` fallback in lib.rs. A serialization failure served
a syntactically valid EMPTY document as 200 OK — no log, no metric — then
cached it. Every generator run in that window produced a client with zero
endpoints and nothing reported a fault. Failing to start is louder and
shorter, and matches extract_openapi, which already .expects the same
call.

README said openapi:lint runs Redocly's `recommended`; it runs
`recommended-strict`, and the distinction is the whole point of the gate
(plain `redocly lint` exits 0 on warnings). Someone trimming config back
to "the documented ruleset" would have disarmed it.
Every other Lambda-backed route is apiKeyRequired, so it carries two
limits from the usage plan: the per-key rate and the daily quota.
/api-docs-json is anonymous by design and therefore has neither — its
only limiter was the stage-wide bucket it SHARES with paying partners.
Throttling is evaluated before the cache, so an anonymous loop on the
documentation route draws that bucket down and a partner inside their
contracted 100 req/s starts seeing 429s from a route they never called.

10 req/s aggregate, burst 20: far above any legitimate use of a static
~40 KB document cached for an hour at the edge, and 5% of the stage
ceiling. A local constant rather than a config key, because it follows
from the route's shape (anonymous, cached, static) rather than from an
environment's capacity.

The methodSettings block moves out of the `if (cacheEnabled)` branch. It
was skipped wholesale when the stage cache is off — which is the
configuration where an unbounded keyless route costs the most, since
every request is then a billed Lambda invocation. Cache TTLs stay
conditional; throttles no longer are. The route gets ONE entry carrying
both, since method settings are keyed by resourcePath+httpMethod and two
entries would collide.

Kept as its own commit deliberately. The #169 review looked at this
route's posture and accepted it ("the residual is small"), asking only
for the cost profile to be written down. This goes further than that, so
`git revert` this one commit if you would rather it did not.
@adamkoot

adamkoot commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator Author

Review follow-up, round 2 — plus five findings from a self-review

@okarcz — everything from your review landed in e40da29, cb646e0, d809a46; the accounting is in the previous comment. This covers what came after: I ran a multi-agent review over the branch afterwards, and it found that three of the four fixes I made for your points were incomplete in the same way the originals were — a check that reads as covering something it does not. Those are fixed in e18b936. Five further findings are fixed in 79bd33a, 07cbc39, 479548c.

One of those last three needs your call, and I have kept it separately revertable. It is at the bottom.


The three fixes that were themselves incomplete (e18b936)

Each is verified by mutating the artifact the check reads and confirming it now fails. All three passed that same mutation before.

1. The CI paths-filter entry you asked for was necessary but not sufficient. Adding api-gateway-stack.ts makes the rust job run on infra-only PRs — but nothing in that job would have failed for the servers half. extract-openapi.sh reads apiBaseUrl from infra/envs/production.json and exports API_BASE_URL itself, so it never observes ComputeStack actually putting that variable on the Lambda. Rename it to API_BASE_URI in an unrelated refactor and synth, lint and the route gate all pass, while production's GET /api-docs-json serves a document with no servers block at all — the CI copy still has one because it was stamped from the JSON file.

New tools/scripts/verify-openapi-servers.mjs compares the synthesized Compute template against the extracted document, and re-asserts the stage-prefix invariant against the StageName the gateway template actually deploys, so deleting the types.ts validation cannot silently remove that guarantee either. The api-handler is identified by carrying API_BASE_URL rather than by name, so a rename fails as "no function declares it" instead of passing against a function the script can no longer find. compute-stack.ts and types.ts joined the paths filter.

2. Aligning the two guards' method sets removed the coverage instead of fixing it. Dropping head/options from both sides made them agree, at the cost of leaving a documented HEAD checked by neither guard in either direction — reopening, for two verbs, the exact unroutable-documented-route defect this task exists to close.

The exclusion is now one-sided: OPTIONS is skipped on the gateway side only (your addCorsPreflight point still holds), HEAD is compared normally, and a documented OPTIONS is refused outright by both guards rather than ignored. Your original nit is still satisfied — the two guards agree — but by raising the weaker side rather than lowering the stronger one.

3. fullPath() still truncated silently. The rewrite threw at two exits, but the truncation happened earlier, at node-construction time: any ParentId that was not {Ref: …} became null, and null is the walk's "reached the root, path complete" signal. Today only Fn::GetAtt … RootResourceId hits that branch, so it works — but an imported RestApi or a gateway split across stacks (task 0126) emits Fn::ImportValue, and the check would have reported /status for /v1/backfill/status: drift on a path that is almost right, or worse, genuine drift passing if the truncated path collides with a documented one. ParentId is now classified ref / root / unresolved, and PathPart / HttpMethod are rejected unless they are literal strings.

Also in that commit: the ledger-ceiling test matched a _ledger name suffix, so its own promise — "a ledger field added later without the attribute fails" — held only for that name shape, and the checked == 5 assertion could not see a field the filter never matched. It now runs two rules: by type over the schemas reachable from the /v1/backfill/status response $ref (every integer there is a ledger sequence), and by name over the whole document using contains, not a suffix.


Your "no 4xx to document" premise was wrong — and my own comment said so (79bd33a)

The .redocly.lint-ignore.yaml exception claimed /health and /api-docs-json "genuinely have no 4xx to document". The cost-profile comment you asked me to add contradicts it three commits later: neither route is in the usage plan, but both sit under the stage-wide /* * throttle, so API Gateway can 429 either one. /api-docs-json can also 5xx, because unlike /health it is a Lambda proxy and a cache miss reaches the handler.

Left alone, a partner generating a client gets no error branch for either: a 429 arrives as {"message": …} and the client tries to deserialize it as the OpenAPI document — the same failure a0b9b29 fixed for the seven key-gated operations. Both responses are now documented, without a body, matching the 403/429 asymmetry you flagged as correct. The ignore file is empty and stays in the tree carrying the reason, so the exceptions cannot come back as the fix.

recommended-strict now passes unaided — 0 errors, 0 warnings, 0 ignored.


The 3600s cache had no invalidation (07cbc39)

"The document is byte-identical for the life of a deployment" is true and beside the point: the caches outlive the deployment that filled them, and nothing dropped either one when a build shipped. A partner who fetched the spec minutes before a release kept generating clients from the old one for the rest of the hour, with no staleness signal — precisely when integrators go look at it.

Split by who controls the cache:

  • Gateway stays 3600s and is now flushed on deploy. deploy-production and deploy-production-compute run a new flush-production-cache target, reading the REST API id from the SSM parameter the stack already publishes. API Gateway has no per-route flush, so it drops the whole stage cache — harmless, every other TTL there is 10–60s on self-correcting data.
  • The handler's Cache-Control drops to 300s, because a partner's HTTP cache is the one we cannot flush. Revalidation costs nothing: those requests land on the gateway cache, not the Lambda.

This is the one place the cache_control tiers and the stage TTLs deliberately disagree, so both sides now say why.

Same commit: lib.rs no longer falls back to {} on a serialization failure. It served a syntactically valid empty document as 200 OK — no log, no metric — and then cached it, so every generator run in that window produced a client with zero endpoints while nothing reported a fault. Now it .expects, matching extract_openapi. And the README said openapi:lint runs recommended; it runs recommended-strict, and that distinction is the whole point of the gate.


🟠 Needs your call: throttle on the anonymous route (479548c)

You looked at this route's posture and accepted it — "the residual is small", asking only that the cost profile be written down. This commit goes further than you asked, so it is isolated: git revert 479548c and nothing else moves.

The argument for going further: /api-docs-json is the only Lambda-backed route with no usage-plan limits, and throttling is evaluated before the cache. So an anonymous loop on it draws down the stage bucket it shares with paying partners, and a partner inside their contracted 100 req/s starts seeing 429s from a route they never called. The mitigations we listed (one shared cache entry, requireAuthorizationForCacheControl) bound the Lambda cost, not the throttle cost.

Second half: the whole methodSettings block sat inside if (cacheEnabled). With the stage cache off, the /api-docs-json TTL entry disappears too — so every anonymous request becomes a billed Lambda invocation, in exactly the configuration where an unbounded keyless route costs the most. Throttles are now unconditional; cache TTLs stay conditional. One entry carries both, since method settings are keyed by resourcePath+httpMethod.

10 req/s aggregate, burst 20 — far above any legitimate use of a static ~40 KB document, 5% of the stage ceiling. Happy to drop it if you would rather keep the posture exactly as reviewed; it is a one-commit revert either way.


Verification

  • cargo test -p prices-api — 35 passed, 0 failed (openapi suite 7 → 9)
  • cargo fmt --check, cargo clippy -p prices-api --all-targets, eslint, tsc, prettier — clean
  • npm run openapi:lint — valid, 0 errors / 0 warnings / 0 ignored
  • npm run openapi:verify-routes — 9/9
  • npm run openapi:verify-servers — matches the synthesized handler config
  • cdk synth — renders one /api-docs-json GET method setting carrying both the 3600s TTL and the 10/20 throttle; the stage-wide /* * entry survives

Mutation checks, each confirming the guard now fails where it previously passed:

Mutation Before After
API_BASE_URLAPI_BASE_URI in the template not checked exit 1, points at compute-stack.ts
servers drifts from the handler's config not checked exit 1, prints both
documented head /v1/assets/{id} green exit 1, unroutable
documented options green exit 1, own message
ParentId: {Fn::ImportValue} /assets, phantom drift exit 1, names the resource
gateway-side OPTIONS method passes passes (0126 unblocked)
tip_seq: u64 with no maximum green, count still 5 fails, names the field
one ledger literal → 4_294_967_296 green (old assert was a tautology) fails, names the field

Two candidates were refuted during verification and are not changes: the duplicated resourcePath literal next to the TTL, and the API_KEY_HEADER const.

Still outstanding and unchanged: the two ACs that need a deploy (the live fetch and confirming the advertised servers serves), and the open question above about whether Tranche 3 AC 2's openapi-validator means IBM's package specifically.

…alidated

The task file described the branch as it stood three commits ago, and
several of its claims were made false by this session's own changes —
which is the same failure mode the branch keeps finding in its guards, so
it does not get to stay in the file that documents them.

Corrected:

- AC said the document passes Redocly's `recommended` ruleset. It passes
  `recommended-strict`, and now with 0 ignored rather than 2 — the two
  `operation-4xx-response` exceptions were deleted, not preserved.
- AC and Implementation Notes said the cache was "3600 s, gateway +
  handler agreeing". It is 3600 s at the gateway (flushed on deploy) and
  300 s at the client, deliberately disagreeing.
- Design Decision #3 asserted that agreement as a decision. Struck
  through rather than rewritten: it was made, shipped, and then found
  wrong, and that sequence is the part worth keeping.
- Verification carried stale counts (223 workspace / 8 openapi, "2
  ignored") and said cdk synth had not been re-run. It has: 225 / 9 / 0
  ignored, and synth needed a workaround now recorded, since a plain dev
  checkout has no Lambda bootstrap assets and fails with CannotFindAsset
  before rendering anything.

Added: the self-review round, its mutation-check table, and the finding
that matters more than any individual item — three of the four fixes made
for the #169 review were incomplete in the same way the originals were.
The review caught a class of defect and the fixes reproduced it one layer
down; that is what a later reader needs, not the four bugs.

Also records the one item still open: the /api-docs-json throttle goes
beyond what #169 accepted, so it is isolated in 479548c pending okarcz's
call.
The file is ~680 lines against the ~150-line threshold in
lore/1-tasks/CLAUDE.md, and larger than any existing task README here.
Recording the decision so a later session reads it as deliberate rather
than as an oversight to re-litigate: the archive move is a `git mv`
anyway, so converting then is one operation instead of two, and doing it
now would hand the PR reviewer a large rename on the file he is reading.

Carries the proposed split, the target README size, and the two things
that break if it is done without care — the current-task symlink and the
inbound links from 0128 and 0155.
@karczuRF

karczuRF commented Aug 6, 2026

Copy link
Copy Markdown
Collaborator

Both open questions answered

Reviewed the self-review round against the branch. The three "my own fixes were incomplete" findings are all genuine, and on the second one you were right and I was wrong — see below. Answers to the two things you left for me:


1. The anonymous-route throttle (479548c) — keep it

Don't revert. Your argument holds and it goes further than the point I raised, correctly.

Throttling is evaluated before the cache, so the mitigations I accepted — one shared cache entry, requireAuthorizationForCacheControl — bound the Lambda cost and not the throttle cost. That leaves the failure mode you describe: an anonymous loop on a free public URL draws down the stage bucket, and a partner inside their contracted 100 req/s starts seeing 429s from a route they never called. That is also miserable to diagnose, because the partner's own usage looks clean.

The trade is lopsided in the direction of keeping it. Too low, and a docs reader gets a 429, retries, and it works — cheap, visible, one-commit revert. Absent, and a paying partner takes an outage caused by traffic they didn't generate. 10 r/s against a 200 r/s stage ceiling, for a ~40 KB document that is byte-identical for the life of a deployment and cached for an hour, is not a real constraint on legitimate use.

The second half is a straight bug fix and would have been worth the commit on its own: the whole methodSettings block sat inside if (cacheEnabled), so with the cache off the route lost its TTL entry in exactly the configuration where every request becomes a billed Lambda invocation. Worst protection in the worst configuration. Throttles unconditional, TTLs conditional is right.

Also a good catch that assigning methodSettings wholesale replaces the /*/* entry CDK renders from deployOptions — that one would have silently dropped the stage-wide throttle.


2. openapi-validator in Tranche 3 AC 2 — generic

Answering your question from the 08-05 comment: it does not mean IBM's package.

I wrote that line myself in 6798293 (2026-05-04, "docs: add general overview"), where the whole document landed in one commit — before this repo had chosen any linter. It names a category of check, not a package; nobody procures a specific npm linter while drafting funding acceptance criteria. And IBM's package is ibm-openapi-validator, which is how you invoked it — the string in the AC isn't its name even read literally.

So Redocly recommended-strict at 0 errors / 0 warnings / 0 ignored satisfies AC 2, and it over-satisfies it: the AC asks for no errors, and the gate is clean on warnings too. Your analysis is right that adopting IBM's would be a utoipa downgrade plus an ErrorEnvelope redesign that breaks every client. That is not a lint cleanup, it isn't in scope, and it isn't what the AC bought.

Two things for the M2 evidence package, since that AC is funder-facing rather than internal:

  • Name the tool and the result explicitly — Redocly recommended-strict, 0/0/0, gate wired into CI — rather than just asserting the spec is valid.
  • Add one line that IBM's validator reports 5 findings, and that they are OpenAPI 3.0 rules applied to a 3.1 document, with the reasoning from your 08-05 comment. A reviewer who runs their own tool should find the number already explained instead of discovering an apparent contradiction with a criterion we've certified as met.

Don't reword the AC in the proposal doc. It's contract wording next to a budget line; changing it after the fact reads as moving the goalposts even when the new wording is more accurate. Put the precision in the evidence package instead.


One non-blocking note

flush-production-cache assumes the stage cache is enabled, but the stack deliberately supports it being off. infra/Makefile:49-59,71-73 runs aws apigateway flush-stage-cache unconditionally, and that errors when a stage has no cache cluster — so make deploy-production would exit non-zero after a successful deploy whenever apiGatewayCacheEnabled: false. Production is true today, so it's latent. It's the same configuration 479548c reasons about carefully for throttles, which is why it stood out.

Smaller, same area: deploy-production-apigateway doesn't flush. Defensible, since that stack doesn't change what the handler serves — but it does own CACHE_TTL, and API Gateway entries keep their insertion-time TTL, so a TTL reduction deployed that way isn't effective for up to the old 3600s. Same "cache outlives the change" class the commit was written for.

Either fix here or as a follow-up, your call — neither blocks.


Verified

I checked the three incomplete-fix claims against the branch rather than taking them on the commit messages:

  • verify-openapi-servers.mjs closes a real hole. extract-openapi.sh stamps servers from production.json and never observes ComputeStack, so the rename scenario does pass every other gate. Identifying the handler by carrying API_BASE_URL (servers.mjs:70-74) is the right shape — a rename fails as "no function declares it" rather than passing against a function the script can't find. Confirmed it runs in CI at ci.yml:260, after synth.
  • Method sets — you were right and my nit was wrong. Aligning the two guards by dropping head/options from both sides, which is what I asked for, would have left a documented HEAD checked by neither guard in either direction, reopening the exact defect this task exists to close. Raising the weaker side instead is the better fix.
  • fullPath() — real. Throwing at two exits didn't help while non-Ref ParentId still became null at node-construction time and null was the walk's "complete" signal. classifyParent's three-way split plus the literal-string checks on PathPart and HttpMethod close it.

The 01440155 renumber is complete; the remaining 0144 strings correctly describe the renumber.

Approved. Good to merge once you've decided on the Makefile note — that isn't a blocker either way. The two deploy-gated ACs staying open on 0124 is the right call.

@adamkoot
adamkoot merged commit dabdd15 into develop Aug 6, 2026
3 checks passed
@adamkoot
adamkoot deleted the feat/0124_expose-openapi-spec-through-gateway branch August 6, 2026 14:05
adamkoot added a commit that referenced this pull request Aug 6, 2026
PR #169 merged to develop (squash dabdd15) after okarcz's approval, three
CI jobs green. Shipped: GET /api-docs-json as a keyless cached proxy route,
`servers` stamped from apiBaseUrl, ErrorEnvelope published, a Redocly
recommended-strict gate, and three artifact-derived guards.

Converted to a directory as the task's own Future Work planned — deferred
to archive time so the reviewer never saw a large rename mid-review. The
three heavy sections moved to notes/S-*, leaving a ~420-line README.

Two ACs stay deployment-verified rather than verified: the live
/api-docs-json fetch and the advertised `servers` URL serving a route both
need `make -C infra deploy-production`. docs/scf/api-endpoints.md carries
the curl.

Also quoted the history dates, which failed lore frontmatter validation as
bare YAML date scalars.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants