back to delimit.ai
Worked-example report v1 / Delimit team / 2026-05-31

What $ref'd component-schema drift looks like under a merge gate

Three field-level mutations on the EU TED v3 procurement API, one merge gate, caught.

Delimit ran against the public European Commission TED v3 procurement API, fetched live from api.ted.europa.eu/api-v3.yaml on 2026-05-31. The spec is OpenAPI 3.1.0, declares 11 paths and 28 component schemas, and weighs roughly 454 KB. Three surgical mutations inside a single $ref'd component schema — a required-field drop, a property retype, and a new-required-field add — all flagged as breaking. Semver verdict: major.

The spec
EU TED v3
11 paths · 28 schemas
The drift
3 mutations
one $ref site
What we caught
3 breaking · 0 additive
semver major
Why it matters
Public sector
procurement scale
Why this worked example. $ref'd component-schema field-level drift is a known blind spot for path-only diff tools. The report demonstrates the fix.
  • Drift inside a $ref'd schema does not show up at any path or operation — the operation declarations are byte-identical on both ends.
  • The TED v3 spec is a real, public, widely-consumed surface; the European Commission publishes EU procurement notices through it.
  • No coordination with the TED team; the spec at api.ted.europa.eu/api-v3.yaml is the only input. The drifted spec is a synthetic mutation we made and clearly label as such.

The spec

TED — Tenders Electronic Daily — is the European Commission's public procurement publication system, the online version of the Supplement to the Official Journal of the European Union. The v3 Public API is the read/write surface eSenders use to submit, validate, render, and search procurement notices for EU member-state contracting authorities. The spec is served live at https://api.ted.europa.eu/api-v3.yaml and rendered through the Swagger UI at https://api.ted.europa.eu/swagger-ui/.

At the time of capture, the spec declares OpenAPI 3.1.0, info.version 3.0.0, 11 paths, 11 operations, and 28 component schemas. It is roughly 14,000 lines / 454 KB of YAML. The shape that matters for this report is the request body of /v3/notices/submit:POST:

# /v3/notices/submit (POST) — abbreviated
requestBody:
  content:
    multipart/form-data:
      schema:
        $ref: "#/components/schemas/NoticeSubmitRequest"

# #/components/schemas/NoticeSubmitRequest
NoticeSubmitRequest:
  type: object
  properties:
    metadata:
      $ref: "#/components/schemas/Metadata"
    notice:
      type: string
      format: binary
  required: [metadata, notice]

# #/components/schemas/Metadata  (the $ref target the report mutates)
Metadata:
  type: object
  properties:
    noticeAuthorEmail:
      type: string
    noticeAuthorLang:
      type: string
  required:
    - noticeAuthorEmail
    - noticeAuthorLang

The chain matters: the operation declaration /v3/notices/submit:POST $ref's NoticeSubmitRequest, which $ref's Metadata. Three layers of indirection between the path and the field. Path-only diff tools see the operation declaration unchanged on both ends and report "no breaking changes." That is the gap.

The drift we introduced

We took the live spec verbatim and applied three mutations inside #/components/schemas/Metadata. The path-level operation declaration is byte-identical on both ends. Each mutation is the kind of change that ships in real-world specs:

  1. Required field dropped. noticeAuthorLang (string, required) removed from Metadata.properties and from its required list.
  2. Property retyped. noticeAuthorEmail changed from type: string to type: integer. Name held; required-set membership held.
  3. New required field added. noticeAuthorOrganization (string) added to Metadata.properties and to its required list.

The mutated Metadata block, end to end:

# BEFORE (live api.ted.europa.eu/api-v3.yaml)
Metadata:
  type: object
  properties:
    noticeAuthorEmail:
      type: string
    noticeAuthorLang:
      type: string
  required:
    - noticeAuthorEmail
    - noticeAuthorLang

