<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" ><generator uri="https://jekyllrb.com/" version="3.10.0">Jekyll</generator><link href="https://speclynx.com/feed.xml" rel="self" type="application/atom+xml" /><link href="https://speclynx.com/" rel="alternate" type="text/html" /><updated>2026-10-21T14:06:46+00:00</updated><id>https://speclynx.com/feed.xml</id><title type="html">SpecLynx</title><subtitle>SpecLynx is one toolchain for every API spec: parse, validate, transform, and lint OpenAPI, AsyncAPI, Arazzo, Overlay, and JSON Schema. Local-first, free, and open source.</subtitle><entry><title type="html">Stop copy-pasting in OpenAPI Overlays: reusable actions in Overlay 1.2</title><link href="https://speclynx.com/blog/overlay-1-2/" rel="alternate" type="text/html" title="Stop copy-pasting in OpenAPI Overlays: reusable actions in Overlay 1.2" /><published>2026-10-21T08:00:00+00:00</published><updated>2026-10-21T08:00:00+00:00</updated><id>https://speclynx.com/blog/overlay-1-2</id><content type="html" xml:base="https://speclynx.com/blog/overlay-1-2/"><![CDATA[<p>If you keep your OpenAPI description in one file and customize it with overlays, you’ve probably seen this happen. The overlay starts small: a summary here, an extension there. Then someone needs the same <code class="language-plaintext highlighter-rouge">404</code> response on every operation that reads or deletes a single resource. The easiest thing to do is to copy the action and change the target. A month later, the overlay contains the same block six times. Then the description of the response changes, five copies get updated, and one doesn’t. Nobody notices, because the overlay still applies cleanly.</p>

<p>If you’re one of the people who maintain overlays like this, this article was written just for you. We’ll go through three ways to stop pasting the same change into an overlay, show where each of them breaks, and look at what the new <a href="https://spec.openapis.org/overlay/v1.2.0.html">Overlay 1.2.0</a>, released on 22 September 2026, changes about it.</p>

<h2 id="what-is-an-overlay">What is an Overlay?</h2>

<p>Let’s first define it. I took the following description directly from the Overlay specification:</p>

<blockquote>
  <p>An Overlay is represented in JSON or YAML and contains actions that are applied in order to an OpenAPI description. Each action uses a target expressed as a RFC9535 JSONPath query expression to identify where changes apply.</p>

  <p><cite><a href="https://spec.openapis.org/overlay/v1.2.0.html">Overlay Specification 1.2.0</a></cite></p>
</blockquote>

<p>In practice, an overlay is how you keep one source description and still publish different versions of it: one for public docs, one for an SDK generator, one for a partner. Instead of maintaining copies of the description, you maintain a list of changes. Each action has a <code class="language-plaintext highlighter-rouge">target</code> that says where, and <code class="language-plaintext highlighter-rouge">update</code>, <code class="language-plaintext highlighter-rouge">copy</code>, or <code class="language-plaintext highlighter-rouge">remove</code> that says what.</p>

<h2 id="the-copy-paste-problem">The copy-paste problem</h2>

<p>Here is a small OpenAPI description I’ll use for the rest of the article:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">openapi</span><span class="pi">:</span> <span class="s">3.1.0</span>
<span class="na">info</span><span class="pi">:</span>
  <span class="na">title</span><span class="pi">:</span> <span class="s">Accounts API</span>
  <span class="na">version</span><span class="pi">:</span> <span class="s">1.0.0</span>
<span class="na">paths</span><span class="pi">:</span>
  <span class="na">/accounts</span><span class="pi">:</span>
    <span class="na">get</span><span class="pi">:</span>
      <span class="na">operationId</span><span class="pi">:</span> <span class="s">listAccounts</span>
      <span class="na">responses</span><span class="pi">:</span>
        <span class="s1">'</span><span class="s">200'</span><span class="err">:</span>
          <span class="na">description</span><span class="pi">:</span> <span class="s">A page of accounts.</span>
  <span class="s">/accounts/{id}</span><span class="err">:</span>
    <span class="na">get</span><span class="pi">:</span>
      <span class="na">operationId</span><span class="pi">:</span> <span class="s">getAccount</span>
      <span class="na">responses</span><span class="pi">:</span>
        <span class="s1">'</span><span class="s">200'</span><span class="err">:</span>
          <span class="na">description</span><span class="pi">:</span> <span class="s">One account.</span>
    <span class="na">delete</span><span class="pi">:</span>
      <span class="na">operationId</span><span class="pi">:</span> <span class="s">deleteAccount</span>
      <span class="na">responses</span><span class="pi">:</span>
        <span class="s1">'</span><span class="s">204'</span><span class="err">:</span>
          <span class="na">description</span><span class="pi">:</span> <span class="s">Account deleted.</span>
  <span class="s">/transfers/{id}</span><span class="err">:</span>
    <span class="na">get</span><span class="pi">:</span>
      <span class="na">operationId</span><span class="pi">:</span> <span class="s">getTransfer</span>
      <span class="na">responses</span><span class="pi">:</span>
        <span class="s1">'</span><span class="s">200'</span><span class="err">:</span>
          <span class="na">description</span><span class="pi">:</span> <span class="s">One transfer.</span>
</code></pre></div></div>

<p>Every operation on <code class="language-plaintext highlighter-rouge">/accounts/{id}</code> and <code class="language-plaintext highlighter-rouge">/transfers/{id}</code> can return <code class="language-plaintext highlighter-rouge">404</code>; <code class="language-plaintext highlighter-rouge">listAccounts</code> can’t. This is the overlay most people write first:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">overlay</span><span class="pi">:</span> <span class="s">1.1.0</span>
<span class="na">info</span><span class="pi">:</span>
  <span class="na">title</span><span class="pi">:</span> <span class="s">Add error responses</span>
  <span class="na">version</span><span class="pi">:</span> <span class="s">1.0.0</span>
<span class="na">actions</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">target</span><span class="pi">:</span> <span class="s">$.paths['/accounts/{id}'].*.responses</span>
    <span class="na">update</span><span class="pi">:</span>
      <span class="s1">'</span><span class="s">404'</span><span class="err">:</span>
        <span class="na">description</span><span class="pi">:</span> <span class="s">The resource does not exist.</span>
        <span class="na">content</span><span class="pi">:</span>
          <span class="na">application/problem+json</span><span class="pi">:</span>
            <span class="na">schema</span><span class="pi">:</span>
              <span class="na">$ref</span><span class="pi">:</span> <span class="s1">'</span><span class="s">#/components/schemas/Problem'</span>
  <span class="pi">-</span> <span class="na">target</span><span class="pi">:</span> <span class="s">$.paths['/transfers/{id}'].*.responses</span>
    <span class="na">update</span><span class="pi">:</span>
      <span class="s1">'</span><span class="s">404'</span><span class="err">:</span>
        <span class="na">description</span><span class="pi">:</span> <span class="s">The resource does not exist.</span>
        <span class="na">content</span><span class="pi">:</span>
          <span class="na">application/problem+json</span><span class="pi">:</span>
            <span class="na">schema</span><span class="pi">:</span>
              <span class="na">$ref</span><span class="pi">:</span> <span class="s1">'</span><span class="s">#/components/schemas/Problem'</span>
</code></pre></div></div>

<p>It’s correct, and with two paths it’s even readable. But the duplication grows with every path you add, and it’s the worst kind of duplication: the copies are supposed to stay identical, nothing checks that they do, and an outdated copy still applies without any error.</p>

<p>There’s one thing this naive version gets right, though, and it’s worth keeping in mind for the rest of the article: <strong>every target is its own action.</strong> When you apply it with a strict tool, a target that matches nothing fails on its own. Not every shortcut below keeps that.</p>

<h2 id="a-word-on-union-selectors">A word on union selectors</h2>

<p>RFC 9535 JSONPath allows several names in one bracket, so a single action can reach both paths:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="pi">-</span> <span class="na">target</span><span class="pi">:</span> <span class="s">$.paths['/accounts/{id}','/transfers/{id}'].*.responses</span>
</code></pre></div></div>

<p>The copy is gone, and for a short, stable list of paths, this is perfectly fine. But let’s see what happens when somebody misspells <code class="language-plaintext highlighter-rouge">/transfers/{id}</code> as <code class="language-plaintext highlighter-rouge">/transfer/{id}</code>. I applied both versions with <a href="/cli/">SpecLynx CLI</a> and <code class="language-plaintext highlighter-rouge">--strict</code>, which fails when an action’s target matches nothing.</p>

<p>The union selector:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>npx @speclynx/cli overlay apply union.overlay.yaml openapi.yaml <span class="nt">--strict</span> <span class="nt">--verbose</span>
Overlay: union.overlay.yaml -&gt; openapi.yaml
  <span class="o">[</span>ok] update <span class="nv">$.</span>paths[<span class="s1">'/accounts/{id}'</span>,<span class="s1">'/transfer/{id}'</span><span class="o">]</span>.<span class="k">*</span>.responses <span class="o">(</span>2 matches<span class="o">)</span>
  Overlay was successfully applied
</code></pre></div></div>

<p>One action per target:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>npx @speclynx/cli overlay apply errors.overlay.yaml openapi.yaml <span class="nt">--strict</span> <span class="nt">--verbose</span>
Overlay: errors.overlay.yaml -&gt; openapi.yaml
  <span class="o">[</span>ok] update <span class="nv">$.</span>paths[<span class="s1">'/accounts/{id}'</span><span class="o">]</span>.<span class="k">*</span>.responses <span class="o">(</span>2 matches<span class="o">)</span>
  <span class="o">[</span>FAIL] noop <span class="nv">$.</span>paths[<span class="s1">'/transfer/{id}'</span><span class="o">]</span>.<span class="k">*</span>.responses <span class="o">(</span>0 matches<span class="o">)</span>
         Action target <span class="s2">"</span><span class="nv">$.</span><span class="s2">paths['/transfer/{id}'].*.responses"</span> matched zero nodes <span class="o">(</span>strict mode<span class="o">)</span>

Error: Action target <span class="s2">"</span><span class="nv">$.</span><span class="s2">paths['/transfer/{id}'].*.responses"</span> matched zero nodes <span class="o">(</span>strict mode<span class="o">)</span>
</code></pre></div></div>

