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, andpackages/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 duringmake test-packagesor 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(recursiveksort, 2-spaceJSON_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 andcomponents.schemasadded/removed/changed, mapping each changed or removed operation back to the request class that implements it (via each request'sSPEC_PATHconstant). Exit codes:0clean,1drift detected,2fetch/decode failure.--updateoverwrites 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'sSPEC_PATH, minus entries inspec/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 ordinarymake test-packagesinvocation, so a snapshot updated via--updateimmediately 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-diffagainst the live spec on a cron. Rejected. Any workflow under.github/workflows/is a human-approval-required surface in this repo (see the org-levelCLAUDE.mdsafety 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 (neverfrom*, which self-recurses underspatie/laravel-data), money/timestamps kept asstring,SPEC_PATHconstants, 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 --updateis a deliberate, disruptive action — it reddensSpecCoverageTestfor 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.jsonentry must carry areason— an exclusion with no reason is not self-documenting and defeats the point of a reviewable drift log. SpecNormalizeris 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 diffonspec/openapi.jsonfor 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.VirtualAccountEventSourcechanged upstream after the 2026-09-01 snapshot was taken, discovered by runningbridge:spec-diffagainst 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.
--updateis 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 —--updatenever runs unattended, and the resultinggit diffonspec/openapi.jsonis ordinary reviewable text, same as any other committed change; (2)SpecDiffCommandvalidates the fetched payload looks like an OpenAPI document (a non-emptyopenapistring and a non-emptypathsobject) 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.