# AFTER (drifted)
Metadata:
  type: object
  properties:
    noticeAuthorEmail:
      type: integer
      format: int64
    noticeAuthorOrganization:
      type: string
  required:
    - noticeAuthorEmail
    - noticeAuthorOrganization

Three breaking changes at one $ref site. A path-only diff against the operation declaration shows zero changes. The test is whether the gate reaches through the $ref to the field.

What delimit caught

Verbatim output from delimit diff /tmp/ted_v3_baseline.yaml /tmp/ted_v3_drifted.yaml:

  3 change(s), 3 breaking

  [BREAKING] Required field 'noticeAuthorLang' removed at #/components/schemas/Metadata
  [BREAKING] New required field 'noticeAuthorOrganization' added at #/components/schemas/Metadata
  [BREAKING] Type changed from string to integer at #/components/schemas/Metadata.noticeAuthorEmail

And the same run through delimit lint, which adds policy compliance and the semver verdict:

------------------------------------------------------------
  GOVERNANCE PASSED WITH WARNINGS
  Semver: MAJOR
------------------------------------------------------------

  Total changes:     3
  Breaking changes:  3
  Policy violations: 1
    Errors:   0
    Warnings: 1

  Governance Gates
------------------------------------------------------------

  Gate                    Status
  ----------------------  ----------
  API Lint                FAIL
  Policy Compliance       FAIL (1 violation)
  Deploy Readiness        BLOCKED

  Deploy blocked until all gates pass.

Three findings, three distinct change-type taxonomy entries (field_removed, required_field_added, type_changed), all classified as breaking, semver bump major, deploy gate blocked. The path to the affected field — #/components/schemas/Metadata.<field> — is the same JSON Pointer a reviewer would use to navigate to the fix.

Findings

3 breaking, 0 additive, 0 flagged as spec hygiene. Each finding cites the exact change-type, the surface affected, and the consumer impact. All three findings hit the same $ref'd component schema — the test case for the LED-1597 fix.

  1. breakingfinding F1
    change type: field_removed (required field dropped)
    surface: #/components/schemas/Metadata.noticeAuthorLang

    The required string field noticeAuthorLang was removed from the Metadata schema. Metadata is $ref'd from NoticeSubmitRequest.metadata, which is the request body of /v3/notices/submit:POST — the endpoint eSenders call to publish a notice for OJS. noticeAuthorLang carries the EU official language (en, fr, de, etc.) the Contracting Authority wants Publications Office correspondence in. Code that constructs submit-request bodies against the prior shape and includes the field finds it silently ignored at the API surface; clients that omit it succeed where they previously failed schema validation. The change-type taxonomy fires field_removed at the schema path, not at the operation path, which is the test the LED-1597 fix added: a removal at #/components/schemas/<name>.<field> is surfaced as breaking even though the path-level operation declaration is byte-identical on both ends.

  2. breakingfinding F2
    change type: required_field_added (new required field)
    surface: #/components/schemas/Metadata.noticeAuthorOrganization

    A new required string field noticeAuthorOrganization was added to Metadata. The shift in obligation is on the consumer: an eSender sending the prior, valid submit body — metadata: { noticeAuthorEmail, noticeAuthorLang } — now fails schema validation against the new spec because noticeAuthorOrganization is not present. New required fields are a strictly tighter contract on the client side and are classified breaking regardless of whether the server happens to default the value at runtime. The gate surfaces the requirement so the consumer can map it before publish, not after the first 400 in production.

  3. breakingfinding F3
    change type: type_changed (stringinteger)
    surface: #/components/schemas/Metadata.noticeAuthorEmail

    The noticeAuthorEmail property's type was changed from string to integer. The field name stayed the same. The required-set membership stayed the same. The path stayed the same. The only thing that moved is the wire type — and that is the failure mode this finding exists to catch. A consumer that hand-rolled the submit body around a string-typed email address now ships an integer-shaped value or fails schema validation. A consumer that ran openapi-generator against the old spec now has Java/Python/Go bindings with String/str/string typed fields that the new API rejects. The diff engine reports type_changed at the same component-schema path the prior two findings used, which is what makes the per-finding consumer-impact map readable: three breaks at one shared $ref site, three distinct change-type entries, one schema to re-pin.

