Skip to main content

D114 — Vendor OpenAPI snapshot + drift gate

Architecture decision record. Status, thematic clusters, and how to record a new ADR: the decision log index. The mechanics live in packages/stables/bridge-sdk/spec/{openapi.json,exclusions.json}, packages/stables/bridge-sdk/src/Console/SpecDiffCommand.php, packages/stables/bridge-sdk/src/Support/{SpecNormalizer,RequestClassIndex}.php, and packages/stables/bridge-sdk/tests/SpecCoverageTest.php.

Context

In-repo vendor SDKs (packages/stables/*-sdk) are hand-written against a vendor's published API docs or OpenAPI spec at the time they're built. Nothing previously caught the vendor's spec moving after that point — a field renamed, an operation added, a schema shape changed — until either a live call broke or someone happened to re-read the vendor's docs. stables/bridge-sdk (packages/stables/bridge-sdk) is the first SDK in this repo generated from Bridge's full, machine-readable OpenAPI spec (128 operations across 24 tag resources) rather than a curated subset of hand-read endpoints, which makes an automated drift signal both feasible (there's a spec to diff against) and valuable (128 operations is too many to eyeball for drift by hand).

Decision

Track vendor API drift via a committed, normalized OpenAPI snapshot, an on-demand diff command, and an offline coverage gate — bridge-sdk is the first package to adopt this pattern.

  • A committed, normalized snapshot (spec/openapi.json) is the single source of truth the package's own test suite reads. It is never fetched over the network during make test-packages or any other gate — the suite stays deterministic and offline, matching this repo's existing test posture (Saloon\Config::preventStrayRequests(), no fixture files).
  • A single normalizer is the formatting authority. Stables\Bridge\Support\SpecNormalizer (recursive ksort, 2-space JSON_PRETTY_PRINT, trailing newline) produces both the committed snapshot and every spec the diff command fetches, so a diff never reports a change that's really just incidental key-ordering or whitespace noise from the vendor's serializer.
  • An on-demand diff command, php artisan bridge:spec-diff {--update} {--url=} (make bridge-spec-diff), fetches the live spec and reports operations added/removed/changed and components.schemas added/removed/changed, mapping each changed or removed operation back to the request class that implements it (via each request's SPEC_PATH constant). Exit codes: 0 clean, 1 drift detected, 2 fetch/decode failure. --update overwrites the committed snapshot with the freshly normalized fetch. It is on-demand, not scheduled — see Alternatives.
  • An offline coverage gate, tests/SpecCoverageTest.php, asserts a 1:1 mapping between every (METHOD, path) operation in the committed snapshot and a request class's SPEC_PATH, minus entries in spec/exclusions.json ({method, path, reason} — a stale exclusion, one that no longer matches a live spec operation, fails the gate too). This test runs in every ordinary make test-packages invocation, so a snapshot updated via --update immediately reddens the suite for every newly added operation until it is implemented or excluded with a reason.

Alternatives considered

  • A scheduled CI job that runs bridge:spec-diff against the live spec on a cron. Rejected. Any workflow under .github/workflows/ is a human-approval-required surface in this repo (see the org-level CLAUDE.md safety rules), and a scheduled job is automation nobody has actually run — "parses" is not "works" for a workflow that has never fired. The on-demand command gives the same signal on any engineer's or agent's schedule, with a human deciding when to pull drift in, without adding an unrun cron surface to the review burden.
  • Codegen from the OpenAPI spec. Rejected. This repo's SDKs (sovera-sdk, conduit-sdk, and now bridge-sdk) share a deliberate, hand-authored house convention — hydrate() factories (never from*, which self-recurses under spatie/laravel-data), money/timestamps kept as string, SPEC_PATH constants, the neutral exception-mapping tree, Saloon v4 request/resource shape. Generic OpenAPI codegen tooling does not produce this shape and would either need a heavily customized generator (ongoing maintenance burden of its own, diverging from every other SDK in the repo) or produce code this repo's conventions would have to be retrofitted onto by hand anyway. The chosen design keeps 100% of the code hand-authored and reviewed; only the signal that something changed is automated.

Consequences

  • Running bridge:spec-diff --update is a deliberate, disruptive action — it reddens SpecCoverageTest for every operation added or removed (see below for what it does not redden). This is intentional: the gate exists to force a conscious implement-or-exclude decision on drift, not to silently absorb it.
  • Every spec/exclusions.json entry must carry a reason — an exclusion with no reason is not self-documenting and defeats the point of a reviewable drift log.
  • SpecNormalizer is the single formatting authority for both the snapshot and every fetched spec; a future change to its output shape must re-normalize the committed snapshot in the same change, or every subsequent diff falsely reports whole-file drift.
  • The coverage gate checks operation coverage only, not schema-field coverage — a schema-field change surfaces as an ordinary git diff on spec/openapi.json for a reviewer to map to a DTO update by hand, not as a failing assertion. bridge-sdk's own README documents a first real instance of this: components.schemas.VirtualAccountEventSource changed upstream after the 2026-09-01 snapshot was taken, discovered by running bridge:spec-diff against the live spec — the first drift this loop has caught, not yet pulled in via --update.
  • This pattern is scoped to bridge-sdk for now. Adopting it for another in-repo vendor SDK (conduit-sdk, sovera-sdk, etc.) is a follow-up decision, not implied by this one — those SDKs were built by hand-reading vendor docs rather than from a single machine-readable spec, so the snapshot-diff mechanics would need to be re-evaluated per vendor, not copy-pasted.
  • --update is a fetch-integrity / supply-chain surface: it writes remote, third-party content (Bridge's live spec response) directly into a committed file (spec/openapi.json) that this package's own test suite, and any future codegen or hand-authored DTO work, treats as trusted ground truth. A compromised or MITM'd fetch could land arbitrary content in that file. Two mitigations, both already in place: (1) PR review of the snapshot diff is the integrity gate--update never runs unattended, and the resulting git diff on spec/openapi.json is ordinary reviewable text, same as any other committed change; (2) SpecDiffCommand validates the fetched payload looks like an OpenAPI document (a non-empty openapi string and a non-empty paths object) before accepting it, rejecting a fetch that returns an error page, redirect target, or otherwise malformed body. The command is on-demand only, never scheduled (see Alternatives above) — there is no unattended job that could pull a compromised spec in without a human running it and a human reviewing the diff.

← Decision log index