<p>The union still matched two nodes, so as far as strict mode is concerned, the action worked, and the missing <code class="language-plaintext highlighter-rouge">404</code> on transfers went completely unnoticed. This isn’t a bug: the target as a whole did match something (<a href="https://github.com/speclynx/apidom/issues/688">apidom#688</a> has the reasoning). A union selector removes the copy by merging targets, and merging targets is exactly what hides a typo.</p>

<h2 id="a-word-on-yaml-anchors">A word on YAML anchors</h2>

<p>If your overlay is written in YAML, you might reach for anchors and aliases (<code class="language-plaintext highlighter-rouge">&amp;notFound</code> and <code class="language-plaintext highlighter-rouge">*notFound</code>) to reuse the <code class="language-plaintext highlighter-rouge">update</code> block. In a YAML-only workflow, this works: you maintain one copy, and the parser replaces every alias with a copy of the anchored value. But the reuse lives in the YAML syntax, not in the overlay, and that has consequences:</p>

<ul>
  <li><strong>JSON overlays can’t use it.</strong> JSON has no anchors.</li>
  <li><strong>It doesn’t survive conversion.</strong> <a href="https://www.rfc-editor.org/rfc/rfc9512.html#section-3.4">RFC 9512</a>, which Overlay 1.2 refers to for how YAML maps to JSON, treats aliases as a serialization detail that can be discarded. Convert the overlay to JSON once, and the copies are back for good.</li>
  <li><strong>The anchor lives inside an action.</strong> YAML requires the anchor to come before its aliases, so the shared block sits in whichever action uses it first. Delete or move that action, and every alias after it stops parsing.</li>
</ul>

<h2 id="a-word-on-generating-overlays">A word on generating overlays</h2>

<p>The last DIY option is to stop writing the overlay by hand: keep a list of targets and a template, and generate the overlay with a small script (<code class="language-plaintext highlighter-rouge">jq</code>, <code class="language-plaintext highlighter-rouge">yq</code>, or a few lines of JavaScript). This works with any overlay version, in JSON and YAML, and with one action per target.</p>

<p>The cost is that you now maintain two things instead of one: the generator and its output. Reviewers review a generated file, and somebody eventually edits the output by hand instead of the input. For a team that already generates its OpenAPI description, this can be fine. For everybody else, it’s a lot of machinery for avoiding a copy.</p>

<h2 id="overlay-12-reusable-actions">Overlay 1.2: reusable actions</h2>

<p>Overlay 1.2 adds what the workarounds above were reaching for: a named, reusable action that every target references on its own. The shared change lives in <code class="language-plaintext highlighter-rouge">components.actions</code>, and every entry in <code class="language-plaintext highlighter-rouge">actions</code> only says where it goes:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">overlay</span><span class="pi">:</span> <span class="s">1.2.0</span>
<span class="na">info</span><span class="pi">:</span>
  <span class="na">title</span><span class="pi">:</span> <span class="s">Add error responses</span>
  <span class="na">version</span><span class="pi">:</span> <span class="s">1.0.0</span>
<span class="na">components</span><span class="pi">:</span>
  <span class="na">actions</span><span class="pi">:</span>
    <span class="na">notFound</span><span class="pi">:</span>
      <span class="na">description</span><span class="pi">:</span> <span class="s">The 404 response every single-resource operation returns.</span>
      <span class="na">fields</span><span class="pi">:</span>
        <span class="na">update</span><span class="pi">:</span>
          <span class="s1">'</span><span class="s">404'</span><span class="err">:</span>
            <span class="na">description</span><span class="pi">:</span> <span class="s">The resource does not exist.</span>
            <span class="na">content</span><span class="pi">:</span>
              <span class="na">application/problem+json</span><span class="pi">:</span>
                <span class="na">schema</span><span class="pi">:</span>
                  <span class="na">$ref</span><span class="pi">:</span> <span class="s1">'</span><span class="s">#/components/schemas/Problem'</span>
<span class="na">actions</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">$ref</span><span class="pi">:</span> <span class="s1">'</span><span class="s">#/components/actions/notFound'</span>
    <span class="na">target</span><span class="pi">:</span> <span class="s">$.paths['/accounts/{id}'].*.responses</span>
  <span class="pi">-</span> <span class="na">$ref</span><span class="pi">:</span> <span class="s1">'</span><span class="s">#/components/actions/notFound'</span>
    <span class="na">target</span><span class="pi">:</span> <span class="s">$.paths['/transfers/{id}'].*.responses</span>
    <span class="na">description</span><span class="pi">:</span> <span class="s">Transfers share the 404 of accounts.</span>
</code></pre></div></div>

<p>Please take a moment here and look at the shape. If you’ve ever written an OpenAPI description, it will look familiar: <code class="language-plaintext highlighter-rouge">components</code> holds the reusable parts and <code class="language-plaintext highlighter-rouge">$ref</code> points to them, the same way schemas and responses work in OpenAPI itself.</p>

<p>Let’s apply it:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>npx @speclynx/cli overlay apply errors.overlay.yaml openapi.yaml <span class="nt">-o</span> result.yaml <span class="nt">--verbose</span>
Overlay: errors.overlay.yaml -&gt; openapi.yaml
  <span class="o">[</span>ok] update <span class="nv">$.</span>paths[<span class="s1">'/accounts/{id}'</span><span class="o">]</span>.<span class="k">*</span>.responses <span class="o">(</span>2 matches<span class="o">)</span>
  <span class="o">[</span>ok] update <span class="nv">$.</span>paths[<span class="s1">'/transfers/{id}'</span><span class="o">]</span>.<span class="k">*</span>.responses <span class="o">(</span>1 matches<span class="o">)</span>
  Overlay was successfully applied
</code></pre></div></div>

<p>The result is byte for byte identical to the result of the copy-pasted 1.1 overlay. This is how it differs from the input:</p>

<div class="language-diff highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">@@ -15,14 +15,32 @@</span>
       responses:
         '200':
           description: One account.
<span class="gi">+        '404':
+          description: The resource does not exist.
+          content:
+            application/problem+json:
+              schema:
+                $ref: '#/components/schemas/Problem'
</span>     delete:
       operationId: deleteAccount
       responses:
         '204':
           description: Account deleted.
<span class="gi">+        '404':
+          description: The resource does not exist.
+          content:
+            application/problem+json:
+              schema:
+                $ref: '#/components/schemas/Problem'
</span>   /transfers/{id}:
     get:
       operationId: getTransfer
       responses:
         '200':
           description: One transfer.
<span class="gi">+        '404':
+          description: The resource does not exist.
+          content:
+            application/problem+json:
+              schema:
+                $ref: '#/components/schemas/Problem'
</span></code></pre></div></div>

<p>A few rules are worth knowing before you write your first reusable action:</p>

<ul>
  <li><strong>The target always comes from the reference.</strong> A reusable action must not contain a <code class="language-plaintext highlighter-rouge">target</code> inside its <code class="language-plaintext highlighter-rouge">fields</code>, and a reference must always have one. A reusable action that nobody references does nothing.</li>
  <li><strong>Only the description can be overridden.</strong> A reference has three fixed fields: <code class="language-plaintext highlighter-rouge">$ref</code>, <code class="language-plaintext highlighter-rouge">target</code>, and <code class="language-plaintext highlighter-rouge">description</code>. If both define a description, the reference’s description wins. You can’t change the <code class="language-plaintext highlighter-rouge">update</code> per use; for a different change, define a second reusable action.</li>
  <li><strong>References are local.</strong> <code class="language-plaintext highlighter-rouge">$ref</code> must point into <code class="language-plaintext highlighter-rouge">#/components/actions/</code> of the same overlay file. Keys are JSON Pointer tokens, so a key like <code class="language-plaintext highlighter-rouge">a/b</code> is referenced as <code class="language-plaintext highlighter-rouge">#/components/actions/a~1b</code>.</li>
</ul>

<p>Let’s put all the approaches side by side:</p>

<div class="table-scroll">

  <table>
    <thead>
      <tr>
        <th>Approach</th>
        <th>No copies</th>
        <th>Works in JSON</th>
        <th>Every target checked on its own</th>
        <th>Shared change has a name and a description</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td>Copy and paste</td>
        <td>no</td>
        <td>yes</td>
        <td>yes</td>
        <td>no</td>
      </tr>
      <tr>
        <td>Union selector</td>
        <td>yes</td>
        <td>yes</td>
        <td>no</td>
        <td>no</td>
      </tr>
      <tr>
        <td>YAML anchors</td>
        <td>in the YAML file only</td>
        <td>no</td>
        <td>yes</td>
        <td>no</td>
      </tr>
      <tr>
        <td>Generator script</td>
        <td>yes (in the input)</td>
        <td>yes</td>
        <td>yes</td>
        <td>in the generator</td>
      </tr>
      <tr>
        <td>Overlay 1.2 reusable actions</td>
        <td>yes</td>
        <td>yes</td>
        <td>yes</td>
        <td>yes</td>
      </tr>
    </tbody>
  </table>

</div>

<p>The one column where reusable actions lose today is one I didn’t put in the table: tool support. Overlay 1.2 is a month old, and not every overlay tool reads it yet.</p>

<h2 id="the-overlay-that-only-works-on-your-machine">The overlay that only works on your machine</h2>

<p>There’s a second problem Overlay 1.2 fixes, and it’s a quieter one. An overlay can say which OpenAPI description it was written for, using <code class="language-plaintext highlighter-rouge">extends</code>. Overlay 1.0 and 1.1 allowed <code class="language-plaintext highlighter-rouge">extends</code> to be a relative reference like <code class="language-plaintext highlighter-rouge">../openapi.yaml</code>, but they never said what it’s relative to. Relative to the overlay file? To the directory you run the tool from? Every tool had to decide on its own, so an overlay that worked from the repository root wasn’t guaranteed to work in CI, from a different directory or with a different tool.</p>

<p>Overlay 1.2 settles it: a relative <code class="language-plaintext highlighter-rouge">extends</code> is resolved against the overlay’s <code class="language-plaintext highlighter-rouge">$self</code> field if it has one, and against the location the overlay was loaded from if it doesn’t. Never against your working directory.</p>

<p>Without <code class="language-plaintext highlighter-rouge">$self</code>, the overlay file’s location is what counts:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">overlay</span><span class="pi">:</span> <span class="s">1.2.0</span>
<span class="na">extends</span><span class="pi">:</span> <span class="s">../openapi.yaml</span>
<span class="na">info</span><span class="pi">:</span>
  <span class="na">title</span><span class="pi">:</span> <span class="s">Add error responses</span>
  <span class="na">version</span><span class="pi">:</span> <span class="s">1.0.0</span>
<span class="c1"># ...</span>
</code></pre></div></div>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>npx @speclynx/cli overlay apply demo/overlays/extends.overlay.yaml <span class="nt">--verbose</span>
Overlay: extends.overlay.yaml -&gt; <span class="o">(</span>extends<span class="o">)</span>
  <span class="o">[</span>ok] update <span class="nv">$.</span>paths[<span class="s1">'/accounts/{id}'</span><span class="o">]</span>.<span class="k">*</span>.responses <span class="o">(</span>2 matches<span class="o">)</span>
  <span class="o">[</span>ok] update <span class="nv">$.</span>paths[<span class="s1">'/transfers/{id}'</span><span class="o">]</span>.<span class="k">*</span>.responses <span class="o">(</span>1 matches<span class="o">)</span>
  Overlay was successfully applied
</code></pre></div></div>

<p>The target argument is omitted here, so the overlay is applied to its <code class="language-plaintext highlighter-rouge">extends</code>, and you get the same result from whichever directory you run it.</p>

<p>With <code class="language-plaintext highlighter-rouge">$self</code>, the overlay carries its own identity, wherever the file happens to be. The following overlay lives on my disk, but it says it belongs next to the sample description from the SpecLynx <a href="/docs/guides/getting-started/">getting-started guide</a>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">overlay</span><span class="pi">:</span> <span class="s">1.2.0</span>
<span class="na">$self</span><span class="pi">:</span> <span class="s">https://speclynx.com/assets/samples/getting-started/petstore.overlay.yaml</span>
<span class="na">extends</span><span class="pi">:</span> <span class="s">openapi.yaml</span>
<span class="na">info</span><span class="pi">:</span>
  <span class="na">title</span><span class="pi">:</span> <span class="s">Add a server</span>
  <span class="na">version</span><span class="pi">:</span> <span class="s">1.0.0</span>
<span class="na">actions</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">target</span><span class="pi">:</span> <span class="s">$</span>
    <span class="na">update</span><span class="pi">:</span>
      <span class="na">servers</span><span class="pi">:</span>
        <span class="pi">-</span> <span class="na">url</span><span class="pi">:</span> <span class="s">https://api.petstore.example.com</span>
</code></pre></div></div>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>npx @speclynx/cli overlay apply petstore.overlay.yaml <span class="nt">--verbose</span>
Overlay: petstore.overlay.yaml -&gt; <span class="o">(</span>extends<span class="o">)</span>
  <span class="o">[</span>ok] update <span class="nv">$ </span><span class="o">(</span>1 matches<span class="o">)</span>
  Overlay was successfully applied

openapi: 3.1.0
info:
  title: Petstore API
...
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">extends: openapi.yaml</code> was resolved against <code class="language-plaintext highlighter-rouge">$self</code> to <code class="language-plaintext highlighter-rouge">https://speclynx.com/assets/samples/getting-started/openapi.yaml</code>, not to a file next to the overlay on my disk.</p>

<p>The specification goes one step further and treats <code class="language-plaintext highlighter-rouge">extends</code> as a name rather than an address:</p>

<blockquote>
  <p>URIs used in an Overlay document, including $self and extends, are identifiers, and not necessarily locators (URLs).</p>

  <p><cite><a href="https://spec.openapis.org/overlay/v1.2.0.html">Overlay Specification 1.2.0</a></cite></p>
</blockquote>

<p>If an OpenAPI 3.2 description declares its own <code class="language-plaintext highlighter-rouge">$self</code>, tools are expected to match <code class="language-plaintext highlighter-rouge">extends</code> against it. An overlay written for <code class="language-plaintext highlighter-rouge">https://apis.example.com/approved/petstore.yaml</code> then still applies to a copy of that description served from a mirror. <a href="https://spec.openapis.org/overlay/v1.2.0.html#appendix-b-examples-of-base-uri-determination-and-identifier-resolution">Appendix B</a> of the specification walks through all the cases, and I highly recommend reading it.</p>

<h2 id="catching-mistakes-before-ci-applies-them">Catching mistakes before CI applies them</h2>

<p>New fields mean new ways to get things wrong: a <code class="language-plaintext highlighter-rouge">$self</code> with a fragment, a <code class="language-plaintext highlighter-rouge">target</code> left inside a reusable action, a reference to <code class="language-plaintext highlighter-rouge">notfound</code> instead of <code class="language-plaintext highlighter-rouge">notFound</code>, a reference without a target. All of these are mistakes a linter can catch before anything is applied. Here is an overlay with four of them:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">overlay</span><span class="pi">:</span> <span class="s">1.2.0</span>
<span class="na">$self</span><span class="pi">:</span> <span class="s">https://example.com/overlays/errors.overlay.yaml#v1</span>
<span class="na">info</span><span class="pi">:</span>
  <span class="na">title</span><span class="pi">:</span> <span class="s">Add error responses</span>
  <span class="na">version</span><span class="pi">:</span> <span class="s">1.0.0</span>
<span class="na">components</span><span class="pi">:</span>
  <span class="na">actions</span><span class="pi">:</span>
    <span class="na">notFound</span><span class="pi">:</span>
      <span class="na">fields</span><span class="pi">:</span>
        <span class="na">target</span><span class="pi">:</span> <span class="s">$.paths.*.*.responses</span>
        <span class="na">update</span><span class="pi">:</span>
          <span class="s1">'</span><span class="s">404'</span><span class="err">:</span>
            <span class="na">description</span><span class="pi">:</span> <span class="s">The resource does not exist.</span>
<span class="na">actions</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">$ref</span><span class="pi">:</span> <span class="s1">'</span><span class="s">#/components/actions/notfound'</span>
    <span class="na">target</span><span class="pi">:</span> <span class="s">$.paths['/accounts/{id}'].*.responses</span>
  <span class="pi">-</span> <span class="na">$ref</span><span class="pi">:</span> <span class="s1">'</span><span class="s">#/components/actions/notFound'</span>
</code></pre></div></div>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>npx @speclynx/cli validate broken.overlay.yaml
broken.overlay.yaml
  2:8-2:59     error  10010602              <span class="nv">$self</span> must not contain a fragment identifier
  10:9-10:15   error  10050201              target must not be defined <span class="k">in </span>Reusable Action fields<span class="p">;</span> it is supplied by the Reusable Action Reference Object
  15:11-15:42  error  321-31-1792587509947  <span class="nb">local </span>reference not found
  17:5-18:1    error  10060200              should always have a <span class="s1">'target'</span>

✖ 4 problems <span class="o">(</span>4 errors<span class="o">)</span>
</code></pre></div></div>

<p>Whatever tool you use, run a validation step like this in CI before the step that applies your overlays. A broken reference is much cheaper to fix in a pull request than in a published description.</p>

<h2 id="what-overlay-12-doesnt-solve">What Overlay 1.2 doesn’t solve</h2>

<p>Reusable actions remove the copy, but not everything around it:</p>

<ul>
  <li><strong>You still list every target.</strong> RFC 9535 JSONPath can’t select keys by pattern, so there’s no single expression for “every path with an <code class="language-plaintext highlighter-rouge">{id}</code> in it”. Each target still needs its own reference. It’s just one line now instead of a whole block.</li>
  <li><strong>Reuse ends at the file.</strong> A reusable action can’t be shared across overlay files. If five overlays need the same <code class="language-plaintext highlighter-rouge">404</code>, each of them defines it.</li>
  <li><strong>No per-use variations.</strong> Apart from the description, a reference can’t change what the action does. Two variations of a change are two reusable actions.</li>
</ul>

<h2 id="trying-it-today">Trying it today</h2>

<p>Overlay 1.2 support is still new across the ecosystem. On the SpecLynx side, since <a href="https://github.com/speclynx/speclynx-cli/releases/tag/v1.5.0">version 1.5</a>, <a href="/cli/">SpecLynx CLI</a> applies, validates, and generates 1.2 overlays (<code class="language-plaintext highlighter-rouge">overlay diff</code> now generates 1.2 by default, and <code class="language-plaintext highlighter-rouge">--overlay-version 1.1.0</code> targets tools that don’t read 1.2 yet). Since <a href="https://github.com/speclynx/api-languageservice/releases/tag/v2.14.0">version 2.14</a> (the latest is <a href="https://github.com/speclynx/api-languageservice/releases/tag/v3.0.0">3.0</a>), the <a href="/language-service/">Language Service</a> provides linting, completion, and hover documentation for 1.2. Both are built on <a href="/apidom/">ApiDOM</a>, which supports 1.2 since <a href="https://github.com/speclynx/apidom/releases/tag/v6.0.0">version 6.0</a> and which you can use directly if you apply overlays from your own code. The <a href="/openapi-toolkit/">OpenAPI Toolkit</a> for VS Code and the browser <a href="/editor/">Editor</a> don’t support 1.2 yet. All examples in this article were run with SpecLynx CLI <a href="https://github.com/speclynx/speclynx-cli/releases/tag/v1.5.2">1.5.2</a>, the latest version at the time of writing.</p>

<h2 id="closing-words">Closing words</h2>

<p>Copy-pasted actions are the most common way overlays rot: they apply cleanly while their copies drift apart. Every workaround available before 1.2 trades something away: union selectors hide typos, anchors don’t survive JSON, and generators add code to maintain. If your tools support it, go for Overlay 1.2: move the shared change into <code class="language-plaintext highlighter-rouge">components.actions</code>, give every target its own reference, and apply your overlays with strict mode on. Sounds pretty easy, right?</p>

<p>Have you found another way to keep overlays free of copies, or a case where reusable actions don’t fit? I’d be happy to add it to this article. And if your Overlay 1.2 document doesn’t apply the way the specification says it should, please <a href="https://github.com/speclynx/speclynx-cli/issues">open an issue</a> with an example. I’d highly appreciate it!</p>

<p><em>Further reading:</em> <a href="https://spec.openapis.org/overlay/v1.2.0.html">Overlay Specification 1.2.0</a> · <a href="https://www.rfc-editor.org/rfc/rfc9535.html">RFC 9535: JSONPath</a> · <a href="/blog/overlay-jsonpath-dialects/">One Overlay, three JSONPaths</a> · <a href="/blog/overlay-yaml-round-tripping/">Your overlay changed one field. Why does the diff touch half the file?</a></p>]]></content><author><name>Vladimír Gorej</name></author><summary type="html"><![CDATA[Overlays that add the same error response to many operations end up with the same block pasted again and again, and the copies drift apart. Three ways to remove the copy, where each one breaks, and what Overlay 1.2 changes.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://speclynx.com/assets/images/blog/overlay-1-2.webp" /><media:content medium="image" url="https://speclynx.com/assets/images/blog/overlay-1-2.webp" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">Your overlay changed one field. Why does the diff touch half the file?</title><link href="https://speclynx.com/blog/overlay-yaml-round-tripping/" rel="alternate" type="text/html" title="Your overlay changed one field. Why does the diff touch half the file?" /><published>2026-10-18T08:00:00+00:00</published><updated>2026-10-18T08:00:00+00:00</updated><id>https://speclynx.com/blog/overlay-yaml-round-tripping</id><content type="html" xml:base="https://speclynx.com/blog/overlay-yaml-round-tripping/"><![CDATA[<p>Here is a workflow I see a lot. You keep your OpenAPI description in YAML, written by hand, reviewed in pull requests. You don’t want to fork it for every audience, so you write an <a href="https://spec.openapis.org/overlay/v1.2.0.html">OpenAPI Overlay</a> (a separate file that lists changes to apply to the document) that adds a summary here, removes an internal path there. CI applies the overlay and opens a pull request with the result. You open it and see that the overlay added one field, but the diff touches half the file. Descriptions are re-wrapped, quotes have changed, comments are gone, and a Markdown list in a description is now one long line.</p>

<p>If you work design-first, that document isn’t build output. You wrote it by hand, and the formatting is part of the work: descriptions wrapped where they read well, Markdown lists that render in your docs, comments that explain why an endpoint looks the way it does. Design-first people tend to be proud of their OpenAPI documents, and rightly so. Adding a single field with an overlay shouldn’t undo any of that.</p>

<p>There’s a second, practical problem: nobody can review a diff that touches half the file. In practice, people either stop reading the overlay output, or they stop using overlays and go back to maintaining copies by hand. Both are bad outcomes, and both have the same root cause: the tool didn’t round-trip the document.</p>

<p>Below I explain why this happens, give you two tests to run on your own tool, and show how four overlay CLIs did on them.</p>

<h2 id="what-is-round-tripping">What is round-tripping?</h2>

<p>Let’s first define it:</p>

<blockquote>
  <p>Round-tripping is reading a document, changing it, and writing it back so that everything you didn’t change comes out exactly as it went in.</p>
</blockquote>

<p>For an Overlay tool, I’d split this into two rules:</p>

<ol>
  <li><strong>Everything the overlay doesn’t target is kept byte for byte.</strong> Comments, quoting, indentation, block scalars, anchors, flow collections, line breaks. If the overlay didn’t ask for it, the output shouldn’t change it.</li>
  <li><strong>Everything the overlay adds keeps the overlay’s own style.</strong> If the overlay author wrote <code class="language-plaintext highlighter-rouge">summary: "Fetch one account"</code> with double quotes, or <code class="language-plaintext highlighter-rouge">x-audience: [public]</code> as a flow sequence, that’s how it should land in the result.</li>
</ol>

<p>With both rules in place, the diff of an overlay run contains only what the overlay did, and you can review it again.</p>

<h2 id="why-yaml-makes-this-hard">Why YAML makes this hard</h2>

<p>JSON gives you few ways to write a given value. YAML gives you many:</p>

<ul>
  <li>The same string can be <a href="https://yaml.org/spec/1.2.2/#733-plain-style">plain</a> (no quotes), <a href="https://yaml.org/spec/1.2.2/#732-single-quoted-style">single-quoted</a> (<code class="language-plaintext highlighter-rouge">'…'</code>), <a href="https://yaml.org/spec/1.2.2/#731-double-quoted-style">double-quoted</a> (<code class="language-plaintext highlighter-rouge">"…"</code>), or a block that spans several lines: a <a href="https://yaml.org/spec/1.2.2/#812-literal-style">literal block</a> (<code class="language-plaintext highlighter-rouge">|</code>) keeps the line breaks, a <a href="https://yaml.org/spec/1.2.2/#813-folded-style">folded block</a> (<code class="language-plaintext highlighter-rouge">&gt;</code>) joins lines with spaces.</li>
  <li>A block can carry small markers: a <a href="https://yaml.org/spec/1.2.2/#8112-block-chomping-indicator">chomping indicator</a> (<code class="language-plaintext highlighter-rouge">-</code>, <code class="language-plaintext highlighter-rouge">+</code>) controls the trailing line break, and an <a href="https://yaml.org/spec/1.2.2/#8111-block-indentation-indicator">indentation indicator</a> (<code class="language-plaintext highlighter-rouge">|2-</code>) fixes the indentation.</li>
  <li>A list can be indented lines starting with <code class="language-plaintext highlighter-rouge">-</code> (a <a href="https://yaml.org/spec/1.2.2/#821-block-sequences">block sequence</a>), or written inline as <code class="language-plaintext highlighter-rouge">[a, b]</code> (a <a href="https://yaml.org/spec/1.2.2/#741-flow-sequences">flow sequence</a>).</li>
  <li>A node can be named with an <a href="https://yaml.org/spec/1.2.2/#692-node-anchors">anchor</a> (<code class="language-plaintext highlighter-rouge">&amp;account</code>) and reused elsewhere with an <a href="https://yaml.org/spec/1.2.2/#71-alias-nodes">alias</a> (<code class="language-plaintext highlighter-rouge">*account</code>).</li>
  <li>And <a href="https://yaml.org/spec/1.2.2/#66-comments">comments</a> aren’t part of the data at all.</li>
</ul>

<p>Most tools parse YAML into ordinary data (objects, arrays, strings) and throw all of that away. <code class="language-plaintext highlighter-rouge">{ title: "The user's accounts API" }</code> doesn’t remember that it was written as <code class="language-plaintext highlighter-rouge">'The user''s accounts API'</code>. When the tool serializes the value back, it picks its own style, and the document changes even where nothing was changed.</p>

<p>Sometimes it’s worse than style: the parser reads the <em>value</em> wrong. Here are three YAML edge cases where that happens:</p>

<ul>
  <li><strong>Escaped quotes.</strong> Inside a single-quoted string, an apostrophe is written twice: <code class="language-plaintext highlighter-rouge">'The user''s API'</code> means <code class="language-plaintext highlighter-rouge">The user's API</code>. A parser that doesn’t turn <code class="language-plaintext highlighter-rouge">''</code> back into <code class="language-plaintext highlighter-rouge">'</code> stores <code class="language-plaintext highlighter-rouge">The user''s API</code>. When the serializer writes it out, it escapes both quotes again, giving <code class="language-plaintext highlighter-rouge">'The user''''s API'</code>.</li>
  <li><strong>Block indicators in either order.</strong> <code class="language-plaintext highlighter-rouge">|2-</code> and <code class="language-plaintext highlighter-rouge">|-2</code> mean the same thing: content indented 2 spaces deeper than its parent, final line break and any trailing empty lines removed. A parser that only recognizes one order gets the indentation wrong, and the text comes back shifted.</li>
  <li><strong>More-indented lines in folded blocks.</strong> In a <code class="language-plaintext highlighter-rouge">&gt;</code> block, ordinary lines are joined into one paragraph, but lines indented further than the rest are kept exactly as written. That is how Markdown bullet lists and tables survive inside a <code class="language-plaintext highlighter-rouge">description</code>. A parser that joins those lines too merges the list into the surrounding prose.</li>
</ul>

<p>In all three cases the damage happens while reading. Switching the output to JSON doesn’t help: the value is already wrong.</p>

<h2 id="a-small-document-that-exercises-all-of-it">A small document that exercises all of it</h2>

<p>Here is an OpenAPI document I’ll use for the rest of the article. It’s small on purpose, but every construct in it has a real bug report behind it:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">openapi</span><span class="pi">:</span> <span class="s">3.1.0</span>
<span class="na">info</span><span class="pi">:</span>
  <span class="na">title</span><span class="pi">:</span> <span class="s1">'</span><span class="s">The</span><span class="nv"> </span><span class="s">user'</span><span class="s1">'</span><span class="s">s</span><span class="nv"> </span><span class="s">accounts</span><span class="nv"> </span><span class="s">API'</span>
  <span class="na">version</span><span class="pi">:</span> <span class="s">1.0.0</span>
<span class="na">paths</span><span class="pi">:</span>
  <span class="c1"># Read-only for now; writes land in v2.</span>
  <span class="s">/accounts/{id}</span><span class="err">:</span>
    <span class="na">get</span><span class="pi">:</span>
      <span class="na">operationId</span><span class="pi">:</span> <span class="s">getAccount</span>
      <span class="na">tags</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">accounts</span><span class="pi">,</span> <span class="nv">read</span><span class="pi">]</span>
      <span class="na">responses</span><span class="pi">:</span>
        <span class="s1">'</span><span class="s">200'</span><span class="err">:</span>
          <span class="na">description</span><span class="pi">:</span> <span class="pi">&gt;-</span>
            <span class="s">Account details. If the response contains unmasked identifiers</span>
            <span class="s">this is sensitive data which should only be exchanged if encrypted.</span>
              <span class="s">* Routing number: identifies the institution;</span>
              <span class="s">* Account number: identifies the customer account.</span>
          <span class="na">content</span><span class="pi">:</span>
            <span class="na">application/json</span><span class="pi">:</span>
              <span class="na">schema</span><span class="pi">:</span> <span class="nl">&amp;account</span>
                <span class="na">type</span><span class="pi">:</span> <span class="s">object</span>
        <span class="s1">'</span><span class="s">404'</span><span class="err">:</span>
          <span class="na">description</span><span class="pi">:</span> <span class="pi">|2-</span>

            <span class="s">No account with this id.</span>
  <span class="na">/me</span><span class="pi">:</span>
    <span class="na">get</span><span class="pi">:</span>
      <span class="na">operationId</span><span class="pi">:</span> <span class="s">getMe</span>
      <span class="na">responses</span><span class="pi">:</span>
        <span class="s1">'</span><span class="s">200'</span><span class="err">:</span>
          <span class="na">description</span><span class="pi">:</span> <span class="s">The signed-in user's account.</span>
          <span class="na">content</span><span class="pi">:</span>
            <span class="na">application/json</span><span class="pi">:</span>
              <span class="na">schema</span><span class="pi">:</span> <span class="nv">*account</span>
</code></pre></div></div>

<h2 id="what-you-get-when-you-build-it-yourself">What you get when you build it yourself</h2>

<p>If you’re tempted to write your own overlay script, the first step is a YAML library. This is what the round trip looks like with the two most popular ones in JavaScript, <a href="https://github.com/nodeca/js-yaml"><code class="language-plaintext highlighter-rouge">js-yaml</code></a> and <a href="https://eemeli.org/yaml/"><code class="language-plaintext highlighter-rouge">yaml</code></a>. I parsed the document above and wrote it back <strong>without changing anything</strong>:</p>

<div class="table-scroll">

  <table>
    <thead>
      <tr>
        <th>Approach</th>
        <th>What changed with zero edits</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td><code class="language-plaintext highlighter-rouge">js-yaml</code> 4: <code class="language-plaintext highlighter-rouge">dump(load(src))</code></td>
        <td>Quotes dropped from the title, comment removed, flow sequence turned into a block sequence, <code class="language-plaintext highlighter-rouge">&gt;-</code> description re-wrapped, anchor and alias renamed to <code class="language-plaintext highlighter-rouge">&amp;ref_0</code> and <code class="language-plaintext highlighter-rouge">*ref_0</code>, <code>&#124;2-</code> written as <code>&#124;-</code></td>
      </tr>
      <tr>
        <td><code class="language-plaintext highlighter-rouge">yaml</code> 2 Document API: <code class="language-plaintext highlighter-rouge">parseDocument(src).toString({ lineWidth: 0 })</code></td>
        <td>Values identical, but the flow sequence gains padding (<code class="language-plaintext highlighter-rouge">[ accounts, read ]</code>), the <code class="language-plaintext highlighter-rouge">&gt;-</code> description is joined into one long line, and <code>&#124;2-</code> is written as <code>&#124;-</code></td>
      </tr>
      <tr>
        <td><code class="language-plaintext highlighter-rouge">yaml</code> 2 CST API: <code class="language-plaintext highlighter-rouge">Parser</code> + <code class="language-plaintext highlighter-rouge">CST.stringify()</code></td>
        <td>Nothing, byte for byte</td>
      </tr>
    </tbody>
  </table>

</div>

<p>A value-level load and dump has nowhere to keep the style. The Document API keeps comments, quotes, and flow collections, but writes them back in its own layout: block scalars are re-folded and flow brackets get padding. The CST API (concrete syntax tree: the parser’s token-by-token view of the file) really round-trips: it keeps every token. <code class="language-plaintext highlighter-rouge">CST.setScalarValue()</code> changes a value in place and keeps its type (quoted stays quoted, folded stays folded), although block scalar headers and line breaks are regenerated. If all you need is to change existing single-line values, that is a great foundation.</p>

<p>An overlay engine needs more than that. JSONPath targets have to be mapped onto a tree of tokens. An <code class="language-plaintext highlighter-rouge">update</code> merges whole objects into the document, so you build new nodes by hand, indent them to fit, and keep the overlay’s style. A <code class="language-plaintext highlighter-rouge">remove</code> has to take the right comments and blank lines with it. The first version of such a script is easy. Keeping it correct on a real-world description tens of thousands of lines long is where the time goes.</p>

<h2 id="how-to-check-your-overlay-tool">How to check your overlay tool</h2>

<p>You don’t have to take any tool’s word for it. Two quick tests tell you most of what you need to know.</p>

<h3 id="the-one-line-test">The one-line test</h3>

<p>Apply an overlay that adds a single extension field:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">overlay</span><span class="pi">:</span> <span class="s">1.0.0</span>
<span class="na">info</span><span class="pi">:</span>
  <span class="na">title</span><span class="pi">:</span> <span class="s">Round-trip check</span>
  <span class="na">version</span><span class="pi">:</span> <span class="s">1.0.0</span>
<span class="na">actions</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">target</span><span class="pi">:</span> <span class="s">$.info</span>
    <span class="na">update</span><span class="pi">:</span>
      <span class="na">x-round-trip-check</span><span class="pi">:</span> <span class="no">true</span>
</code></pre></div></div>

<p>Apply it to your real document and diff the result against the input. The diff should be <strong>exactly one added line</strong>. Every other line that shows up is a line your tool will rewrite on every run, whatever your overlay does.</p>

<p>Why not an empty <code class="language-plaintext highlighter-rouge">update: {}</code>? A tool can notice there’s nothing to do and return the input untouched, so the empty diff proves nothing. A real change, however small, forces the full round trip.</p>

<h3 id="the-style-test">The style test</h3>

<p>Apply an overlay that adds values written in specific styles. Point the target at any operation in your own document; here it’s the sample’s <code class="language-plaintext highlighter-rouge">/accounts/{id}</code>:</p>

<div class="language-yaml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="na">overlay</span><span class="pi">:</span> <span class="s">1.0.0</span>
<span class="na">info</span><span class="pi">:</span>
  <span class="na">title</span><span class="pi">:</span> <span class="s">Add public description</span>
  <span class="na">version</span><span class="pi">:</span> <span class="s">1.0.0</span>
<span class="na">actions</span><span class="pi">:</span>
  <span class="pi">-</span> <span class="na">target</span><span class="pi">:</span> <span class="s">$.paths['/accounts/{id}'].get</span>
    <span class="na">update</span><span class="pi">:</span>
      <span class="na">summary</span><span class="pi">:</span> <span class="s2">"</span><span class="s">Fetch</span><span class="nv"> </span><span class="s">one</span><span class="nv"> </span><span class="s">account"</span>
      <span class="na">description</span><span class="pi">:</span> <span class="pi">|</span>
        <span class="s">Returns a single account.</span>

        <span class="s">Requires the `accounts:read` scope.</span>
      <span class="na">x-audience</span><span class="pi">:</span> <span class="pi">[</span><span class="nv">public</span><span class="pi">]</span>
</code></pre></div></div>

<p>The diff should contain exactly these lines, written the way they are written here: the double quotes, the literal block, the flow sequence.</p>

<p>When you read your diffs, look first at the constructs that break most often:</p>

<ul>
  <li>escaped quotes (<code class="language-plaintext highlighter-rouge">'it''s'</code>)</li>
  <li>comments</li>
  <li>Markdown lists and tables in <code class="language-plaintext highlighter-rouge">&gt;</code> blocks</li>
  <li>block indicators (<code class="language-plaintext highlighter-rouge">|2-</code>, <code class="language-plaintext highlighter-rouge">|+</code>, <code class="language-plaintext highlighter-rouge">&gt;-</code>)</li>
  <li>anchors and aliases</li>
  <li>flow collections (<code class="language-plaintext highlighter-rouge">[a, b]</code>)</li>
  <li>Windows line breaks (CRLF)</li>
</ul>

<h2 id="how-four-overlay-clis-compare">How four overlay CLIs compare</h2>

<p>I ran the one-line test and the style test on the sample document above, through four overlay CLIs at their latest versions on 18 October 2026: <a href="https://github.com/speakeasy-api/speakeasy">Speakeasy CLI</a> 1.802.0, <a href="https://github.com/bump-sh/cli">Bump.sh CLI</a> 2.11.0, <a href="https://github.com/thim81/openapi-format">openapi-format</a> 1.34.0, and <a href="/cli/">SpecLynx CLI</a> 1.5.1. Every tool ran with its default options. Full disclosure: I maintain SpecLynx CLI.</p>

<div class="table-scroll">

  <table>
    <thead>
      <tr>
        <th>Check</th>
        <th>Speakeasy CLI</th>
        <th>Bump.sh CLI</th>
        <th>openapi-format</th>
        <th>SpecLynx CLI</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td><strong>One-line test: lines changed</strong> (should be 1)</td>
        <td>6</td>
        <td>21</td>
        <td>20</td>
        <td>1</td>
      </tr>
      <tr>
        <td>Escaped quote kept</td>
        <td>yes</td>
        <td>no</td>
        <td>no</td>
        <td>yes</td>
      </tr>
      <tr>
        <td>Comment kept</td>
        <td>yes</td>
        <td>yes</td>
        <td>no</td>
        <td>yes</td>
      </tr>
      <tr>
        <td>Flow sequence kept</td>
        <td>yes</td>
        <td>no</td>
        <td>no</td>
        <td>yes</td>
      </tr>
      <tr>
        <td>Folded block kept</td>
        <td>no</td>
        <td>no</td>
        <td>no</td>
        <td>yes</td>
      </tr>
      <tr>
        <td><code>&#124;2-</code> block kept</td>
        <td>no</td>
        <td>no</td>
        <td>no</td>
        <td>yes</td>
      </tr>
      <tr>
        <td>Anchor and alias kept</td>
        <td>yes</td>
        <td>no</td>
        <td>no</td>
        <td>yes</td>
      </tr>
      <tr>
        <td>Key order kept</td>
        <td>yes</td>
        <td>yes</td>
        <td>no</td>
        <td>yes</td>
      </tr>
      <tr>
        <td>Overlay’s own style kept</td>
        <td>yes</td>
        <td>no</td>
        <td>no</td>
        <td>yes</td>
      </tr>
      <tr>
        <td><strong><abbr title="Parsing the result gives exactly the same data as the input plus the overlay's changes. Only the formatting may differ.">All values unchanged</abbr></strong></td>
        <td><strong><abbr title="Two descriptions changed: the 200 description gained an empty line, and the 404 description lost its leading line break.">no</abbr></strong></td>
        <td>yes</td>
        <td>yes</td>
        <td>yes</td>
      </tr>
    </tbody>
  </table>

</div>

<p><em>All values unchanged</em> means that parsing the result gives exactly the input data plus the overlay’s changes; only the formatting may differ.</p>

<p>The commands I ran:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="c"># Speakeasy CLI</span>
<span class="nv">$ </span>speakeasy overlay apply <span class="nt">-s</span> openapi.yaml <span class="nt">-o</span> overlay.yaml <span class="nt">--out</span> result.yaml

<span class="c"># Bump.sh CLI</span>
<span class="nv">$ </span>bump overlay openapi.yaml overlay.yaml <span class="nt">-o</span> result.yaml

<span class="c"># openapi-format</span>
<span class="nv">$ </span>openapi-format openapi.yaml <span class="nt">--overlayFile</span> overlay.yaml <span class="nt">-o</span> result.yaml

<span class="c"># SpecLynx CLI</span>
<span class="nv">$ </span>speclynx overlay apply overlay.yaml openapi.yaml <span class="nt">-o</span> result.yaml
</code></pre></div></div>

<p>Bump.sh CLI and openapi-format keep every value, but they rewrite most of the file, much like the <code class="language-plaintext highlighter-rouge">js-yaml</code> row above. Quotes, flow collections, and the anchor are gone. Bump.sh CLI rewrites both block scalars; openapi-format keeps the <code class="language-plaintext highlighter-rouge">&gt;-</code> indicator but re-wraps its text and loses <code class="language-plaintext highlighter-rouge">|2-</code>. With its default options, openapi-format also drops the comment and reorders keys. Speakeasy CLI and Bump.sh CLI have no output formatting options, but openapi-format does, so I tried them all. Its best combination, <code class="language-plaintext highlighter-rouge">--no-sort --keepComments --no-bundle</code>, keeps the comment and key order and gets the one-line test down to 18 lines. The quotes, flow sequence, block scalars, and the overlay’s own style still change, and the anchor is kept but renamed to <code class="language-plaintext highlighter-rouge">&amp;a1</code>.</p>

<p>Speakeasy CLI keeps nearly all of the style, which is impressive. But it re-wraps the folded description and adds an empty line to it, and it drops the empty first line of the <code>&#124;2-</code> block. Both of those change the value. That’s the worst kind of change, because a reviewer reads it as formatting noise.</p>

<p>If I misused any of these tools, tell me and I’ll update the table.</p>

<h2 id="how-speclynx-cli-got-there">How SpecLynx CLI got there</h2>

<p>Until recently, SpecLynx CLI failed most of these checks too. Here is the style test applied to the sample document with <a href="https://github.com/speclynx/speclynx-cli/releases/tag/v1.5.1">SpecLynx CLI 1.5.1</a>:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>npx @speclynx/cli overlay apply overlay.yaml openapi.yaml <span class="nt">-o</span> result.yaml
</code></pre></div></div>

<p>The input is on the left and the result on the right, with changed lines shaded.</p>

<div class="code-compare">
<span class="cmp-head cmp-head-l">openapi.yaml</span><span class="cmp-head cmp-head-g" aria-hidden="true"></span><span class="cmp-head cmp-head-r">result.yaml with 1.5.1</span>
<span class="cmp-l">openapi: 3.1.0</span><span class="cmp-g"></span><span class="cmp-r">openapi: 3.1.0</span>
<span class="cmp-l">info:</span><span class="cmp-g"></span><span class="cmp-r">info:</span>
<span class="cmp-l" style="--i:2">title: &#x27;The user&#x27;&#x27;s accounts API&#x27;</span><span class="cmp-g"></span><span class="cmp-r" style="--i:2">title: &#x27;The user&#x27;&#x27;s accounts API&#x27;</span>
<span class="cmp-l" style="--i:2">version: 1.0.0</span><span class="cmp-g"></span><span class="cmp-r" style="--i:2">version: 1.0.0</span>
<span class="cmp-l">paths:</span><span class="cmp-g"></span><span class="cmp-r">paths:</span>
<span class="cmp-l" style="--i:2"># Read-only for now; writes land in v2.</span><span class="cmp-g"></span><span class="cmp-r" style="--i:2"># Read-only for now; writes land in v2.</span>
<span class="cmp-l" style="--i:2">/accounts/{id}:</span><span class="cmp-g"></span><span class="cmp-r" style="--i:2">/accounts/{id}:</span>
<span class="cmp-l" style="--i:4">get:</span><span class="cmp-g"></span><span class="cmp-r" style="--i:4">get:</span>
<span class="cmp-l" style="--i:6">operationId: getAccount</span><span class="cmp-g"></span><span class="cmp-r" style="--i:6">operationId: getAccount</span>
<span class="cmp-l" style="--i:6">tags: [accounts, read]</span><span class="cmp-g"></span><span class="cmp-r" style="--i:6">tags: [accounts, read]</span>
<span class="cmp-l" style="--i:6">responses:</span><span class="cmp-g"></span><span class="cmp-r" style="--i:6">responses:</span>
<span class="cmp-l" style="--i:8">&#x27;200&#x27;:</span><span class="cmp-g"></span><span class="cmp-r" style="--i:8">&#x27;200&#x27;:</span>
<span class="cmp-l" style="--i:10">description: &gt;-</span><span class="cmp-g"></span><span class="cmp-r" style="--i:10">description: &gt;-</span>
<span class="cmp-l" style="--i:12">Account details. If the response contains unmasked identifiers</span><span class="cmp-g"></span><span class="cmp-r" style="--i:12">Account details. If the response contains unmasked identifiers</span>
<span class="cmp-l" style="--i:12">this is sensitive data which should only be exchanged if encrypted.</span><span class="cmp-g"></span><span class="cmp-r" style="--i:12">this is sensitive data which should only be exchanged if encrypted.</span>
<span class="cmp-l" style="--i:14">* Routing number: identifies the institution;</span><span class="cmp-g"></span><span class="cmp-r" style="--i:14">* Routing number: identifies the institution;</span>
<span class="cmp-l" style="--i:14">* Account number: identifies the customer account.</span><span class="cmp-g"></span><span class="cmp-r" style="--i:14">* Account number: identifies the customer account.</span>
<span class="cmp-l" style="--i:10">content:</span><span class="cmp-g"></span><span class="cmp-r" style="--i:10">content:</span>
<span class="cmp-l" style="--i:12">application/json:</span><span class="cmp-g"></span><span class="cmp-r" style="--i:12">application/json:</span>
<span class="cmp-l" style="--i:14">schema: &amp;account</span><span class="cmp-g"></span><span class="cmp-r" style="--i:14">schema: &amp;account</span>
<span class="cmp-l" style="--i:16">type: object</span><span class="cmp-g"></span><span class="cmp-r" style="--i:16">type: object</span>
<span class="cmp-l" style="--i:8">&#x27;404&#x27;:</span><span class="cmp-g"></span><span class="cmp-r" style="--i:8">&#x27;404&#x27;:</span>
<span class="cmp-l" style="--i:10">description: |2-</span><span class="cmp-g"></span><span class="cmp-r" style="--i:10">description: |2-</span>
<span class="cmp-l"> </span><span class="cmp-g"></span><span class="cmp-r"> </span>
<span class="cmp-l" style="--i:12">No account with this id.</span><span class="cmp-g"></span><span class="cmp-r" style="--i:12">No account with this id.</span>
<span class="cmp-l cmp-gap" aria-hidden="true"></span><span class="cmp-g"></span><span class="cmp-r cmp-add" style="--i:6">summary: &quot;Fetch one account&quot;</span>
<span class="cmp-l cmp-gap" aria-hidden="true"></span><span class="cmp-g"></span><span class="cmp-r cmp-add" style="--i:6">description: |</span>
<span class="cmp-l cmp-gap" aria-hidden="true"></span><span class="cmp-g"></span><span class="cmp-r cmp-add" style="--i:8">Returns a single account.</span>
<span class="cmp-l cmp-gap" aria-hidden="true"></span><span class="cmp-g"></span><span class="cmp-r cmp-add"> </span>
<span class="cmp-l cmp-gap" aria-hidden="true"></span><span class="cmp-g"></span><span class="cmp-r cmp-add" style="--i:8">Requires the `accounts:read` scope.</span>
<span class="cmp-l cmp-gap" aria-hidden="true"></span><span class="cmp-g"></span><span class="cmp-r cmp-add" style="--i:6">x-audience: [public]</span>
<span class="cmp-l" style="--i:2">/me:</span><span class="cmp-g"></span><span class="cmp-r" style="--i:2">/me:</span>
<span class="cmp-l" style="--i:4">get:</span><span class="cmp-g"></span><span class="cmp-r" style="--i:4">get:</span>
<span class="cmp-l" style="--i:6">operationId: getMe</span><span class="cmp-g"></span><span class="cmp-r" style="--i:6">operationId: getMe</span>
<span class="cmp-l" style="--i:6">responses:</span><span class="cmp-g"></span><span class="cmp-r" style="--i:6">responses:</span>
<span class="cmp-l" style="--i:8">&#x27;200&#x27;:</span><span class="cmp-g"></span><span class="cmp-r" style="--i:8">&#x27;200&#x27;:</span>
<span class="cmp-l" style="--i:10">description: The signed-in user&#x27;s account.</span><span class="cmp-g"></span><span class="cmp-r" style="--i:10">description: The signed-in user&#x27;s account.</span>
<span class="cmp-l" style="--i:10">content:</span><span class="cmp-g"></span><span class="cmp-r" style="--i:10">content:</span>
<span class="cmp-l" style="--i:12">application/json:</span><span class="cmp-g"></span><span class="cmp-r" style="--i:12">application/json:</span>
<span class="cmp-l" style="--i:14">schema: *account</span><span class="cmp-g"></span><span class="cmp-r" style="--i:14">schema: *account</span>
</div>

<p>The only shaded lines are the ones the overlay added. Everything the overlay didn’t target is byte for byte identical, and everything it added is written the way the overlay author wrote it.</p>

<h3 id="what-changed-under-the-hood">What changed under the hood</h3>

<p>SpecLynx CLI applies overlays with <a href="https://github.com/speclynx/apidom">SpecLynx ApiDOM</a>, an open source parser for API descriptions. The fixes landed in <a href="https://github.com/speclynx/apidom/releases/tag/v6.1.0">ApiDOM 6.1.0</a> and fall into three groups:</p>

<p><strong>The parser computes the right value.</strong> The three edge cases above are fixed, along with nearly twenty related ones.</p>

<p><strong>Style preservation writes back what was written.</strong> Block scalars, flow scalars, anchors, aliases, and CRLF line breaks come back exactly as they were in the source.</p>

<p><strong>Style survives the overlay itself.</strong> Values added by an overlay keep their YAML style, and objects and arrays merged by an <code class="language-plaintext highlighter-rouge">update</code> action keep theirs.</p>

<p>If you build tools on ApiDOM, all of this is opt-in (<code class="language-plaintext highlighter-rouge">style: true</code> when parsing, <code class="language-plaintext highlighter-rouge">preserveStyle: true</code> when writing YAML); see the <a href="/apidom/parsing/#style-preservation">Parsing guide</a>.</p>

<h3 id="what-isnt-perfect-yet">What isn’t perfect yet</h3>

<p>One edge I found while writing this: extra spaces before a comment get collapsed, so <code class="language-plaintext highlighter-rouge">title: 'API'   # note</code> comes back as <code class="language-plaintext highlighter-rouge">title: 'API' # note</code>. It doesn’t change a value, but it shows up in a diff, and it’s tracked in <a href="https://github.com/speclynx/apidom/issues/682">apidom#682</a>.</p>

<h3 id="thank-you-clyde-cutting">Thank you, Clyde Cutting</h3>

<p>None of this would exist without Clyde Cutting (<a href="https://github.com/cuttingclyde">@cuttingclyde</a>), who reported three issues against SpecLynx CLI between August and October:</p>

<ul>
  <li><a href="https://github.com/speclynx/speclynx-cli/issues/138">#138</a> showed Financial Data Exchange (FDX) descriptions with Markdown lists and tables being re-wrapped. It looked like a formatting preference and turned out to be a parser bug.</li>
  <li><a href="https://github.com/speclynx/speclynx-cli/issues/174">#174</a> pointed at <code class="language-plaintext highlighter-rouge">|2-</code> blocks in the 75,000-line OpenAPI description of Plaid, a financial data API, with a “bonus” overlay proving that values coming <em>from</em> the overlay broke too. Its CRLF attachments became a test case of their own.</li>
  <li><a href="https://github.com/speclynx/speclynx-cli/issues/177">#177</a> covered almost every quoting variant in one small file, which narrowed the bug down immediately.</li>
</ul>

<p>Each report came with real files and a clear expected result. While fixing them, I kept finding related problems and fixed those in ApiDOM too. Thank you, Clyde. Issue reports like these are the most valuable contribution an open source project can get.</p>

<h2 id="closing-words">Closing words</h2>

<p>An overlay is a promise: change these nodes and leave everything else alone. A tool that doesn’t leave the rest alone makes you choose between overlays and readable diffs. Whatever tool you use, run the one-line test on your own documents. It takes a minute and tells you whether your overlay diffs can be trusted.</p>

<p>If you want to try SpecLynx CLI, run the latest version:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">$ </span>npx @speclynx/cli@latest overlay apply overlay.yaml openapi.yaml
</code></pre></div></div>

<p>If anything outside your overlay’s targets changes, please <a href="https://github.com/speclynx/speclynx-cli/issues">open an issue</a> with an example. That’s exactly how this release happened.</p>

<p><em>Further reading:</em> <a href="https://spec.openapis.org/overlay/v1.2.0.html">Overlay Specification 1.2.0</a> · <a href="https://yaml.org/spec/1.2.2/">YAML 1.2.2 specification</a> · <a href="https://github.com/speclynx/speclynx-cli/releases/tag/v1.5.1">SpecLynx CLI 1.5.1 release notes</a> · <a href="https://github.com/speclynx/apidom/releases/tag/v6.1.0">ApiDOM 6.1.0 release notes</a> · <a href="/apidom/parsing/">ApiDOM parsing guide</a></p>]]></content><author><name>Vladimír Gorej</name></author><summary type="html"><![CDATA[Applying an OpenAPI Overlay to a hand-written YAML document often rewrites far more than the overlay asked for. Why YAML makes this hard, a one-minute test you can run on your own document, and how four overlay CLIs did on it.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://speclynx.com/assets/images/blog/overlay-yaml-round-tripping.webp" /><media:content medium="image" url="https://speclynx.com/assets/images/blog/overlay-yaml-round-tripping.webp" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">One Overlay, three JSONPaths: should a compliant implementation accept dialects?</title><link href="https://speclynx.com/blog/overlay-jsonpath-dialects/" rel="alternate" type="text/html" title="One Overlay, three JSONPaths: should a compliant implementation accept dialects?" /><published>2026-08-19T08:00:00+00:00</published><updated>2026-08-19T08:00:00+00:00</updated><id>https://speclynx.com/blog/overlay-jsonpath-dialects</id><content type="html" xml:base="https://speclynx.com/blog/overlay-jsonpath-dialects/"><![CDATA[<p>A standards body for the banking industry writes five <a href="https://spec.openapis.org/overlay/v1.1.0.html">OpenAPI Overlays</a>, one per API, to stamp regulatory annotations onto schemas that don’t already carry them. The overlays are tested against a popular CLI and produce exactly the expected output. Then someone runs the same five files through a different Overlay tool, and every one of them fails on the first action:</p>

<div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>Error: Invalid JSONPath expression:
  "$.components.schemas.*[?(!@.x-fdx-csdf-account-categories &amp;&amp; !@.x-fdx-csdf-technical)]".
  Syntax error at position 29, expected "[", "*", "_", "0", "-", ":", "!", "(", "@", "$", "-0", ".", "..", "&amp;&amp;", "||"
</code></pre></div></div>

<p>Nothing about the overlays changed. Nothing about the API descriptions changed. What changed is <em>which JSONPath</em> the tool on the other end speaks. That is the subject of this post: when an Overlay implementation is handed an expression written for a different JSONPath dialect, should it try to understand it anyway, or refuse?</p>

<p>Our answer is <strong>refuse, and be helpful about it</strong>. A canonical implementation should be strict about what it consumes. The rest of this post is the reasoning: what the dialects are, what the spec says, what other tools do, the honest case for leniency, and why strictness still wins.</p>

<h2 id="there-is-no-single-jsonpath-until-recently">There is no single JSONPath (until recently)</h2>

<p>JSONPath started as a <a href="https://goessner.net/articles/JsonPath/">2007 blog post by Stefan Gössner</a>. It described a syntax, shipped a JavaScript implementation with <code class="language-plaintext highlighter-rouge">eval()</code> inside, and left plenty unspecified. Every language ecosystem then grew its own interpretation:</p>

<ul>
  <li>
    <p><strong>Goessner-style</strong> libraries (<code class="language-plaintext highlighter-rouge">jsonpath</code> on npm, most Python and Java ports) are the classic. Filter expressions are small scripts handed to the host language, so anything the host can evaluate, including its regex syntax and its comparison operators, “works”.</p>
  </li>
  <li>
    <p><strong>JSONPath Plus</strong> is a popular fork that added selectors the original never had: a parent selector, a property-name selector, and a handful of <code class="language-plaintext highlighter-rouge">@</code>-prefixed helpers and type selectors. Stoplight’s Spectral runs on this dialect: its own Nimma engine supports most JSONPath Plus additions, it falls back to <code class="language-plaintext highlighter-rouge">jsonpath-plus</code> for compatibility, and its docs say it is <a href="https://github.com/stoplightio/spectral/blob/develop/docs/guides/4a-rules.md#given">not yet aligned with RFC 9535</a>. A lot of developers have muscle memory from writing rulesets against exactly this syntax.</p>
  </li>
  <li>
    <p><strong>RFC 9535</strong> is the <a href="https://www.rfc-editor.org/rfc/rfc9535.html">IETF standard</a>, published February 2024. Names in brackets must be quoted, names after a dot are limited to letters, digits and underscores, filters are a real expression grammar rather than host-language code, and regexes and lengths come from a fixed set of functions. For a compliant implementation in JavaScript, see <a href="https://github.com/swaggerexpert/jsonpath"><code class="language-plaintext highlighter-rouge">@swaggerexpert/jsonpath</code></a>.</p>
  </li>
</ul>

<p>For everyday paths (a named operation, all schemas under <code class="language-plaintext highlighter-rouge">components</code>, parameters filtered by <code class="language-plaintext highlighter-rouge">in</code>) all three agree. Then the edges bite:</p>

<div class="table-scroll">

  <table>
    <thead>
      <tr>
        <th>You wrote</th>
        <th>Goessner</th>
        <th>Plus</th>
        <th>RFC 9535</th>
      </tr>
    </thead>
    <tbody>
      <tr>
        <td><code class="language-plaintext highlighter-rouge">$..[?(!@.x-fdx-csdf-technical)]</code></td>
        <td>Silently matches nothing (read as <code class="language-plaintext highlighter-rouge">@.x - fdx - …</code>); the legacy Go engine in the story accepts it</td>
        <td>Error: <code class="language-plaintext highlighter-rouge">fdx is not defined</code></td>
        <td>Parse error at the <code class="language-plaintext highlighter-rouge">-</code>; hyphenated names need brackets: <code class="language-plaintext highlighter-rouge">!@['x-fdx-csdf-technical']</code></td>
      </tr>
      <tr>
        <td><code class="language-plaintext highlighter-rouge">$..[?(@.deprecated === true)]</code></td>
        <td>OK (JS <code class="language-plaintext highlighter-rouge">===</code>)</td>
        <td>OK</td>
        <td>Parse error; only <code class="language-plaintext highlighter-rouge">==</code></td>
      </tr>
      <tr>
        <td><code class="language-plaintext highlighter-rouge">$..[?(@.name.match(/^x-/))]</code></td>
        <td>OK (host regex)</td>
        <td>OK, if guarded against nodes without <code class="language-plaintext highlighter-rouge">name</code>; throws otherwise</td>
        <td>Parse error; use <code class="language-plaintext highlighter-rouge">match(@.name, "x-.*")</code></td>
      </tr>
      <tr>
        <td><code class="language-plaintext highlighter-rouge">$..parameters^</code></td>
        <td>Parse error</td>
        <td>OK (parent selector)</td>
        <td>Parse error; no parent selector</td>
      </tr>
      <tr>
        <td><code class="language-plaintext highlighter-rouge">$.paths[*]~</code></td>
        <td>Parse error</td>
        <td>OK (property-name selector)</td>
        <td>Parse error</td>
      </tr>
      <tr>
        <td><code class="language-plaintext highlighter-rouge">$.info[?length(@.title) &gt; 40]</code></td>
        <td>Parse error</td>
        <td>Silently matches nothing</td>
        <td>OK; standard function</td>
      </tr>
      <tr>
        <td><code class="language-plaintext highlighter-rouge">$['paths']['/pets']</code></td>
        <td>OK</td>
        <td>OK</td>
        <td>OK; this is RFC’s <em>normalized path</em> form</td>
      </tr>
    </tbody>
  </table>

</div>

<p><small>Goessner and Plus columns checked against <code class="language-plaintext highlighter-rouge">jsonpath</code> 1.1.1 and <code class="language-plaintext highlighter-rouge">jsonpath-plus</code> 10.x; the RFC 9535 column against <code class="language-plaintext highlighter-rouge">@swaggerexpert/jsonpath</code>.</small></p>

<p>The first row is the shape of the banking overlays from the opening. Position 29 of the original expression is the hyphen in <code class="language-plaintext highlighter-rouge">x-fdx-csdf-account-categories</code>: a member name that RFC 9535’s dot-notation grammar doesn’t allow, and that a lenient parser happily reads through. Same file, two meanings of “valid”.</p>

<h2 id="what-the-overlay-spec-actually-says">What the Overlay spec actually says</h2>

<p>Overlay 1.0.0 (October 2024) defined <code class="language-plaintext highlighter-rouge">target</code> as “a JSONPath expression selecting nodes in the target document” and cited RFC 9535 as a normative reference. The wording was soft, but the intent was clear, and implementers generally read it the same way: RFC 9535 was the JSONPath the spec meant.</p>

<p>Overlay 1.1.0 (January 2026) closed the gap with a dedicated section, <a href="https://spec.openapis.org/overlay/v1.1.0.html#rfc9535-compliance"><em>RFC9535 Compliance</em></a>:</p>

<blockquote>
  <p>A tool or library MUST fully implement RFC9535 when parsing and expanding JSONPath query expressions to be compliant with the Overlay specification.</p>

  <p>Interoperable Overlay Documents MUST use RFC9535 JSONPath query expressions and MUST NOT use tool-specific JSONPath extensions.</p>
</blockquote>

<p>So the spec answers half the question: a compliant implementation <strong>must</strong> speak RFC 9535, and a portable overlay <strong>must not</strong> rely on anything else. What it doesn’t say is whether an implementation <em>may</em> also accept other dialects as a courtesy.</p>

<h2 id="what-the-ecosystem-actually-does">What the ecosystem actually does</h2>

<p>A quick survey of open-source Overlay tooling, as of this writing:</p>

<ul>
  <li><a href="https://github.com/lornajane/openapi-overlays-js"><code class="language-plaintext highlighter-rouge">openapi-overlays-js</code></a> (Lorna Mitchell’s reference-style implementation) depends on the <code class="language-plaintext highlighter-rouge">jsonpath</code> package: the Goessner dialect.</li>
  <li><a href="https://github.com/bump-sh/cli">Bump.sh CLI</a> runs its overlay support on <code class="language-plaintext highlighter-rouge">jsonpathly</code>, which since version 3 is an RFC 9535 implementation.</li>
  <li><a href="https://github.com/speakeasy-api/openapi/tree/main/overlay">Speakeasy</a> picks the dialect by Overlay version: a 1.0.0 document is evaluated with its legacy, Goessner-inspired <code class="language-plaintext highlighter-rouge">yaml-jsonpath</code> engine by default (with a warning on expressions that aren’t valid RFC 9535, and an opt-in <code class="language-plaintext highlighter-rouge">x-speakeasy-jsonpath: rfc9535</code> extension), while a 1.1.0 document gets RFC 9535 by default with an opt-out back to legacy. It also ships an upgrade helper that moves an overlay from 1.0.0 to 1.1.0.</li>
  <li><a href="/cli/">SpecLynx CLI</a> and <a href="https://github.com/speclynx/apidom/tree/main/packages/apidom-overlay"><code class="language-plaintext highlighter-rouge">@speclynx/apidom-overlay</code></a> evaluate targets with <code class="language-plaintext highlighter-rouge">@speclynx/apidom-json-path</code>, which is RFC 9535-only for every Overlay version.</li>
</ul>

<p>So the picture isn’t simply “Goessner tools vs. RFC tools”. The same <code class="language-plaintext highlighter-rouge">target</code> string can be evaluated by a Goessner engine, an RFC engine, or either one depending on the <code class="language-plaintext highlighter-rouge">overlay:</code> version field at the top of the file, and the spec says only one of those behaviours is compliant. The banking overlays from the opening were Overlay 1.0.0 documents, so the first CLI read them with its legacy engine, where hyphenated dot-names are fine. The second CLI read them as RFC 9535, where they aren’t.</p>

<h2 id="the-case-for-being-lenient-a-fair-hearing">The case for being lenient: a fair hearing</h2>

<p>It’s a real case, so let’s make it honestly.</p>

<p><strong>Existing overlays.</strong> People wrote overlays before 1.1.0 made the rule explicit, against whatever their tool accepted. Rejecting those files is friction on day one: five failing overlays and a bug report, in the story above.</p>

<p><strong>Postel’s law.</strong> “Be conservative in what you send, liberal in what you accept” is the oldest instinct in protocol design. Accepting Goessner and Plus syntax alongside RFC 9535 would mean more overlays Just Work.</p>

<p><strong>Adoption.</strong> Overlays are still early. A tool that says “no” to a working file is a tool people route around.</p>

<p>The strongest form of this argument is the one Speakeasy actually built: don’t guess, <em>version-gate</em>. Treat the <code class="language-plaintext highlighter-rouge">overlay: 1.0.0</code> header as permission to use the old engine, warn loudly when an expression wouldn’t survive RFC 9535, make the standard the default the moment the author writes <code class="language-plaintext highlighter-rouge">1.1.0</code>, and give them a one-command upgrade. That is leniency with a migration path designed in, and it’s a reasonable way to carry an existing user base across the line.</p>

<h2 id="why-strict-wins">Why strict wins</h2>

<p>And here’s why, for all that, the lenient case loses for a <em>canonical</em> implementation, the one meant to define what “correct” means.</p>

<p><strong>An overlay is a portable artifact, not a script.</strong> Its whole value is that the same file can be applied by your docs tool, your SDK generator, and your CI gate and produce the same result. Every dialect an implementation accepts, even deliberately and with warnings, is a file that will break the moment it meets an implementation that doesn’t. Leniency doesn’t remove the interoperability problem; it moves it downstream, to someone who didn’t write the overlay and can’t see why it fails. That’s exactly how five overlays that “worked” arrived at a tool that couldn’t read them: the version gate kept them working in one place and tool-specific everywhere else.</p>

<p><strong>Postel’s law has a known failure mode.</strong> The IETF itself revisited the robustness principle in <a href="https://www.rfc-editor.org/rfc/rfc9413.html">RFC 9413, <em>Maintaining Robust Protocols</em></a> (2023): tolerating deviations lets them harden into de-facto requirements, and the ecosystem ossifies around the most lenient implementation instead of the standard. JSONPath’s two decades of dialect drift <em>are</em> that failure mode. RFC 9535 exists to end it, and an Overlay implementation that re-admits the dialects undoes the work.</p>

<p><strong>Ambiguity is worse than an error.</strong> Some expressions parse in more than one dialect with different meanings. A lenient implementation has to guess which one the author meant. Guessing wrong doesn’t error; it edits the wrong node. For a tool whose job is to modify API contracts, a loud parse error is the kinder outcome.</p>

<p><strong>“Strict input” still leaves room to help.</strong> Strictness is about what the implementation <em>applies</em>, not about how it treats the person. An error that names the offending expression and the exact position, plus a hint toward the RFC form, turns a rejection into a minutes-long fix. A linter or converter for legacy overlays can be a separate, honest tool; it doesn’t have to live inside <code class="language-plaintext highlighter-rouge">apply</code>.</p>

<h2 id="where-speclynx-landed">Where SpecLynx landed</h2>

<p>SpecLynx is RFC 9535 only, because the Overlay specification requires it and because we think that’s the right answer for a canonical implementation: <code class="language-plaintext highlighter-rouge">@speclynx/apidom-json-path</code> is fully RFC 9535 compliant, and <a href="/cli/">SpecLynx CLI</a> evaluates every <code class="language-plaintext highlighter-rouge">target</code> with it, whatever the overlay’s version field says:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npx @speclynx/cli overlay apply overlay.yaml openapi.yaml
</code></pre></div></div>

<p>An expression that isn’t valid RFC 9535 is rejected with a parse error that points at the offending position and lists what the grammar would have accepted there (the message at the top of this post is SpecLynx’s) rather than guessed at. The other common way an overlay silently does nothing is a target that matches no nodes at all; <code class="language-plaintext highlighter-rouge">--strict</code> turns that into a failure too:</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npx @speclynx/cli overlay apply overlay.yaml openapi.yaml <span class="nt">--strict</span>
</code></pre></div></div>

<p>The opening story is real: <a href="https://github.com/speclynx/speclynx-cli/issues/127">speclynx-cli#127</a>. The fix on the overlay side was mechanical (bracket the hyphenated names) and the author had all five working the same day.</p>

<h3 id="migrating-an-existing-overlay">Migrating an existing overlay</h3>

<p>If you maintain overlays written against a Goessner or JSONPath Plus tool, the migration usually is that mechanical: quote bracketed names and anything with a hyphen, replace host-language regexes with <code class="language-plaintext highlighter-rouge">match()</code>/<code class="language-plaintext highlighter-rouge">search()</code>, and rewrite parent/property selectors as explicit paths. The table above covers most of what we’ve seen. The <a href="https://github.com/swaggerexpert/jsonpath#validation"><code class="language-plaintext highlighter-rouge">@swaggerexpert/jsonpath</code> validator</a> and <a href="https://jsonpath.com/">jsonpath.com</a> (in its RFC 9535 mode; it also offers a JSONPath Plus mode, which is the whole point of this post) will tell you whether an expression is valid RFC 9535 before you run anything.</p>

<p>For a larger batch, the prompt below does the conversion with any AI assistant, and is the one we handed over in that issue:</p>

<details>
  <summary>Migration prompt for an AI assistant</summary>

  <div class="language-text highlighter-rouge"><div class="highlight"><pre class="highlight"><code>You are a JSONPath migration assistant. Convert the JSONPath expression below so that it is valid under RFC 9535 (the standard implemented by @swaggerexpert/jsonpath), while preserving its exact meaning.

Apply these rules:
1. Never write a dot directly before a bracket. `a.[...]` must become `a[...]`.
2. Dot-notation member names may only contain letters, digits, `_` and non-ASCII characters. Any name containing a hyphen, space, or other punctuation must be written in bracket notation with a quoted string, e.g. `@.x-foo-bar` -&gt; `@['x-foo-bar']`, `$.some key` -&gt; `$['some key']`.
3. Filters use `?` inside brackets: `[?&lt;expr&gt;]`. Parentheses around the whole expression are optional (`[?(expr)]` and `[?expr]` are both fine); keep them if present.
4. `!@...` / `@...` are existence tests (key present or not), not truthiness tests. Keep them as existence tests unless the original clearly compares values.
5. Comparisons must be `==`, `!=`, `&lt;`, `&lt;=`, `&gt;`, `&gt;=` between singular queries or literals; use `&amp;&amp;`, `||`, `!` for logic. Replace non-standard operators (`=~`, `in`, `nin`, `size`, `empty`, `contains`) with `match()`, `search()`, `length()`, `value()`, `count()` or equivalent comparisons, and say so explicitly.
6. Replace non-standard shorthands: `$..*` is fine, but script filters `[(@.length-1)]` become `[-1]`, `@.length` becomes `length(@)`, and `$.a.b.[*]` becomes `$.a.b[*]`.
7. Do not change what the expression selects. If a fully equivalent RFC 9535 form does not exist, say so and give the closest alternative with the difference explained.

Output:
- The migrated expression on its own line in a code block.
- A short bullet list of each change and why.
- If possible, a second, shorter equivalent form.

Expression to migrate:
&lt;PASTE EXPRESSION HERE&gt;
</code></pre></div>  </div>

</details>

<h2 id="the-short-version">The short version</h2>

<ul>
  <li>Overlay <code class="language-plaintext highlighter-rouge">target</code> is JSONPath, and since Overlay 1.1.0 that means <strong>RFC 9535</strong>, for tools <em>and</em> for overlays that want to be portable.</li>
  <li>Dialects agree on the easy paths and diverge on filters, regexes, hyphenated names, and selector extensions, exactly where copy-pasted expressions live.</li>
  <li>A canonical implementation should be <strong>strict about what it consumes and generous about how it says no</strong>: clear errors and migration help, not silent dialect guessing.</li>
</ul>

<p><em>Further reading:</em> <a href="https://spec.openapis.org/overlay/v1.1.0.html">Overlay Specification 1.1.0</a> · <a href="https://www.rfc-editor.org/rfc/rfc9535.html">RFC 9535</a> · <a href="https://www.rfc-editor.org/rfc/rfc9413.html">RFC 9413</a> · <a href="/cli/">SpecLynx CLI</a> · <a href="https://github.com/speclynx/apidom/tree/main/packages/apidom-overlay"><code class="language-plaintext highlighter-rouge">@speclynx/apidom-overlay</code></a></p>]]></content><author><name>Vladimír Gorej</name></author><summary type="html"><![CDATA[OpenAPI Overlay targets are JSONPath expressions, but which JSONPath? The same overlay can apply cleanly in one tool and fail in the next. Here's why, how the dialects diverge in practice, and why a canonical implementation should be strict about what it consumes.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://speclynx.com/assets/images/blog/overlay-jsonpath-dialects.webp" /><media:content medium="image" url="https://speclynx.com/assets/images/blog/overlay-jsonpath-dialects.webp" xmlns:media="http://search.yahoo.com/mrss/" /></entry><entry><title type="html">We just open-sourced SpecLynx ApiDOM</title><link href="https://speclynx.com/blog/open-sourcing-apidom/" rel="alternate" type="text/html" title="We just open-sourced SpecLynx ApiDOM" /><published>2025-12-23T09:00:00+00:00</published><updated>2025-12-23T09:00:00+00:00</updated><id>https://speclynx.com/blog/open-sourcing-apidom</id><content type="html" xml:base="https://speclynx.com/blog/open-sourcing-apidom/"><![CDATA[<p>We just open-sourced SpecLynx <strong>ApiDOM</strong>.</p>

<p>Think of it as <em>Babel.js, but for API specifications</em>.</p>

<p>ApiDOM is the semantic core behind SpecLynx. It powers every layer of the stack: the <a href="/language-service/">Language Service</a>, <a href="/openapi-toolkit/">VS Code extension</a>, <a href="https://editor.speclynx.com">browser editor</a>, and <a href="/cli/">CLI</a>.</p>

<p>It parses OpenAPI, AsyncAPI, JSON Schema, Arazzo, and Overlay specs into a unified semantic tree you can traverse, transform, and serialize back out.</p>

<p>The goal is simple:</p>

<blockquote>
  <p>Let developers work with API specs programmatically without losing the structure, style, and intent of the original file.</p>
</blockquote>

<p>Because that part matters.</p>

<p>You should be able to modify a spec and get back something that still looks like <strong>your</strong> file, not a machine-generated rewrite.</p>

<h2 id="what-you-can-build-on-it">What you can build on it</h2>

<p>SpecLynx ApiDOM is Apache-2.0 licensed, and you can use it to build your own:</p>

<ul>
  <li>✅ Linters</li>
  <li>✅ Transforms</li>
  <li>✅ Code generators</li>
  <li>✅ Migration tools</li>
  <li>✅ Spec-aware automation</li>
</ul>

<h2 id="what-we-did-to-make-it-ready">What we did to make it ready</h2>

<ul>
  <li><strong>Tree-sitter based parsing</strong>: so tooling stays useful even while specs are incomplete</li>
  <li>A <strong>semantic data model</strong>: not generic objects, but one that understands OpenAPI Operations and Schema Objects</li>
  <li><strong>Lossless roundtrip</strong>: comments, indentation, and key ordering are preserved across transformations</li>
  <li>Performance and memory efficiency for <em>large specs</em></li>
</ul>

<h2 id="why-open-source-it">Why open source it</h2>

<p>We open-sourced ApiDOM to build trust. Every line of source code is now auditable and viewable, which also makes it usable in regulated environments where that’s not a nice-to-have but a requirement.</p>

<h2 id="try-it">Try it</h2>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>npm <span class="nb">install</span> @speclynx/apidom-reference
</code></pre></div></div>

<p>Parse an API specification and access its data model right away:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="p">{</span> <span class="nx">parse</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">@speclynx/apidom-reference</span><span class="dl">'</span><span class="p">;</span>
<span class="k">import</span> <span class="p">{</span> <span class="nx">toValue</span> <span class="p">}</span> <span class="k">from</span> <span class="dl">'</span><span class="s1">@speclynx/apidom-core</span><span class="dl">'</span><span class="p">;</span>

<span class="kd">const</span> <span class="nx">result</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">parse</span><span class="p">(</span><span class="dl">'</span><span class="s1">/path/to/openapi.json</span><span class="dl">'</span><span class="p">);</span>

<span class="nx">toValue</span><span class="p">(</span><span class="nx">result</span><span class="p">.</span><span class="nx">api</span><span class="p">.</span><span class="nx">info</span><span class="p">.</span><span class="nx">title</span><span class="p">);</span>   <span class="c1">// document title</span>
<span class="nx">toValue</span><span class="p">(</span><span class="nx">result</span><span class="p">.</span><span class="nx">api</span><span class="p">.</span><span class="nx">info</span><span class="p">.</span><span class="nx">version</span><span class="p">);</span> <span class="c1">// document version</span>
</code></pre></div></div>

<h2 id="get-started">Get started</h2>

<ul>
  <li>GitHub: <a href="https://github.com/speclynx/apidom">speclynx/apidom</a></li>
  <li>Documentation: <a href="/apidom/">speclynx.com/apidom</a></li>
</ul>

<p>If you’ve ever had to parse, rewrite, or generate API specs: <em>what broke first?</em> <a href="https://github.com/orgs/speclynx/discussions">Tell us in the discussions</a> 👇</p>]]></content><author><name>Vladimír Gorej</name></author><summary type="html"><![CDATA[Think of it as Babel.js, but for API specifications. ApiDOM is the semantic core behind SpecLynx, now Apache-2.0 licensed, fully auditable, and ready to power your own spec tooling.]]></summary><media:thumbnail xmlns:media="http://search.yahoo.com/mrss/" url="https://speclynx.com/assets/images/blog/open-sourcing-apidom.webp" /><media:content medium="image" url="https://speclynx.com/assets/images/blog/open-sourcing-apidom.webp" xmlns:media="http://search.yahoo.com/mrss/" /></entry></feed>