Why this matters for public-sector APIs

Public-sector APIs sit in a particular spot: real money on the line, statutory obligations on top of the contract, and a consumer base that is wide and largely uncoordinated. TED specifically publishes EU procurement notices — billions of euros of public contracts pass through the system each year, and the API is the wire-level interface that eSenders, member states, third-party platforms, and downstream observers all integrate against.

Drift inside a $ref'd component schema is exactly the class of change that hurts that kind of consumer base. The operation declaration looks unchanged. CI that diffs only paths and operations reports clean. Generated SDKs continue to compile against the new spec because the field-shape classes are emitted from the component, not the operation. The break only shows up when a downstream consumer hits production and gets a 4xx, or worse, when their integration quietly drops a field the server now ignores. The cost of silent drift compounds with the number of integrators, and in public procurement that number is large.

Path-level diff tools miss this class of change because they never reach through the $ref. Pre-merge, the gate either sees the field movement or it does not. The merge gate's job is to make the shape of the change visible at the JSON Pointer level — #/components/schemas/<name>.<field> — so that a reviewer can map the consumer impact before the spec ships and downstream integrators inherit the break.

What this report is not

Not a defect claim against the European Commission's TED v3 API. The drift in this report is a synthetic mutation we made to the live spec to demonstrate the engine. The real TED v3 spec at api.ted.europa.eu/api-v3.yaml is the baseline only.

The point of the report is the gap class, not the specific API. $ref'd component-schema field-level drift is a pattern that shows up everywhere OpenAPI 3.0 / 3.1 specs use $ref to share schemas across operations — which is most non-trivial specs. The TED v3 surface gives the demonstration a real-world shape, real procurement consequences, and a spec anyone can fetch and reproduce against.

The attestation artifact

A Delimit attestation is a bounded evidence record at a single spec pair. The same Delimit version run against the same two files produces the same bytes; that is the replayable property. The attestation does not opine on whether a change should have shipped, only on what shipped and how the change-type taxonomy classifies it.

For the precise list of checks, the explicit out-of-scope list, and the reproducibility guarantee, see the attestation methodology v1. This report is the OpenAPI-diff surface of the same primitive that powers the merge gate for AI-written code.

Reproduce this in your own repo

Anyone can re-run the analysis above and verify the same three findings come out. The full command sequence:

# Install the CLI
npm install -g delimit-cli

# Pull the live TED v3 spec as the baseline
curl -sL https://api.ted.europa.eu/api-v3.yaml \
  -o /tmp/ted_v3_baseline.yaml

# Make the drifted copy: edit #/components/schemas/Metadata
#   - remove noticeAuthorLang from properties + required
#   - change noticeAuthorEmail.type from string to integer
#   - add noticeAuthorOrganization (string), required
cp /tmp/ted_v3_baseline.yaml /tmp/ted_v3_drifted.yaml
# (apply the three mutations in your editor, or scripted)

# Run the merge gate
delimit lint /tmp/ted_v3_baseline.yaml /tmp/ted_v3_drifted.yaml

# Or the raw diff
delimit diff /tmp/ted_v3_baseline.yaml /tmp/ted_v3_drifted.yaml

If the output you get differs from the output in this report, that is itself a finding worth raising on the Delimit repo. The lint pass completes in well under one second on a standard laptop.

For your own API surface

If you ship an OpenAPI-described surface — public, private, partner-only, internal-only — and want field-level drift detection on every PR, install delimit-cli and run delimit lint <old> <new> against your own specs. The GitHub Action is on the Marketplace at delimit-ai/delimit-action. Free for individual maintainers. Pro tier $10/month for teams.

The signed, replayable attestation is the artifact your reviewers, auditors, or downstream consumers can read without rerunning the gate.