<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[Graphwake Blog]]></title><description><![CDATA[Engineering notes from Graphwake on API breaking changes between teams: OpenAPI diffs in CI, alerts to consumer teams, and who calls which endpoint.]]></description><link>https://graphwake.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6af80cab97fffc20d7ebbf36/0ad46df5-dd11-404c-82f3-033bf4341bbc.png</url><title>Graphwake Blog</title><link>https://graphwake.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sun, 15 Nov 2026 17:56:50 GMT</lastBuildDate><atom:link href="https://graphwake.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Breaking change alerts from Azure DevOps, Bitbucket Pipelines and Jenkins]]></title><description><![CDATA[Copy-paste setup for Azure DevOps, Bitbucket Pipelines and Jenkins: store the key, add one step, set the live branch, confirm it.]]></description><link>https://graphwake.hashnode.dev/breaking-change-alerts-from-azure-devops-bitbucket-pipelines-and-jenkins</link><guid isPermaLink="true">https://graphwake.hashnode.dev/breaking-change-alerts-from-azure-devops-bitbucket-pipelines-and-jenkins</guid><category><![CDATA[azure-devops]]></category><category><![CDATA[Jenkins]]></category><category><![CDATA[ci-cd]]></category><category><![CDATA[APIs]]></category><dc:creator><![CDATA[Graphwake]]></dc:creator><pubDate>Sat, 14 Nov 2026 19:05:08 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6af80cab97fffc20d7ebbf36/9a5786da-d27d-4475-999d-4874203c3cfc.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>GitHub Actions and GitLab CI get a ready-made action and template from us. Azure DevOps, Bitbucket Pipelines and Jenkins get a snippet you paste into the pipeline, so here are those three, copy-paste ready.</p>
<p>The idea: add one step to the pipeline that builds your API. It runs our open-source CI agent, which sends the service's OpenAPI spec to <a href="https://graphwake.com/?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=ci-alerts-azure-bitbucket-jenkins">Graphwake</a> with the build's context. On a pull request build the spec is stored as a draft, so the teams subscribed to a changed endpoint can hear about a breaking change before the merge. On a build of the live branch it becomes the live spec.</p>
<p>You need three things: the service's ingest key stored as a CI secret named <code>GRAPHWAKE_KEY</code>, the snippet for your platform, and the right live branch.</p>
<p><img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/ech2uqqrns07r7y3vbab.png" alt="Example Graphwake alert in Slack for pull request #1284 on orders-api: a breaking change to GET /orders/{id}, owned by payments-team, where total was removed and currency is now required. The alert reaches the three subscribed teams (checkout, mobile-app, reporting); search is not subscribed and stays quiet." /></p>
<p><em>Example: what a consumer team sees once the step is in place.</em></p>
<h2>Before you start</h2>
<p>You need a service in Graphwake that takes its spec from CI. In Graphwake, add a service and choose <strong>CI pipeline</strong> under "Where the spec comes from". Graphwake shows the service's ingest key (<code>dsk_...</code>) only once, so copy it straight away.</p>
<p>You also need a build step that writes the spec. The agent sends the spec as it is, YAML or JSON. It doesn't generate it. The snippets below expect <code>openapi.json</code> in the working directory. Recipes for ASP.NET Core, Spring Boot, FastAPI, NestJS and Go are in the <a href="https://github.com/graphwake/agent#producing-the-spec">agent README</a>.</p>
<p>Keys are per service. A repository with several services runs the step once per service, each with its own spec and its own key.</p>
<p>If you're on GitHub Actions or GitLab CI, the same agent has a ready-made action and template, described in <a href="https://graphwake.com/guides/graphwake-ci-agent">inside the Graphwake CI agent</a>.</p>
<h2>Step 1: store the ingest key as a secret</h2>
<p>Every snippet reads the key from the <code>GRAPHWAKE_KEY</code> environment variable. Keep it in your CI's secret store and pass it as that variable. The agent also accepts <code>--key</code>, but command-line arguments show up in process lists, so use the variable.</p>
<ul>
<li><strong>Azure DevOps:</strong> add a pipeline variable named <code>GRAPHWAKE_KEY</code> and mark it secret. Azure doesn't hand secret variables to scripts on its own, which is why the snippet maps it in <code>env</code>.</li>
<li><strong>Bitbucket Pipelines:</strong> add a secured repository variable named <code>GRAPHWAKE_KEY</code>.</li>
<li><strong>Jenkins:</strong> add a credential of kind Secret text with the ID <code>graphwake-key</code>. The snippet binds it to <code>GRAPHWAKE_KEY</code> with <code>withCredentials</code>.</li>
</ul>
<p>The agent hides the key in everything it prints. If the key leaks or is lost, rotate it from the service's <strong>Source</strong> tab in Graphwake and update the secret. The old key keeps working for 24 hours, which gives you time to update every pipeline.</p>
<h2>Step 2: add the step</h2>
<h3>Azure DevOps</h3>
<p>Add this script step to the pipeline's <code>steps</code>, after the step that writes <code>openapi.json</code>. It downloads the agent release, checks it against <code>checksums.txt</code> and runs it. This is <code>azure-pipelines.yml</code>, from the <a href="https://github.com/graphwake/agent#azure-devops">agent README</a>:</p>
<pre><code class="language-yaml">  - bash: |
      VERSION=0.1.0
      BASE=https://github.com/graphwake/agent/releases/download/v$VERSION
      ARCHIVE=graphwake_${VERSION}_linux_amd64.tar.gz
      curl -fsSLO "$BASE/$ARCHIVE" &amp;&amp; curl -fsSLO "$BASE/checksums.txt" &amp;&amp;
        sha256sum --check --ignore-missing checksums.txt &amp;&amp;
        tar -xzf "$ARCHIVE" graphwake &amp;&amp;
        ./graphwake upload --spec openapi.json || echo "graphwake: agent not run, spec not sent"
    displayName: Upload spec to Graphwake
    continueOnError: true
    env:
      GRAPHWAKE_KEY: $(GRAPHWAKE_KEY)
</code></pre>
<p>Run it on pull requests and on main. The agent treats a build with <code>BUILD_REASON=PullRequest</code> as a pull request build. For Azure Repos, pull request builds come from a build validation branch policy on the target branch; for a GitHub repository, from the pipeline's <code>pr</code> trigger.</p>
<p>If your organisation has the Graphwake extension installed, the <code>GraphwakeUpload@0</code> task does the same with two inputs, <code>spec: openapi.json</code> and <code>key: $(GRAPHWAKE_KEY)</code>.</p>
<p>One Azure quirk: it doesn't report a pull request closed without merging. Drafts of a pull request that never merges close on their own after 14 days.</p>
<h3>Bitbucket Pipelines</h3>
<p>Define the step once with an anchor and run it for every pull request and for builds of <code>main</code>. Replace the first script line with your own spec build, or put the step after the one that produces <code>openapi.json</code>. The step's image needs <code>curl</code>, <code>sha256sum</code> and <code>tar</code>. This is <code>bitbucket-pipelines.yml</code>, from the <a href="https://github.com/graphwake/agent#bitbucket-pipelines">agent README</a>:</p>
<pre><code class="language-yaml">definitions:
  steps:
    - step: &amp;graphwake
        name: API spec
        script:
          - ./build-openapi.sh   # produce openapi.json
          - &gt;-
            VERSION=0.1.0; BASE=https://github.com/graphwake/agent/releases/download/v$VERSION;
            ARCHIVE=graphwake_${VERSION}_linux_amd64.tar.gz;
            (curl -fsSLO "$BASE/$ARCHIVE" &amp;&amp; curl -fsSLO "$BASE/checksums.txt" &amp;&amp;
            sha256sum --check --ignore-missing checksums.txt &amp;&amp; tar -xzf "$ARCHIVE" graphwake &amp;&amp;
            ./graphwake upload --spec openapi.json) || echo "graphwake: agent not run, spec not sent"
pipelines:
  pull-requests:
    '**':
      - step: *graphwake
  branches:
    main:
      - step: *graphwake
</code></pre>
<p>The agent reads <code>BITBUCKET_PR_ID</code> on pull request builds and <code>BITBUCKET_BRANCH</code> on branch builds. If your live branch isn't <code>main</code> or <code>master</code>, rename the <code>branches</code> entry and set the live branch (step 3).</p>
<h3>Jenkins</h3>
<p>Use a multibranch pipeline, so Jenkins sets <code>CHANGE_ID</code>, <code>CHANGE_URL</code> and <code>CHANGE_AUTHOR</code> on pull request builds and <code>BRANCH_NAME</code> on branch builds. That's how the agent tells them apart, so a plain pipeline job won't do. Add this stage to the <code>Jenkinsfile</code> after the stage that writes <code>openapi.json</code>. The agent runs from its image, so the Jenkins agent needs Docker. The <code>-e</code> flags pass the build's variables into the container. From the <a href="https://github.com/graphwake/agent#jenkins">agent README</a>:</p>
<pre><code class="language-groovy">stage('API spec') {
  steps {
    withCredentials([string(credentialsId: 'graphwake-key', variable: 'GRAPHWAKE_KEY')]) {
      sh 'docker run --rm -e GRAPHWAKE_KEY -e BRANCH_NAME -e CHANGE_ID -e CHANGE_URL -e CHANGE_AUTHOR -e GIT_COMMIT -e JENKINS_URL -v "$PWD:/w" -w /w ghcr.io/graphwake/agent:0.1.0 upload --spec openapi.json || true'
    }
  }
}
</code></pre>
<p>The image has no git. On Jenkins that means the agent can't check that the build is still the branch tip, and it can't find the merged pull request, so a merge shows up in Graphwake as a separate live change. To get both, download the binary in an <code>sh</code> step instead, with the checksum-checked script from the README's Install section; it then uses the git in your workspace. The same script works if the Jenkins agent has no Docker. If you keep the image, pin it by digest (<code>ghcr.io/graphwake/agent@sha256:...</code>).</p>
<h2>Step 3: set the live branch</h2>
<p>A build of a live branch publishes the live spec, a pull request build stores a draft, and any other build sends nothing. None of these three platforms tells the agent its default branch, so the agent treats <code>main</code> and <code>master</code> as live unless you configure something else.</p>
<p><img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/ec5ugd0t9hg5wh6k3d57.png" alt="Flow diagram of what the step does with each build: a pull request build stores a draft spec and consumer teams hear before the merge; a live-branch build sends the live spec and notifications go out on merge; anything else sends nothing." /></p>
<p><em>Only pull request builds and live-branch builds send anything.</em></p>
<table>
<thead>
<tr>
<th>Platform</th>
<th>Pull request build</th>
<th>Live build</th>
<th>Set the live branch with</th>
</tr>
</thead>
<tbody><tr>
<td>Azure DevOps</td>
<td><code>BUILD_REASON=PullRequest</code></td>
<td>Other builds of a live branch</td>
<td>A pipeline variable <code>GRAPHWAKE_LIVE_BRANCH</code>, or the task's <code>liveBranch</code> input</td>
</tr>
<tr>
<td>Bitbucket Pipelines</td>
<td><code>BITBUCKET_PR_ID</code></td>
<td><code>BITBUCKET_BRANCH</code> is a live branch</td>
<td>A repository variable <code>GRAPHWAKE_LIVE_BRANCH</code></td>
</tr>
<tr>
<td>Jenkins (multibranch)</td>
<td><code>CHANGE_ID</code></td>
<td><code>BRANCH_NAME</code> (or <code>GIT_BRANCH</code>) is a live branch</td>
<td><code>GRAPHWAKE_LIVE_BRANCH</code> in the stage's environment, passed into the container with <code>-e GRAPHWAKE_LIVE_BRANCH</code></td>
</tr>
</tbody></table>
<p>The value is a comma-separated list and globs are allowed: <code>main,release/*</code> publishes from <code>main</code> and from every <code>release/</code> branch. Before a live send the agent checks that the build is still the tip of its branch, where it can, so an old re-run doesn't replace a newer live spec.</p>
<h2>Step 4: confirm it worked</h2>
<p>Run the pipeline on a pull request that touches the API, then on <code>main</code>. The step prints one line, which tells you what happened:</p>
<table>
<thead>
<tr>
<th>Line in the CI log</th>
<th>Meaning</th>
</tr>
</thead>
<tbody><tr>
<td><code>graphwake: spec sent (pull request #42)</code></td>
<td>Draft stored for the pull request.</td>
</tr>
<tr>
<td><code>graphwake: spec sent (pull request #42 merged, live)</code></td>
<td>The spec is live and the pull request's notifications are settled.</td>
</tr>
<tr>
<td><code>graphwake: spec sent (live)</code></td>
<td>The spec is live.</td>
</tr>
<tr>
<td><code>graphwake: not a pull request or live-branch build, nothing sent</code></td>
<td>A feature branch or a tag. On <code>main</code>, check the live branch setting.</td>
</tr>
<tr>
<td><code>invalid or missing ingest key</code></td>
<td>Check that <code>GRAPHWAKE_KEY</code> reaches the step. A rotated key stops working after 24 hours.</td>
</tr>
<tr>
<td><code>this service takes its spec from a URL, not from CI</code></td>
<td>The service is set up to poll a URL. Use a service whose source is CI pipeline.</td>
</tr>
<tr>
<td><code>spec rejected: ...</code></td>
<td>The file isn't valid OpenAPI, or the service no longer exists.</td>
</tr>
</tbody></table>
<p>Then open the service in Graphwake. Its <strong>Source</strong> tab shows the CI platform the agent detected and the time of the last upload.</p>
<p>To see an alert end to end, turn on <strong>Alerts on open pull requests</strong> under Settings → Alerts, subscribe a test team to an endpoint, and open a pull request that removes a response field from it. The team hears about it in its Slack, Microsoft Teams or email channel, and the impact score shows up on the change in Graphwake.</p>
<p>From there it works the same as on GitHub or GitLab: only the teams subscribed to a changed endpoint are told, nothing goes to a company-wide channel, and the step sends the OpenAPI file and the build's context. Your source code is not sent. Each change set gets an <a href="https://graphwake.com/impact-score">impact score with public rules</a>.</p>
<hr />
<p><em>Originally published on <a href="https://graphwake.com/guides/breaking-change-alerts-azure-devops-bitbucket-jenkins">graphwake.com</a>.</em></p>
<p>Which CI does your API build on? If it's not GitHub Actions, what's the integration you miss most?</p>
]]></content:encoded></item><item><title><![CDATA[Does your AI assistant know who uses your API?]]></title><description><![CDATA[Your assistant reads your code, not the other team's. Why the map of who calls an endpoint has to come from somewhere else.]]></description><link>https://graphwake.hashnode.dev/does-your-ai-assistant-know-who-uses-your-api</link><guid isPermaLink="true">https://graphwake.hashnode.dev/does-your-ai-assistant-know-who-uses-your-api</guid><category><![CDATA[AI]]></category><category><![CDATA[APIs]]></category><category><![CDATA[Microservices]]></category><category><![CDATA[Productivity]]></category><dc:creator><![CDATA[Graphwake]]></dc:creator><pubDate>Sat, 14 Nov 2026 19:04:56 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6af80cab97fffc20d7ebbf36/483bb074-dc70-4a41-8755-df47e0a6bdfa.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Usually not. Your AI assistant knows the code and files it can open, and the teams that call your endpoint keep their code somewhere else. So when you ask it whether a change is safe, it answers from your side of the contract only.</p>
<h2>It reads your code, not the other team's</h2>
<p>Ask your AI assistant to remove a field from a response and it will do a careful job. It searches the repository, finds no reader of that field, updates the spec, fixes the tests and writes a tidy commit message. From inside the repository, that's the right answer.</p>
<p>The mobile app that reads the field lives in another repository, owned by another team, and nobody told the assistant it exists. The change is correct for the code it could see and wrong for the code it couldn't.</p>
<h2>What the assistant sees, and what it doesn't</h2>
<table>
<thead>
<tr>
<th>It can see</th>
<th>It cannot see</th>
</tr>
</thead>
<tbody><tr>
<td>Your repository and the files you open</td>
<td>Repositories it has no access to</td>
</tr>
<tr>
<td>Your OpenAPI spec and your tests</td>
<td>Which teams call each endpoint, and how often</td>
</tr>
<tr>
<td>Docs and tickets you paste or connect</td>
<td>What another team agreed to in a thread last year</td>
</tr>
<tr>
<td>Tools you connect to it</td>
<td>Anything those tools don't know either</td>
</tr>
</tbody></table>
<p>A smarter model doesn't help here: it reasons better about the same files, but it still doesn't see more of them.</p>
<h2>Why it guesses instead of saying "I don't know"</h2>
<p>When the answer isn't in what it can read, an assistant fills the gap with what's likely. "No usages found" becomes "unused". "Looks internal" becomes "safe to change". That's fine for a private helper function. For an API it's exactly the assumption that breaks another team in production.</p>
<p>A better prompt won't fix that. The assistant needs a source that actually knows who the consumers are.</p>
<h2>Where the map can come from</h2>
<table>
<thead>
<tr>
<th>Source</th>
<th>What it knows</th>
<th>Where it falls short</th>
</tr>
</thead>
<tbody><tr>
<td>Code search</td>
<td>Callers in the code you can open</td>
<td>Misses every other repository</td>
</tr>
<tr>
<td>Static analysis across repositories</td>
<td>Call sites in every repository it scans</td>
<td>Needs read access to every consumer's code</td>
</tr>
<tr>
<td>Gateway or access logs</td>
<td>Which clients called recently</td>
<td>Client ids, not teams; misses rare callers</td>
</tr>
<tr>
<td>Contract tests (Pact)</td>
<td>Consumers that wrote a contract</td>
<td>Only those that wrote one</td>
</tr>
<tr>
<td>A service catalog</td>
<td>What someone wrote down</td>
<td>Goes stale when nobody updates it</td>
</tr>
<tr>
<td>Subscriptions</td>
<td>Teams that declared the endpoints they call</td>
<td>Needs each team to subscribe once; gaps show as blind spots</td>
</tr>
</tbody></table>
<p>To be fair, sometimes your assistant alone is enough. In a monorepo where every caller lives in the same repository, a code search finds them. If your consumers publish Pact contracts, the <a href="https://docs.pact.io/pact_broker">Pact Broker</a> shows who depends on you. And if every caller identifies itself at the gateway and you can tie each client to a team, recent traffic is a good list.</p>
<h2>Give the assistant the map, not more code</h2>
<p>That's what we built <a href="https://graphwake.com/?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=ai-assistant-api-consumers">Graphwake</a> to keep. The map comes from your OpenAPI specs and from subscriptions: each consumer team declares the endpoints it calls, and owners are named per service or endpoint. Graphwake never reads code. It needs only the spec.</p>
<p>With Graphwake MCP, your AI assistant asks that map directly. Graphwake has no language model of its own. The model is your assistant, and Graphwake answers its questions with data. It works with Claude Code, Cursor, GitHub Copilot and other clients that accept an API key.</p>
<p><img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/dq30ibj1my39o3wvmv84.png" alt="Example Graphwake MCP session. Asked " /></p>
<p><em>Example: two questions your assistant can now answer with data instead of a guess.</em></p>
<p>What it can ask:</p>
<ul>
<li>"Who consumes this?" returns the owner and the consumer teams of an endpoint, with the channel each one is alerted in.</li>
<li>"What would this break?" runs a dry run of your local spec against the live one. Nothing is stored and nobody is alerted.</li>
<li>"Who might we be missing?" brings in Discovery, which suggests teams that may use an endpoint, with the reason for each. Those suggestions come from fixed rules, not a model.</li>
</ul>
<p>A local check sends only the OpenAPI file, which is compared and then deleted. Graphwake never sees your code.</p>
<h2>Questions to ask before you change an endpoint</h2>
<ul>
<li>Who owns this endpoint, and who consumes it?</li>
<li>Which of those teams would this change break, and how badly?</li>
<li>Is there an endpoint here that nobody has claimed?</li>
<li>Is another open pull request already breaking the same endpoint?</li>
</ul>
<p>Your assistant can answer what changes from your repository. Who it reaches, and who owns it, needs the map. The <a href="https://graphwake.com/guides/ai-assistant-api-impact-mcp">setup guide</a> connects it in a few minutes.</p>
<hr />
<p><em>Originally published on <a href="https://graphwake.com/guides/does-your-ai-assistant-know-who-uses-your-api">graphwake.com</a>.</em></p>
<p>Has your assistant ever told you a field or endpoint was unused when another team was relying on it? How did you find out?</p>
]]></content:encoded></item><item><title><![CDATA[Contract testing without the tests]]></title><description><![CDATA[Most teams never get every consumer to write contract tests. A spec diff plus one subscription per team catches breaking changes.]]></description><link>https://graphwake.hashnode.dev/contract-testing-without-the-tests</link><guid isPermaLink="true">https://graphwake.hashnode.dev/contract-testing-without-the-tests</guid><category><![CDATA[APIs]]></category><category><![CDATA[Testing]]></category><category><![CDATA[Microservices]]></category><category><![CDATA[OpenApi]]></category><dc:creator><![CDATA[Graphwake]]></dc:creator><pubDate>Sat, 14 Nov 2026 19:04:44 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6af80cab97fffc20d7ebbf36/8e26bf8c-f635-4f71-9ee1-0134cb901d2d.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Cross-team integrations break at the seams. Each service passes its own tests, and then the two stop agreeing. Contract testing is the usual answer, and most rollouts stall before every consumer has written its tests.</p>
<p>We built <a href="https://graphwake.com/?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=contract-testing">Graphwake</a> around a smaller ask: the OpenAPI spec you already publish, plus one subscription from each team that calls an endpoint. That catches the common breaking changes on every endpoint. It doesn't replace tests where behaviour matters, and we'll come back to where those still pay off.</p>
<h2>Why adoption stalls</h2>
<p>Pact describes itself as "a code-first tool for testing HTTP and message integrations using contract tests", and its contract "is generated during the execution of the automated consumer tests" (<a href="https://docs.pact.io/">docs.pact.io</a>, as of 2026-11-14). That's where the cost comes from, and the people who pay it aren't the people who change the API.</p>
<p>The consumer pays. A team that calls ten internal APIs is asked to write and keep contract tests for ten services it doesn't own, and each test only pays off when some other team changes something. That's hard to get into a sprint.</p>
<p>Ownership is split. The consumer writes the contract, the provider verifies it, and someone runs the broker. When a verification fails at 6 pm, it isn't obvious whose job it is. Tests nobody owns rot, and a rotting contract test does real harm because it teaches people to ignore red.</p>
<p>Coordination costs the most. Every pair of teams has to agree on provider states, versions and the order of rollout. Pact's own page on <a href="https://docs.pact.io/getting_started/what_is_pact_good_for">what it is good for</a> lists "testing providers where the consumer and provider teams do not have good communication channels" as a poor fit. In a company with dozens of services, that can describe a fair share of the pairs.</p>
<p>And coverage stops at the first team that says no. The same page lists an API whose other side "will not also be using Pact" as a poor fit. If the mobile team or a contractor team has no tests, the provider is back to guessing about the consumers it knows least about. Pact's docs are candid about it: "in the wrong situation, it will probably be just as painful!" A company-wide rollout plan tends to assume every pair of teams is in the right situation.</p>
<p>Spec-driven tools such as <a href="https://specmatic.io/">Specmatic</a> generate the tests from the provider's spec instead (as of 2026-11-14). The contract is then the provider's description of the API, but the spec by itself still doesn't say which teams rely on which part of it.</p>
<h2>What the spec alone can tell you</h2>
<p>If you publish an OpenAPI spec and keep it accurate, much of the contract is already written down. Compare the spec before and after a change, and the changes that can make an existing client fail show up without anyone writing a test:</p>
<ul>
<li>an endpoint or a response field removed</li>
<li>a required parameter or a required request field added</li>
<li>a field's type changed</li>
<li>the accepted values narrowed, for example a shorter enum</li>
</ul>
<p><img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/nqoh2jd9prukutym5u0y.png" alt="Example OpenAPI diff of GET /orders/{id}: the currency query parameter changes from required false to required true, and the total response field is removed. Both are flagged as breaking, and the endpoint reaches the checkout and mobile-app teams." /></p>
<p><em>Example: two breaking changes found from the spec alone, with no test code.</em></p>
<p>That covers the shape of most breaking changes between internal teams. The check runs on every pull request that touches the spec, and the consuming teams don't have to do anything for it.</p>
<h2>What the spec can't tell you</h2>
<p>Behaviour, for one. A <code>total</code> that switches from dollars to cents is the same <code>number</code> in the spec. You only see that by running the service.</p>
<p>Drift. If the spec and the code disagree, a spec check is checking the wrong thing. Generating the spec from the code in the build helps, but it doesn't prove what the service returns.</p>
<p>What a consumer relies on beyond the spec: an undocumented field, the order of a list, an error message someone parses with a regular expression. None of that is in the spec.</p>
<p>And who calls the endpoint. A spec check can say "<code>total</code> was removed from <code>GET /orders/{id}</code>", but not which teams read <code>total</code>, or even which teams call <code>GET /orders/{id}</code>. So the finding goes to the pull request author, who usually doesn't know either.</p>
<p>The first three are reasons to keep contract tests where they earn their cost. The last one is why most breaking changes still surprise someone. The diff found the change, and the news went to the wrong person.</p>
<h2>The smallest contract</h2>
<p>A consumer-driven contract holds two kinds of information. Most of it is detail: the exact fields, values and states this consumer depends on. The rest is a single fact, that this team calls this endpoint. Only some integrations need the detail, which is expensive to write. Every integration needs the fact, and it costs almost nothing.</p>
<p>So in Graphwake we ask every consumer team for the fact and nothing more. A team that marks "we call <code>GET /orders/{id}</code>" has written the smallest contract there is: one subscription, with no test code and nothing to verify. Put that next to the spec diff and you get most of what teams wanted from contract testing. When the spec changes in a breaking way, the teams that call the endpoint hear about it, and with a CI step they hear while the change is still a pull request.</p>
<p><img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/cgl86ob1ii9twkni2l3o.png" alt="Example consumer map for GET /orders/{id} in orders-api: owned by payments-team, consumed by checkout and mobile-app, with storefront suggested by Discovery. GET /orders/{id}/invoice has no subscriber and is shown as a blind spot." /></p>
<p><em>Example: the subscriptions behind an endpoint, and an endpoint nobody has claimed yet.</em></p>
<p>How that works in practice:</p>
<ul>
<li>Consumer teams subscribe to the endpoints they call. That's all we ask of them. They write no test code and run no broker. Subscriptions are per endpoint, not per field.</li>
<li>Graphwake reads only your OpenAPI spec, from a URL it checks every 30 minutes or from one CI step on each pull request. It never asks for access to your code.</li>
<li>A removed field, a new required parameter, a changed type or narrowed values reaches only the teams subscribed to that endpoint, in Slack, Microsoft Teams or email. Additions stay quiet by default.</li>
<li>Each change set gets an <a href="https://graphwake.com/impact-score">impact score from 0 to 100 with public rules</a>. A breaking change on an endpoint nobody subscribes to counts as a blind spot, and rule-based Discovery suggests teams that may call it.</li>
</ul>
<p>The spec plus a list of who calls what also covers every endpoint from the first day, not only the ones whose consumers volunteered to write tests.</p>
<h2>Where contract tests still earn their cost</h2>
<p>Keep contract tests where both sides are teams that talk and will maintain them, where a runtime break would be costly (contract tests check the running service without deploying everything), and where consumers depend on behaviour or fields the spec doesn't describe.</p>
<p>The split we suggest: the spec and subscriptions on every endpoint, so every breaking change reaches the teams that call it, and contract tests on the integrations where a runtime break would be costly. If you already run Pact or Specmatic, keep them. Graphwake reads the spec and tells the teams subscribed to a changed endpoint; contract tests check a running service. They work side by side.</p>
<hr />
<p><em>Originally published on <a href="https://graphwake.com/guides/contract-testing-without-the-tests">graphwake.com</a>.</em></p>
<p>Did your team roll out contract testing across services? How far did it get before it stalled, or did it stick?</p>
]]></content:encoded></item><item><title><![CDATA[Modeling "who calls this endpoint" as a graph of teams]]></title><description><![CDATA[Teams, services and endpoints as a small graph that answers who a change affects, what a team depends on, and what nobody owns.]]></description><link>https://graphwake.hashnode.dev/modeling-who-calls-this-endpoint-as-a-graph-of-teams</link><guid isPermaLink="true">https://graphwake.hashnode.dev/modeling-who-calls-this-endpoint-as-a-graph-of-teams</guid><category><![CDATA[APIs]]></category><category><![CDATA[architecture]]></category><category><![CDATA[database]]></category><category><![CDATA[Microservices]]></category><dc:creator><![CDATA[Graphwake]]></dc:creator><pubDate>Sat, 14 Nov 2026 19:04:32 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6af80cab97fffc20d7ebbf36/fea81b92-00a2-4090-ab94-08a75d5bbeac.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Before a team changes an endpoint, someone has to answer a plain question: who calls this? In most companies the answer lives in people's heads, in an old wiki page, or in a Slack thread that starts after something broke.</p>
<p>What you're really asking about is a relationship between teams and endpoints. So when we built <a href="https://graphwake.com/?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=consumer-graph">Graphwake</a>, we stored it as one, in a graph. It turns out to need very little: three kinds of node, three kinds of edge, and queries that are one or two hops long.</p>
<h2>Three nodes, three edges</h2>
<table>
<thead>
<tr>
<th>Node</th>
<th>What it is</th>
<th>Where it comes from</th>
</tr>
</thead>
<tbody><tr>
<td>Service</td>
<td>One API with its own OpenAPI spec, such as <code>orders-api</code>.</td>
<td>Set up in Graphwake with its spec, from a published URL or a CI step.</td>
</tr>
<tr>
<td>Endpoint</td>
<td>One method and path of a service, such as <code>GET /orders/{id}</code>.</td>
<td>Read from the spec. A removed endpoint stays on record for its history.</td>
</tr>
<tr>
<td>Team</td>
<td>A group of people with one place for alerts: a Slack channel, a Microsoft Teams channel or an email address.</td>
<td>Created in Graphwake by the people who run it.</td>
</tr>
</tbody></table>
<table>
<thead>
<tr>
<th>Edge</th>
<th>From → to</th>
<th>Kept by</th>
<th>Rule</th>
</tr>
</thead>
<tbody><tr>
<td>exposes</td>
<td>Service → Endpoint</td>
<td>The spec</td>
<td>Every endpoint belongs to exactly one service.</td>
</tr>
<tr>
<td>owns</td>
<td>Team → Service, or Team → Endpoint</td>
<td>An admin</td>
<td>Optional, and a single team. An endpoint with its own owner overrides its service's owner; otherwise it inherits it.</td>
</tr>
<tr>
<td>subscribes to</td>
<td>Team → Endpoint</td>
<td>The consumer team</td>
<td>Per whole endpoint. A team subscribes to each endpoint it calls.</td>
</tr>
</tbody></table>
<p><img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/2czr6b7sv0kogq3wjmnr.png" alt="Diagram of the consumer graph: a Team owns a Service, the Service exposes two Endpoints, and the Team subscribes to one of them. The other Endpoint has no subscriber and is marked as an orphan." /></p>
<p><em>The consumer graph. An endpoint with no subscriber is an orphan.</em></p>
<p>Here's the same model drawn for one example service. <code>orders-api</code> is owned by payments-team. Its endpoint <code>GET /orders/{id}</code> has two subscribed consumer teams, checkout and mobile-app, and one team Discovery suggests, storefront. <code>GET /orders/{id}/invoice</code> has no subscriber at all: an orphan, shown as a blind spot. The rest of this post uses that example.</p>
<p><img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/dbfq38ngb4ch3g6rk5md.png" alt="Example service graph for orders-api in Graphwake: GET /orders/{id} is owned by payments-team, consumed by checkout (4 people) and mobile-app (6 people), with storefront suggested by Discovery at 72 because it uses a neighbouring endpoint. GET /orders/{id}/invoice has no subscriber and is flagged as a blind spot." /></p>
<h2>A sketch of the model</h2>
<p>The graph is small and shallow, so four relational tables hold it comfortably. This is a generic sketch of the shape, not Graphwake's schema:</p>
<pre><code class="language-sql">-- Example: a minimal relational sketch of the model. Not Graphwake's schema.
CREATE TABLE teams (
  id    uuid PRIMARY KEY,
  name  text NOT NULL UNIQUE
);

CREATE TABLE services (
  id             uuid PRIMARY KEY,
  name           text NOT NULL UNIQUE,
  owner_team_id  uuid REFERENCES teams (id)          -- optional: owns
);

CREATE TABLE endpoints (
  id             uuid PRIMARY KEY,
  service_id     uuid NOT NULL REFERENCES services (id),  -- exposes
  method         text NOT NULL,
  path           text NOT NULL,
  owner_team_id  uuid REFERENCES teams (id),         -- optional override
  removed_at     timestamptz,                        -- kept for history
  UNIQUE (service_id, method, path)
);

CREATE TABLE subscriptions (                         -- subscribes to
  team_id      uuid NOT NULL REFERENCES teams (id),
  endpoint_id  uuid NOT NULL REFERENCES endpoints (id),
  created_at   timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (team_id, endpoint_id)
);
</code></pre>
<p>A few choices in there are worth spelling out.</p>
<p>Ownership is a nullable column rather than a table. One team answers for a service, and an endpoint may name a different one. It may stay empty, because an unowned endpoint you can see is more useful than a wrong name.</p>
<p>A subscription is a row with a composite key, so a team subscribes to an endpoint once. There's no field column, because a subscription covers the whole endpoint.</p>
<p>Endpoints aren't deleted. When an endpoint disappears from the spec it's marked removed. The live graph hides it, and the change history still points at it.</p>
<p>Edges have history too. Graphwake records each subscribe and unsubscribe, each ownership change, and each published change with the teams it reached. So it can later show that checkout heard about a change because it subscribed to <code>GET /orders/{id}</code> three months earlier.</p>
<h2>Why the consumers keep the list</h2>
<p>The producer team looks like the natural owner of a consumer list, but it's in a poor position to keep one. The producer sees requests arrive, but not which team's code sent them or what that code relies on.</p>
<p>Each consumer team knows exactly which endpoints it calls, and it learns this at a precise moment: when it writes the integration. Recording the edge at that moment takes a minute. Reconstructing it later from the producer's side takes a search across every repository, and that search still misses some callers.</p>
<p>So a subscription is a consumer team saying "we call this". The side that has the knowledge declares the edge, and nobody has to infer it.</p>
<p>The list stays current because we route every breaking change along it. A team that missed an alert finds out why and subscribes. A team that keeps getting alerts it doesn't need removes the edge.</p>
<p>A team that isn't subscribed isn't told, and the producer can see that this was a decision rather than an oversight. In a broadcast channel there's no way to tell a team that isn't affected from one that missed the post. And an endpoint with no subscriber shows up as an orphan, with a count next to it, so the gap is on screen rather than in someone's head.</p>
<p>Traffic logs and code search still help you find callers you don't know about yet (<a href="https://graphwake.com/guides/api-consumer-tracking">API consumer tracking</a> compares those methods). They tell you which client called recently, or which repository contains a URL. A subscription tells you which team wants to hear when the contract changes, and that's what you need to know when a change breaks it.</p>
<h2>Three questions the graph answers</h2>
<h3>Who does this change affect?</h3>
<p>Start from the endpoints a change set touches and follow the subscription edges back to teams. One hop.</p>
<pre><code class="language-sql">-- Example: which teams does a change to these endpoints reach?
SELECT DISTINCT t.name
FROM subscriptions s
JOIN teams t ON t.id = s.team_id
WHERE s.endpoint_id = ANY ($1);   -- the endpoints with a breaking change
</code></pre>
<p>In the example, a breaking change to <code>GET /orders/{id}</code> reaches checkout and mobile-app, and no other team. A breaking change to <code>GET /orders/{id}/invoice</code> reaches nobody, and that's the result to worry about. Graphwake counts it as a blind spot in the change's <a href="https://graphwake.com/impact-score">impact score</a>, so the gap shows while the change is still a pull request.</p>
<h3>What does my team depend on?</h3>
<p>Start from a team, follow its subscription edges to endpoints, then each endpoint's owner edge (or its service's) to the team to ask. Two hops.</p>
<pre><code class="language-sql">-- Example: what does one team depend on, and whom does it ask about each endpoint?
SELECT sv.name AS service, e.method, e.path,
       ot.name AS owner            -- the endpoint's owner, else the service's
FROM subscriptions s
JOIN endpoints e   ON e.id = s.endpoint_id AND e.removed_at IS NULL
JOIN services sv   ON sv.id = e.service_id
LEFT JOIN teams ot ON ot.id = COALESCE(e.owner_team_id, sv.owner_team_id)
WHERE s.team_id = $1
ORDER BY sv.name, e.path, e.method;
</code></pre>
<p>For checkout, that's <code>GET /orders/{id}</code> in <code>orders-api</code>, owned by payments-team, plus whatever else checkout calls. A team wants this list in front of it when it plans an upgrade or runs an incident review. Your AI assistant can read the same list through <a href="https://graphwake.com/guides/ai-assistant-api-impact-mcp">Graphwake MCP</a>.</p>
<h3>Which endpoints have no owner or no consumer?</h3>
<p>Look for endpoints with a missing edge: no subscription, or no owner on either the endpoint or its service.</p>
<pre><code class="language-sql">-- Example: live endpoints with no consumer (orphans) or no owner
SELECT sv.name AS service, e.method, e.path,
       NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.endpoint_id = e.id) AS orphan,
       COALESCE(e.owner_team_id, sv.owner_team_id) IS NULL                  AS unowned
FROM endpoints e
JOIN services sv ON sv.id = e.service_id
WHERE e.removed_at IS NULL
  AND (NOT EXISTS (SELECT 1 FROM subscriptions s WHERE s.endpoint_id = e.id)
       OR COALESCE(e.owner_team_id, sv.owner_team_id) IS NULL);
</code></pre>
<p>An orphan is either unused or unmapped, and you can't tell which from the graph alone. An unowned endpoint has nobody to ask. Graphwake flags orphans on the service graph and, when the weekly report is turned on, emails admins a weekly list of orphan endpoints, endpoints without an owner and services without an owner.</p>
<p>Do you need a graph database for any of this? Usually not. The useful questions are one or two hops, and a few relational tables with the right keys answer them. A graph database starts to pay off when you need long, variable-length paths, which a team-to-endpoint map rarely does.</p>
<h2>Filling the gaps: Discovery</h2>
<p>A declared graph is only as complete as its declarations. New endpoints and forgotten subscriptions need a nudge.</p>
<p>Discovery is rule-based. After every spec arrives, it looks at each new endpoint and each endpoint with a breaking change, and asks which teams that aren't subscribed may be affected. It uses only data the graph already holds, and it shows every signal it found as the reason for the suggestion.</p>
<table>
<thead>
<tr>
<th>Signal</th>
<th>What it reads</th>
<th>In the example</th>
</tr>
</thead>
<tbody><tr>
<td>Subscriptions nearby</td>
<td>The team subscribes to another endpoint of the same service under the same path prefix.</td>
<td>storefront subscribes to <code>GET /orders</code>, next to <code>GET /orders/{id}</code>.</td>
</tr>
<tr>
<td>Shared schema</td>
<td>The team subscribes to an endpoint whose request body or success response uses the same named schema.</td>
<td>storefront also subscribes to <code>POST /orders</code>, which returns the same <code>Order</code> schema as <code>GET /orders/{id}</code>.</td>
</tr>
<tr>
<td>Ownership</td>
<td>The team owns the service or the endpoint.</td>
<td>payments-team, for a new endpoint in <code>orders-api</code> (on its own, too weak to be listed).</td>
</tr>
<tr>
<td>Maybe moved</td>
<td>The change set removed an endpoint and added another that may replace it; the old endpoint's subscribers are suggested for the new one.</td>
<td>If <code>GET /orders/{id}</code> were replaced by <code>GET /v2/orders/{id}</code>, checkout and mobile-app would be suggested for the new path.</td>
</tr>
<tr>
<td>Past decisions</td>
<td>The team said "Not us" to another endpoint with the same schema. This lowers the score.</td>
<td>A team that already declined an <code>Order</code> endpoint is less likely to be suggested for the next one.</td>
</tr>
</tbody></table>
<p>Each signal adds a fixed weight and a past "Not us" subtracts one, with the total kept between 0 and 100. There's no language model involved. Suggestions under 40 are counted but not listed, so weak guesses stay out of people's way.</p>
<p>A person always decides. The suggested team (or an admin) chooses Confirm, which subscribes the team, or Not us. A decision is final for that team and endpoint, and a subscribed team is never suggested. When a released change breaks an endpoint and a suggestion for it scores 60 or more, the suggested team gets one "This may affect you" note with Confirm and Not us links. A workspace can turn this off.</p>
<p>One weak signal isn't enough. storefront is listed because two signals agree: a nearby subscription and a shared schema. If the orphan <code>GET /orders/{id}/invoice</code> changed in a breaking way, the path rule would point at checkout and mobile-app, because both subscribe to <code>GET /orders/{id}</code> just above it. On its own that rule scores under 40, so those suggestions are counted but not shown, and the orphan stays a visible blind spot until a team subscribes.</p>
<h2>What the model leaves out</h2>
<p>Each of these omissions is deliberate, and each one keeps the graph small enough for people to maintain.</p>
<p>There are no field-level subscriptions. A team subscribes to a whole endpoint, so a removed field reaches every subscriber of that endpoint, including a team that never read it. The cost is an occasional alert a team marks as handled. In return, the list stays small enough for a person to keep accurate.</p>
<p>There's no traffic or code analysis. Graphwake reads only the OpenAPI spec. It doesn't read source code or request logs, so an edge exists because a team declared it or confirmed a suggestion.</p>
<p>Consumers are teams, not services. A breaking change is a conversation between people, so the graph records who to talk to, not which process opens the connection. A team has members and a channel; a service doesn't read Slack.</p>
<p>Each node has one owner. When two teams share ownership, questions tend to go unanswered. The model allows one team per service and one override per endpoint.</p>
<p>Follows aren't edges. Anyone can follow an endpoint and get its alerts by email, but a follow isn't a subscription: it never appears in the graph, the consumer list, the impact score or Discovery.</p>
<hr />
<p><em>Originally published on <a href="https://graphwake.com/guides/modeling-api-consumers-as-a-graph">graphwake.com</a>.</em></p>
<p>Where does "who calls this endpoint" live at your company today? A catalog, a spreadsheet, gateway logs, or someone's memory?</p>
]]></content:encoded></item><item><title><![CDATA[Multi-tenant SaaS on PostgreSQL row-level security]]></title><description><![CDATA[One PostgreSQL database, a policy on every tenant table: shaping RLS policies, pooled connections, migrations and isolation tests.]]></description><link>https://graphwake.hashnode.dev/multi-tenant-saas-on-postgresql-row-level-security</link><guid isPermaLink="true">https://graphwake.hashnode.dev/multi-tenant-saas-on-postgresql-row-level-security</guid><category><![CDATA[PostgreSQL]]></category><category><![CDATA[Security]]></category><category><![CDATA[SaaS]]></category><category><![CDATA[database]]></category><dc:creator><![CDATA[Graphwake]]></dc:creator><pubDate>Sat, 14 Nov 2026 19:04:05 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6af80cab97fffc20d7ebbf36/d5fd9f3c-9dee-4d81-8ba6-802ccc8b7c87.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Row-level security (RLS) is a PostgreSQL feature that filters the rows a query can see and write through a policy attached to the table. In a multi-tenant SaaS with one shared database, every tenant-owned table carries a <code>tenant_id</code> column and a policy that compares it with a tenant setting on the connection. A query that forgets its <code>WHERE tenant_id = …</code> then returns nothing instead of another customer's data.</p>
<p>Getting it right comes down to four habits. Force the policies and give each one both USING and WITH CHECK. Set the tenant every time a connection is handed out, before the first query. Add the policy in the same migration as the table. Write tests that try to read across tenants.</p>
<p>All the code below is generic example code with made-up names (<code>app.current_tenant</code>, <code>projects</code>, <code>tasks</code>), written for this post.</p>
<h2>Why we care about this</h2>
<p><a href="https://graphwake.com/?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=postgres-rls">Graphwake</a> maps which teams consume each endpoint of a company's OpenAPI specs and alerts only the subscribed teams when a breaking change is coming. To do that it holds things a company treats as internal: the list of its services and endpoints, which team calls which API, who owns what, every spec version, the history of API changes and the channels its alerts go to.</p>
<p><img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/kcqxyd9t5ck7i8qjkzci.png" alt="Example of the kind of data one company keeps in Graphwake: a weekly ownership report built from its service map, listing 9 endpoints with no consumers, 1 service with no owner, and an orphaned endpoint." /></p>
<p><em>Example: a weekly ownership report built from one company's service map.</em></p>
<p>A service map describes, fairly completely, how a company's systems fit together. Showing one row of it to another customer would be a serious failure, whatever the row says. So we didn't want isolation to depend on every query in the codebase remembering a filter. It has to hold in the database, including for queries someone writes next year without ever reading this post.</p>
<h2>Three ways to split tenants in PostgreSQL</h2>
<p>There are three common layouts, and each is a reasonable choice for some products.</p>
<table>
<thead>
<tr>
<th></th>
<th>Shared tables + RLS</th>
<th>Schema per tenant</th>
<th>Database per tenant</th>
</tr>
</thead>
<tbody><tr>
<td>Where the boundary lives</td>
<td>A policy on every tenant table</td>
<td><code>search_path</code> set per request</td>
<td>The connection string</td>
</tr>
<tr>
<td>Migrations</td>
<td>Run once</td>
<td>Run once per tenant schema</td>
<td>Run once per database</td>
</tr>
<tr>
<td>Connection pooling</td>
<td>One pool for everyone</td>
<td>One pool, but the path is per request</td>
<td>A pool per tenant, or reconnects</td>
</tr>
<tr>
<td>Cost of a new tenant</td>
<td>One row in <code>tenants</code></td>
<td>A schema and its tables; the catalog grows with every tenant</td>
<td>A database; the most operational work</td>
</tr>
<tr>
<td>Restore or export one tenant</td>
<td>Harder: filter by <code>tenant_id</code></td>
<td><code>pg_dump --schema</code></td>
<td>Easiest</td>
</tr>
<tr>
<td>One heavy tenant slows the others</td>
<td>Yes, they share tables and indexes</td>
<td>Yes, they share the server</td>
<td>Less, if databases are on separate servers</td>
</tr>
<tr>
<td>A query with a missing filter</td>
<td>The policy still filters it</td>
<td>Correct only if the path was set right</td>
<td>Correct only if the right connection was used</td>
</tr>
</tbody></table>
<p>Database per tenant gives the strongest separation and the easiest per-customer restore, and it costs the most to run: migrations fan out across every database, and a pool per tenant doesn't scale to thousands of small tenants.</p>
<p>Schema per tenant sits in the middle, but its isolation still depends on the application setting the right <code>search_path</code>. That's the same per-request discipline RLS asks for, without a policy to catch the mistake.</p>
<p>Shared tables with RLS are the cheapest to run and migrate, and in this layout alone the database itself catches a forgotten filter. In exchange, the policies have to be right on every table, every query pays for an extra predicate, and one large tenant shares indexes with everyone else.</p>
<p>For a product with many tenants of similar size and one schema for all of them, shared tables with RLS are usually the right default. That's what we chose: Graphwake runs on one PostgreSQL database with row-level security per tenant.</p>
<h2>How a request reaches only its own rows</h2>
<p><img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/6okrsdolkt59m25bg8gj.png" alt="Flow of one request: it arrives at the API, the API resolves the tenant from the caller's identity, the connection gets the tenant setting for this request, the Postgres policy filters rows in the database, and only that tenant's rows return while other tenants stay invisible." /></p>
<p>The application authenticates the caller and works out which tenant it belongs to. Before the first query, it puts that tenant on the database connection as a setting. Every tenant table has a policy that reads the setting, and PostgreSQL adds the policy to every query against the table. Application code keeps writing ordinary queries, and the database makes sure they only touch the current tenant's rows.</p>
<h2>The shape of a policy</h2>
<p>Every table that holds tenant data gets a <code>tenant_id</code> column, an index that starts with it, and a policy. The example uses a custom setting called <code>app.current_tenant</code>; any name with a dot in it works as a custom setting.</p>
<pre><code class="language-sql">-- Example: a tenant-owned table and its policy
CREATE FUNCTION current_tenant_id() RETURNS uuid
  LANGUAGE sql STABLE
  AS $$ SELECT NULLIF(current_setting('app.current_tenant', true), '')::uuid $$;

CREATE TABLE projects (
  id         uuid PRIMARY KEY,
  tenant_id  uuid NOT NULL REFERENCES tenants (id),
  name       text NOT NULL,
  UNIQUE (tenant_id, id)
);
CREATE INDEX projects_tenant_idx ON projects (tenant_id);

ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects FORCE ROW LEVEL SECURITY;

CREATE POLICY projects_by_tenant ON projects
  USING      (tenant_id = current_tenant_id())   -- which rows a query can see
  WITH CHECK (tenant_id = current_tenant_id());  -- which rows a write may produce
</code></pre>
<p>Going through it piece by piece:</p>
<p><code>USING</code> decides which existing rows a <code>SELECT</code>, <code>UPDATE</code> or <code>DELETE</code> can see. Another tenant's row doesn't raise an error. The query just doesn't see it.</p>
<p><code>WITH CHECK</code> decides which rows an <code>INSERT</code> or <code>UPDATE</code> may produce. When it's left out, PostgreSQL uses the USING expression for writes as well. We still write it out. It keeps the write rule visible next to the read rule, you need it anyway once the two differ, and it makes plain that an <code>UPDATE</code> can't move a row to another tenant by changing its <code>tenant_id</code>.</p>
<p><code>FORCE ROW LEVEL SECURITY</code> makes the policy apply to the table's owner too. By default the owner is exempt, and in many setups the application connects as the role that created the tables. Superusers are never subject to policies, so the application shouldn't connect as one.</p>
<p><code>current_setting(…, true)</code> returns NULL instead of raising an error when the setting is missing, and <code>NULLIF(…, '')</code> turns an emptied setting into NULL as well. <code>tenant_id = NULL</code> is never true, so a connection with no tenant sees no rows and can't write any. The policy fails closed.</p>
<p>Wrapping the lookup in a <code>STABLE</code> function keeps the expression in one place and lets the planner evaluate it once per query, so the comparison can use the <code>tenant_id</code> index.</p>
<p>Foreign keys need one more step. PostgreSQL checks referential integrity without applying the policies, so a plain <code>project_id</code> reference would accept the id of another tenant's project. Making the parent unique on <code>(tenant_id, id)</code> and referencing both columns keeps every link inside one tenant:</p>
<pre><code class="language-sql">-- Example: a child row cannot point at another tenant's parent
CREATE TABLE tasks (
  id          uuid PRIMARY KEY,
  tenant_id   uuid NOT NULL,
  project_id  uuid NOT NULL,
  title       text NOT NULL,
  FOREIGN KEY (tenant_id, project_id) REFERENCES projects (tenant_id, id)
);
</code></pre>
<h2>Carrying the tenant on a pooled connection</h2>
<p>The setting lives on a database connection, and connections are pooled. The connection your request uses served another tenant's request a moment ago and will serve a third one next. So the rule is: set the tenant every time a connection is handed out, before the first query, and never trust a value a previous borrower left behind. A request with no tenant should set an empty value rather than inherit one.</p>
<p>There's more than one correct way to do that. The example below sets the tenant at the start of each transaction with <code>set_config(name, value, true)</code>, the parameter-friendly form of <code>SET LOCAL</code>, which lasts until the transaction commits or rolls back. Setting it whenever the application takes a connection from the pool, before any query, follows the same rule. Either way, the current request always writes the value.</p>
<pre><code class="language-sql">-- Example: what the application sends for one request
BEGIN;
SELECT set_config('app.current_tenant', $1, true);  -- $1 is the tenant id, sent as a bind parameter
                                                     -- true = local to this transaction
SELECT id, name FROM projects ORDER BY name;         -- no tenant filter needed: the policy adds it
UPDATE projects SET name = $2 WHERE id = $3;         -- another tenant's row is simply not found
COMMIT;                                              -- the local setting ends with the transaction
</code></pre>
<p>A few practical points:</p>
<ul>
<li>Use <code>set_config</code> with a bind parameter. <code>SET LOCAL</code> doesn't accept parameters, so it tempts people into building the statement with string concatenation, while <code>set_config</code> takes the tenant id as an ordinary parameter.</li>
<li>Match the setting to its scope. A transaction-local setting only covers the transaction it was set in, so with that form, run tenant work inside a transaction. Whatever the scope, the fail-closed policy above means a query without a tenant returns no rows rather than wrong rows.</li>
<li>Put it in one place. Set the tenant in the data-access layer, from the authenticated request, not in each handler. In .NET that's a connection or transaction interceptor; most ORMs and frameworks have an equivalent hook.</li>
</ul>
<h2>Adding RLS in migrations</h2>
<p>Row-level security belongs in the same migration that creates the table. A table that exists for one deploy without its policy can be read across tenants by any query, and "we'll add the policy later" is how that happens. A small migration helper that emits the <code>ENABLE</code>, <code>FORCE</code> and <code>CREATE POLICY</code> statements for a table name makes the safe version the easiest one to write, and gives every table the same policy shape.</p>
<p>Bringing an existing table under RLS takes a few more steps: add the column, backfill it from a parent row, make it <code>NOT NULL</code>, index it, then enable and force the policy.</p>
<pre><code class="language-sql">-- Example: bringing an existing table under RLS, in one migration
ALTER TABLE invoices ADD COLUMN tenant_id uuid REFERENCES tenants (id);

UPDATE invoices i SET tenant_id = c.tenant_id
  FROM customers c WHERE c.id = i.customer_id;      -- backfill from the parent row

ALTER TABLE invoices ALTER COLUMN tenant_id SET NOT NULL;
CREATE INDEX invoices_tenant_idx ON invoices (tenant_id);

ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;
ALTER TABLE invoices FORCE ROW LEVEL SECURITY;
CREATE POLICY invoices_by_tenant ON invoices
  USING (tenant_id = current_tenant_id())
  WITH CHECK (tenant_id = current_tenant_id());
</code></pre>
<p>Each migration should also have a down step that removes the policy and turns RLS off in the reverse order. On a large table, run the backfill in batches before the migration that adds the constraint, so the <code>ALTER TABLE</code> statements stay short.</p>
<h2>Testing isolation</h2>
<p>Policies are code. The bugs that hurt are the ones where everything looks fine to the tenant you're testing with, so the tests have to look from the other side. Three kinds of test cover most of it.</p>
<p>The first is a catalog check: one query that lists every table with a <code>tenant_id</code> column that isn't under forced row-level security with a policy. Run it in CI after migrations and it catches the new table someone added without its policy.</p>
<pre><code class="language-bash"># Example: fail the build if any table with a tenant_id column is not under forced RLS
out=$(psql "$TEST_DATABASE_URL" -At -v ON_ERROR_STOP=1 &lt;&lt;'SQL'
SELECT c.relname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
JOIN pg_attribute a ON a.attrelid = c.oid AND a.attname = 'tenant_id' AND NOT a.attisdropped
WHERE n.nspname = 'public' AND c.relkind = 'r'
  AND NOT (c.relrowsecurity AND c.relforcerowsecurity
           AND EXISTS (SELECT 1 FROM pg_policy p WHERE p.polrelid = c.oid));
SQL
) || exit 1
# Any table name printed here is a table some tenant can read across.
[ -z "$out" ] || { echo "$out"; exit 1; }
</code></pre>
<p>The second is a two-tenant test. Seed two tenants with rows in every table. With tenant 1 set, check that each table returns only tenant 1's rows, that asking for tenant 2's rows by id returns nothing, that updates aimed at tenant 2 change nothing, and that inserting a row for tenant 2 is rejected. Then check that with no tenant set, nothing comes back.</p>
<pre><code class="language-sql">-- Example: run as the same database role the application uses, with two seeded tenants
BEGIN;
SELECT set_config('app.current_tenant', '11111111-1111-1111-1111-111111111111', true);

SELECT count(*) FROM projects;                                   -- expect tenant 1's rows only
SELECT count(*) FROM projects
  WHERE tenant_id = '22222222-2222-2222-2222-222222222222';      -- expect 0, even when asked for directly
UPDATE projects SET name = 'x'
  WHERE tenant_id = '22222222-2222-2222-2222-222222222222';      -- expect UPDATE 0
INSERT INTO projects (id, tenant_id, name)
  VALUES (gen_random_uuid(), '22222222-2222-2222-2222-222222222222', 'x');
                                                                 -- expect: new row violates row-level security policy
ROLLBACK;

BEGIN;                                                           -- no tenant set at all
SELECT count(*) FROM projects;                                   -- expect 0
ROLLBACK;
</code></pre>
<p>The third is a pool test. Run requests for tenant 1 and tenant 2 alternately through a pool with a single connection, and assert that each request only sees its own tenant. This one catches a setting that outlives its request.</p>
<p>Run all of them as the same database role the application uses, not as a superuser. A superuser isn't subject to policies, so an isolation test run as one passes whether the policies work or not.</p>
<h2>What RLS doesn't do for you</h2>
<ul>
<li>It doesn't choose the tenant. The policy trusts the setting. Resolving the tenant from the authenticated caller, and never from a value the caller can choose freely, is still the application's job.</li>
<li>It doesn't replace your filters. Writing <code>tenant_id</code> into application queries helps the planner and makes the intent obvious. RLS is the backstop for the query that forgot.</li>
<li>It doesn't isolate load. One heavy tenant still shares tables, indexes and the server with the others. Watch per-tenant query cost and index on <code>tenant_id</code> first.</li>
<li>It covers rows and nothing else. Files in object storage, caches and search indexes need their own tenant scoping.</li>
</ul>
<p>On performance: a simple policy adds one predicate to each query, much like the <code>WHERE tenant_id = …</code> you'd write by hand. With an index that starts with <code>tenant_id</code> and a STABLE function or a direct <code>current_setting</code> call in the policy, the planner can use the index and the overhead is small. Check the plans of your heaviest queries with EXPLAIN after enabling it.</p>
<h2>How we do it in Graphwake</h2>
<p>Graphwake runs on one PostgreSQL database. Customer data lives in tables that carry the workspace each row belongs to, and a row-level security policy filters each read and checks each write against it.</p>
<p>The API resolves the workspace from the signed-in user and puts it on the database connection before the first query runs. A connection with no workspace set sees no rows.</p>
<p>Migrations enable and force row-level security on customer tables, with the same policy shape on each one, so the policy applies to every query the application runs. Tests seed two workspaces and check that each one sees only its own rows, that one can't write into the other, and that a connection with no workspace sees nothing.</p>
<p>That's how one company's service map, subscriptions, ownership and <a href="https://graphwake.com/guides/anatomy-of-a-breaking-change">API change history</a> stay out of every other company's view, while its consumer teams get alerts about the breaking changes that affect them.</p>
<h2>A checklist</h2>
<ol>
<li>Give every tenant-owned table a <code>tenant_id</code> column, an index that starts with it, and composite foreign keys on <code>(tenant_id, id)</code>.</li>
<li>Enable and force row-level security, with one policy that has both USING and WITH CHECK and fails closed when no tenant is set.</li>
<li>Connect as an ordinary role, never as a superuser.</li>
<li>Set the tenant every time a connection is handed out, before the first query, with a bind parameter, in one place in the data-access layer.</li>
<li>Add the policy in the migration that adds the table, and run a catalog check in CI that finds any table without one.</li>
<li>Test with two tenants, with no tenant, and through a pool of one connection, as the application's own role.</li>
</ol>
<hr />
<p><em>Originally published on <a href="https://graphwake.com/guides/postgres-row-level-security-multi-tenant">graphwake.com</a>.</em></p>
<p>Which layout did you go with for tenant isolation, and would you pick it again?</p>
]]></content:encoded></item><item><title><![CDATA[Orphaned API endpoints: what they are and how to find them]]></title><description><![CDATA[Endpoints with no consumer on record or no owner: how to find both from your OpenAPI specs with jq, and a weekly routine to clear them.]]></description><link>https://graphwake.hashnode.dev/orphaned-api-endpoints-what-they-are-and-how-to-find-them</link><guid isPermaLink="true">https://graphwake.hashnode.dev/orphaned-api-endpoints-what-they-are-and-how-to-find-them</guid><category><![CDATA[APIs]]></category><category><![CDATA[OpenApi]]></category><category><![CDATA[Microservices]]></category><category><![CDATA[Devops]]></category><dc:creator><![CDATA[Graphwake]]></dc:creator><pubDate>Sat, 14 Nov 2026 19:03:54 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6af80cab97fffc20d7ebbf36/8e15307a-7bc4-4508-b0fc-e3c392d3ce3a.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>An orphaned API endpoint is a live endpoint that no team has said it depends on. A second gap usually travels with it: an endpoint with no owner, so nobody answers for it. The first means a change there warns nobody. The second means nobody is there to ask.</p>
<p>You can find both by comparing the endpoints in your OpenAPI specs with two lists, consumers and owners, and clear them with a short weekly review. Here's how we'd do it by hand, and then how <a href="https://graphwake.com/?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=orphaned-endpoints">Graphwake</a> does it for you.</p>
<h2>Two gaps, two questions</h2>
<table>
<thead>
<tr>
<th>Gap</th>
<th>The question it leaves open</th>
<th>What goes wrong</th>
</tr>
</thead>
<tbody><tr>
<td>No consumer team on record (orphan)</td>
<td>Whom do we tell when this endpoint changes?</td>
<td>A breaking change ships and the team that calls it hears from its users first</td>
</tr>
<tr>
<td>No owner</td>
<td>Whom do we ask about this endpoint?</td>
<td>Questions, incidents and retirement decisions have nobody to go to</td>
</tr>
</tbody></table>
<p>The two are independent. An endpoint can have an owner and no known consumers, which is the common case for a new endpoint. It can have five consumer teams and no owner, which is common after a reorganisation. Both are worth a look, and they need different fixes.</p>
<p>A note on the word. In API security writing, "orphaned", "zombie" and "shadow" APIs are endpoints that stay reachable while nobody maintains or owns them, and tools find them from traffic: Cloudflare API Shield <a href="https://developers.cloudflare.com/changelog/post/2025-11-25-zombie-endpoint-risk-label/">labels a saved endpoint</a> with no traffic for 32 days <code>cf-risk-zombie</code>. Here, and in Graphwake, the word means a gap in the record: an endpoint in the spec with no consumer team. An orphan in this sense may be called all day by a team that never said so.</p>
<h2>Why it matters</h2>
<p>When an endpoint with no recorded consumers changes, the producer has no one to warn. Either nobody calls it, or someone does and will find out in production.</p>
<p>Retirement stalls, too. Without a consumer list nobody can say whether the endpoint is safe to remove, so it stays forever. (We wrote separately about <a href="https://graphwake.com/guides/deprecate-api-endpoint-safely">how to deprecate an endpoint safely</a>.)</p>
<p>Ownership decays quietly. Teams split, merge and rename. An owner field that points at a team that no longer exists is worse than an empty one, because it looks filled.</p>
<p>And there's a security angle. The OWASP API Security Top 10 lists <a href="https://api-security.owasp.org/editions/2023/en/0xa9-improper-inventory-management">improper inventory management (API9:2023)</a>: hosts without a clear purpose, no retirement plan, missing or outdated documentation. An endpoint nobody owns is part of that problem.</p>
<h2>How to find them from your specs</h2>
<p>Your OpenAPI specs list every endpoint that exists. Start there, not from traffic, so the list is complete on one side. With a JSON spec and <code>jq</code>, list each operation as a method and a path:</p>
<pre><code class="language-bash">jq -r '.paths | to_entries[] | .key as $p | .value | keys[]
  | select(test("^(get|put|post|delete|patch|head|options|trace)$"))
  | "\(ascii_upcase) \($p)"' openapi.json | sort &gt; endpoints.txt
</code></pre>
<p>Then compare it with whatever records consumers today: a sheet, a wiki page, catalog entries. In this example it's a two-column file, <code>METHOD /path,team</code>. The lines that only appear in the spec are your orphans:</p>
<pre><code class="language-bash">cut -d, -f1 consumers.csv | sort -u | comm -23 endpoints.txt -
</code></pre>
<p>For a made-up orders-api with three of its four endpoints on record, the output is:</p>
<pre><code class="language-text">GET /orders/{id}/invoice
</code></pre>
<p>Do the same with an owners list to find endpoints without an owner. If owners are set per service, an endpoint without its own owner inherits the service's. Only an endpoint whose service has no owner either is truly unowned.</p>
<h2>What to do with each orphan</h2>
<p>Finding one means you have something to check. Look at the endpoint's traffic, then pick one of three answers.</p>
<table>
<thead>
<tr>
<th>What you see</th>
<th>What it means</th>
<th>What to do</th>
</tr>
</thead>
<tbody><tr>
<td>Calls from a caller you can name</td>
<td>A consumer that never put itself on record</td>
<td>Ask that team to add itself (<a href="https://graphwake.com/guides/find-which-teams-use-an-api-endpoint">finding which teams use an endpoint</a> covers the search)</td>
</tr>
<tr>
<td>No calls over a full business cycle</td>
<td>Probably unused</td>
<td>Confirm with the owner, then retire it with notice</td>
</tr>
<tr>
<td>A new endpoint, not released to callers yet</td>
<td>Nobody is meant to call it yet</td>
<td>Leave it, and look again after the first consumer ships</td>
</tr>
</tbody></table>
<p>Don't delete an orphan just because it's an orphan. Only remove it once the traffic shows nobody calls it over a full business cycle and the owner agrees.</p>
<p>For an endpoint with no owner, someone just has to decide: whoever owns the service, or the team that changes the code most often, takes it. Write it down even if it's a guess you'll correct later.</p>
<h2>A weekly routine</h2>
<ol>
<li><strong>Get the list on Monday.</strong> Orphan endpoints, endpoints without an owner and services without an owner, sent to the people who can assign owners. Short lists get read, so cap each one.</li>
<li><strong>Triage the new lines.</strong> Each new orphan gets one of the three answers above. Each unowned service or endpoint gets an owner, or a name and a date for the decision.</li>
<li><strong>Ask, don't chase.</strong> Send one message per likely consumer team, naming the endpoint and the evidence. A team that says "not ours" has given you an answer too.</li>
<li><strong>Watch the count, not the list.</strong> The number should fall and then stay low. A sudden rise usually means a new service came in with no consumers mapped yet.</li>
</ol>
<h2>How Graphwake reports orphans</h2>
<p>In Graphwake an orphan endpoint is a live endpoint that no consumer team subscribes to. Graphwake reads every endpoint from your OpenAPI specs and the subscriptions teams make, so the comparison above runs on its own and the weekly list arrives without anyone building it.</p>
<p><img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/6xq9umhuioupjsm486ct.png" alt="Example weekly ownership report email from Graphwake, Monday 08:00: 9 endpoints have no consumers, 1 service has no owner (catalog-api), and GET /orders/{id}/invoice is flagged as an orphan." /></p>
<p><em>Example: the Monday email to admins.</em></p>
<p>Every Monday from 08:00 UTC, admins get one email listing orphan endpoints, endpoints without an owner and services without an owner, up to 50 lines each. A week with nothing to report sends nothing, and admins can turn it off.</p>
<p>Ownership is optional and inherits. A service may have an owner team, and an endpoint may have its own. An endpoint without one inherits the service's owner, so only real gaps show up.</p>
<p>The service graph flags each orphan endpoint next to its owner, and the overview counts them. A breaking change on an orphan counts toward the <a href="https://graphwake.com/impact-score">impact score</a> as a blind spot.</p>
<p>When an orphan is new or changes in a breaking way, Discovery suggests teams from rules such as subscriptions to neighbouring endpoints and shared schemas. Someone confirms before a team subscribes.</p>
<h2>When a traffic tool or catalog is enough on its own</h2>
<p>If you need to know whether an endpoint is called at all, gateway analytics, access logs or a traffic-based inventory such as Cloudflare API Shield answer that. If you're hunting endpoints that are live but missing from any spec, API discovery tools that compare traffic with your specs find these "shadow" endpoints. And if you want one owner field for every service, library and database, a software catalog like Backstage covers far more than APIs.</p>
<hr />
<p><em>Originally published on <a href="https://graphwake.com/guides/orphaned-api-endpoints">graphwake.com</a>.</em></p>
<p>How many endpoints in your specs have a consumer on record somewhere? Has anyone on your team ever run the numbers?</p>
]]></content:encoded></item><item><title><![CDATA[Anatomy of a breaking change: one pull request, three teams, two alerts]]></title><description><![CDATA[One example pull request from OpenAPI change to change history: which changes break, how the score adds up, and who gets told.]]></description><link>https://graphwake.hashnode.dev/anatomy-of-a-breaking-change-one-pull-request-three-teams-two-alerts</link><guid isPermaLink="true">https://graphwake.hashnode.dev/anatomy-of-a-breaking-change-one-pull-request-three-teams-two-alerts</guid><category><![CDATA[APIs]]></category><category><![CDATA[OpenApi]]></category><category><![CDATA[Microservices]]></category><category><![CDATA[Devops]]></category><dc:creator><![CDATA[Graphwake]]></dc:creator><pubDate>Sat, 14 Nov 2026 19:03:41 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6af80cab97fffc20d7ebbf36/5c432ad5-be2e-4b2d-b182-c3ac304c9654.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A breaking API change is a change to an API's contract that can make an existing client fail, like a removed response field or a new required parameter. Talking about them in the abstract only gets you so far, so let's follow one pull request all the way through <a href="https://graphwake.com/?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=anatomy-of-a-breaking-change">Graphwake</a>: from the spec change, through the score and the alerts, to the change history after the merge.</p>
<p>The behaviour is Graphwake's. The company, people and numbers are made up for the example.</p>
<h2>The cast</h2>
<p>Four teams are involved, though only one of them writes the change.</p>
<table>
<thead>
<tr>
<th>Example team</th>
<th>Who</th>
<th>Relationship to <code>GET /orders/{id}</code></th>
</tr>
</thead>
<tbody><tr>
<td>payments-team</td>
<td>Owns <code>orders-api</code>. John is on this team and opens the pull request.</td>
<td>Owner</td>
</tr>
<tr>
<td>checkout</td>
<td>4 people. Calls the endpoint to show an order total at checkout. Alerts go to Slack <code>#team-checkout</code>.</td>
<td>Subscribed consumer</td>
</tr>
<tr>
<td>mobile-app</td>
<td>6 people. Calls it from the order details screen. Alerts go to Slack <code>#mobile-alerts</code>.</td>
<td>Subscribed consumer</td>
</tr>
<tr>
<td>search</td>
<td>Calls <code>GET /orders</code> to index orders, never <code>GET /orders/{id}</code>.</td>
<td>Consumes the service, not this endpoint</td>
</tr>
</tbody></table>
<p>That map comes from subscriptions: each consumer team marked the endpoints it calls. Graphwake uses that list to tell checkout and mobile-app and to leave search alone, so nobody has to remember who calls what during a review.</p>
<p><img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/ll8oflqsyla00oxxucoh.png" alt="Example service graph for orders-api. GET /orders/{id} is owned by payments-team and has two consumer teams, checkout (4 people, Slack #team-checkout) and mobile-app (6 people, Slack #mobile-alerts). Discovery suggests storefront with a confidence of 72 because it uses a neighbouring endpoint. GET /orders/{id}/invoice has no subscriber and is marked as a blind spot." /></p>
<p><em>Example: the map for <code>GET /orders/{id}</code>. It also shows storefront, a Discovery suggestion nobody has confirmed, and an endpoint with no subscriber.</em></p>
<h2>The timeline</h2>
<p><img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/8rt1gn8sqgg086wmit8x.png" alt="Timeline: the spec changes in a pull request, the CI step sends the draft, a breaking change is found, the impact score is calculated, the 2 subscribed teams are alerted while 1 team that is not subscribed gets no alert, the pull request merges, and the change is recorded in the change history." /></p>
<p><em>Example: pull request #42 in orders-api, from the spec change to the change history.</em></p>
<h2>1. The spec changes in a pull request</h2>
<p>John renames the response field <code>total</code> to <code>totalAmount</code> and adds a <code>region</code> query parameter so orders can be served from the nearest region. He makes <code>region</code> required, because the new code needs it. Lines starting with <code>+</code> were added and the line starting with <code>-</code> was removed:</p>
<pre><code class="language-yaml"># orders-api/openapi.yaml, pull request #42 (branch orders-region-param)
paths:
  /orders/{id}:
    get:
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
+       - { name: region, in: query, required: true, schema: { type: string } }
      responses:
        "200":
          content:
            application/json:
              schema:
                properties:
                  id:          { type: string }
-                 total:       { type: number }
+                 totalAmount: { type: number }
                  currency:    { type: string }
</code></pre>
<p>As a code diff, this is a small pull request. As a contract it holds three changes, and two of them break callers that work today.</p>
<h2>2. The CI step sends the spec as a draft</h2>
<p>orders-api runs the <a href="https://graphwake.com/guides/graphwake-ci-agent">Graphwake CI agent</a> in its pipeline. On the pull request build, the agent sends the spec with the build's context: pull request #42, its link, the author and the commit. Graphwake stores it as a draft for that pull request. The live spec stays exactly as it was, because nothing has been merged.</p>
<p>This step is why everything below happens before the merge. Without it, Graphwake can still poll a published spec URL every 30 minutes, but then it only sees the change after it's deployed.</p>
<h2>3. Graphwake finds the breaking changes</h2>
<p>Graphwake compares the draft with the live spec, endpoint by endpoint. We call a change breaking when it can make an existing client fail, which in practice means one of these:</p>
<ul>
<li>an endpoint or a response field is removed</li>
<li>a new required parameter or required request field is added</li>
<li>a field's type changes</li>
<li>the accepted values are narrowed, for example an enum loses a value</li>
</ul>
<table>
<thead>
<tr>
<th>Change in #42</th>
<th>Breaking?</th>
<th>Why</th>
</tr>
</thead>
<tbody><tr>
<td>Response field <code>total</code> removed</td>
<td>Yes</td>
<td>Every client that reads <code>total</code> now gets nothing. A rename is a removal plus an addition.</td>
</tr>
<tr>
<td>Query parameter <code>region</code> added as required</td>
<td>Yes</td>
<td>Every existing request leaves it out, and the server can now reject those requests.</td>
</tr>
<tr>
<td>Response field <code>totalAmount</code> added</td>
<td>No</td>
<td>Clients ignore fields they don't know. It stays quiet by default.</td>
</tr>
</tbody></table>
<p>The rule of thumb behind the list: does a client that follows today's contract keep working? Adding something a client can ignore is safe. Taking away something it reads, or demanding something it doesn't send, breaks it. A team that wants to hear about the safe changes too can opt in to every change.</p>
<h2>4. The impact score is computed</h2>
<p>Each change set gets a score from 0 to 100, made of four parts with public rules. Each part counts something, gives a fixed number of points per item and stops at a cap, so no single part can dominate. The full rules are on the <a href="https://graphwake.com/impact-score">impact score page</a>. For #42:</p>
<table>
<thead>
<tr>
<th>Rule</th>
<th>Count</th>
<th>Points each</th>
<th>Cap</th>
<th>Points</th>
</tr>
</thead>
<tbody><tr>
<td>Breaking changes</td>
<td>2</td>
<td>10</td>
<td>30</td>
<td>20</td>
</tr>
<tr>
<td>Affected teams</td>
<td>2</td>
<td>12</td>
<td>36</td>
<td>24</td>
</tr>
<tr>
<td>People to notify</td>
<td>10</td>
<td>1</td>
<td>14</td>
<td>10</td>
</tr>
<tr>
<td>Blind spots</td>
<td>0</td>
<td>10</td>
<td>20</td>
<td>0</td>
</tr>
<tr>
<td>Total</td>
<td></td>
<td></td>
<td></td>
<td><strong>54</strong></td>
</tr>
</tbody></table>
<p>The two breaking changes are the removed field and the new required parameter; the added field doesn't count. checkout and mobile-app subscribe to <code>GET /orders/{id}</code>, search doesn't, so two affected teams. The people count is the distinct members of those teams: 4 in checkout and 6 in mobile-app, nobody in both. Someone who follows the endpoint personally gets the alert by email but isn't counted here. And there are no blind spots, because every endpoint with a breaking change has at least one subscriber and no endpoint was removed.</p>
<p>54 out of 100 is Medium. Low runs from 0 to 29, Medium from 30 to 59, and High is 60 and above. We publish the rules so that a reviewer can check every point.</p>
<p>Blind spots work differently. Suppose #42 had also removed <code>GET /orders/{id}/invoice</code>, which no team subscribes to. That's a third breaking change (30 instead of 20) and a blind spot (plus 10): 74 out of 100, High. The score goes up because Graphwake has nobody to tell. When that happens, Discovery suggests teams that may be affected, each with a reason, and a suggested team becomes a subscriber only when someone confirms it.</p>
<p>To show what High looks like, here's a larger example from the same service. This one is not #42: pull request #1284 has three breaking changes and three affected teams, so 30 + 36 + 13 people = 79.</p>
<p><img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/xhkix42pyht45r26mi3l.png" alt="Example impact score card for pull request #1284 on orders-api, not merged: 79 out of 100, High. 3 breaking changes for 30 points, 3 consumer teams for 36, 13 people notified for 13, no blind spots." /></p>
<h2>5. Two teams are alerted, the third is not</h2>
<p>With "Alerts on open pull requests" turned on in settings, Graphwake sends the alert while #42 is still open. It goes to the teams subscribed to the changed endpoint, each in the channel that team chose: checkout in <code>#team-checkout</code>, mobile-app in <code>#mobile-alerts</code>. A team that chose Microsoft Teams or email gets the same information there.</p>
<p>These are the lines of the chat message checkout and mobile-app get. The Change line holds a short description of each breaking change found, separated by semicolons:</p>
<pre><code class="language-text">Breaking change in PR #42
Service: orders-api
Endpoint: GET /orders/{id}
Owned by payments-team
Change: &lt;the removed field&gt;; &lt;the new required parameter&gt;
Author: john
PR: https://github.com/example/orders-api/pull/42
</code></pre>
<p>The message carries what a consumer needs to act on it: the endpoint, what changed, who changed it and where to comment. The impact score stays in Graphwake, on the change set, next to the list of teams it reached.</p>
<p>search gets nothing. It calls <code>orders-api</code>, but not the endpoint that changed. In Graphwake's impact view it's listed among the teams that consume the service but none of the changed endpoints, so the producer can see it was considered and that skipping it was deliberate.</p>
<p>While the pull request stays open, Graphwake keeps one message per team and endpoint. If John pushes again, the existing message is edited in place instead of a new one being posted. An edit notifies nobody, so if a push makes a change breaking that wasn't before, Graphwake adds a thread reply: "This change is now breaking." If a push removes the breaking change, the message is updated to say it's no longer breaking.</p>
<h2>6. The teams prepare, then the pull request merges</h2>
<p>checkout starts sending <code>region</code>, mobile-app moves to <code>totalAmount</code>, and each team marks the change as handled for itself in Graphwake. That conversation happened during the review. Usually it happens after an incident.</p>
<p>John merges #42. The default-branch build sends the spec as live, and the agent reports that the commit came from pull request #42. Graphwake settles the draft as merged: each team's message is updated to "Published: Breaking change in PR #42", with the thread reply "Published to live." The handled marks carry over, because the breaking changes were the same in the draft and at the merge. Had John closed the pull request instead, the reply would have been "PR closed without merging." Where the CI doesn't report closes, the draft closes quietly after 14 days, and the live spec never changed.</p>
<h2>7. The change history keeps the record</h2>
<p>The merge is recorded in the change history of <code>orders-api</code> and of <code>GET /orders/{id}</code>: the change set, its breaking changes, the team that published it, and which teams were told in which channel. The history is append-only. Months later, when someone asks why checkout knew about the <code>region</code> parameter, checkout's subscription to the endpoint links to the change it was told about.</p>
<p>If an old pipeline on the main branch is re-run later, its spec doesn't quietly undo #42: Graphwake ignores a live spec from an older commit than the one it already holds.</p>
<h2>Why routing beats broadcasting</h2>
<p>The usual alternative is a post in a shared channel, and the hope that everyone who calls the endpoint reads it. In this example that means asking the whole company to work out whether they call <code>GET /orders/{id}</code>.</p>
<p>With subscriptions, each consumer team records what it calls once, when it starts calling it. Nobody has to repeat that matching every time another team ships.</p>
<p>search heard nothing, and that tells search something: it isn't affected. In a broadcast channel, silence tells you nothing: you can't tell "not affected" from "missed the post".</p>
<p>And an endpoint nobody subscribes to shows up as a blind spot and raises the score, where a broadcast would leave it to surface in production.</p>
<h2>What to take from this</h2>
<ol>
<li>Review spec changes as contract changes. A rename is a removal, and a new required input breaks every caller that doesn't send it.</li>
<li>Count who is affected before the merge. Two breaking changes on an endpoint with two consumer teams is a different conversation from the same changes on an endpoint nobody calls.</li>
<li>Tell the teams that call the endpoint, in their own channel, and let everyone else stay quiet.</li>
</ol>
<p>For the review side, there's a <a href="https://graphwake.com/guides/api-breaking-change-pr-checklist">breaking change checklist for pull requests</a>, and more examples of what counts as breaking in <a href="https://graphwake.com/guides/openapi-breaking-changes">OpenAPI breaking changes</a>.</p>
<hr />
<p><em>Originally published on <a href="https://graphwake.com/guides/anatomy-of-a-breaking-change">graphwake.com</a>.</em></p>
<p>When a breaking change shipped at your company, who found out first: the team that made it, or the team whose app broke?</p>
]]></content:encoded></item><item><title><![CDATA[One Go binary, five CI systems: inside our open-source CI agent]]></title><description><![CDATA[How our CI agent detects the platform and build type, keeps old re-runs from replacing a newer live spec, and keeps its key out of logs.]]></description><link>https://graphwake.hashnode.dev/one-go-binary-five-ci-systems-inside-our-open-source-ci-agent</link><guid isPermaLink="true">https://graphwake.hashnode.dev/one-go-binary-five-ci-systems-inside-our-open-source-ci-agent</guid><category><![CDATA[Go Language]]></category><category><![CDATA[Open Source]]></category><category><![CDATA[Devops]]></category><category><![CDATA[ci-cd]]></category><dc:creator><![CDATA[Graphwake]]></dc:creator><pubDate>Sat, 14 Nov 2026 19:03:29 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6af80cab97fffc20d7ebbf36/78ef555a-e9c4-43e2-937f-8adce4d34bfb.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>We ship a small open-source Go binary that sends a service's OpenAPI spec from a CI pipeline to Graphwake. The same step runs in GitHub Actions, GitLab CI, Azure DevOps, Bitbucket Pipelines and Jenkins. It reads the platform's environment, decides whether the build is a pull request, a live-branch build or neither, and sends the spec with that context.</p>
<p>The code is at <a href="https://github.com/graphwake/agent">github.com/graphwake/agent</a> under Apache-2.0. Below are the parts that took us the most thought.</p>
<h2>Why the agent exists</h2>
<p><a href="https://graphwake.com/?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=graphwake-ci-agent">Graphwake</a> keeps a map of which teams consume each endpoint of your OpenAPI specs. When a spec changes in a breaking way, it scores the change and alerts only the teams subscribed to the affected endpoints, in Slack, Microsoft Teams or email. For that to be useful, the alert has to arrive while the change can still be stopped or adjusted.</p>
<p>A spec can reach Graphwake two ways. Graphwake can poll a published spec URL every 30 minutes, which sees a change once it's deployed. Or a step in the producer's pipeline sends the spec from the pull request build, so Graphwake sees the change before anyone merges it. The agent is that step.</p>
<p>We couldn't assume one CI system. A company with a few dozen services often runs two or three. So we set ourselves three goals.</p>
<p><strong>One step, same settings everywhere.</strong> <code>--spec</code> and <code>GRAPHWAKE_KEY</code> are enough on every platform. Everything else is read from the CI environment.</p>
<p><strong>Only the spec.</strong> The agent sends the spec file as it is (YAML or JSON). It doesn't generate it, and your source code is not sent. To classify the build it reads the CI environment and, where git is available, a few commit details: the SHA, the commit time and message, and the remote branch tip.</p>
<p><strong>Small enough to read.</strong> It's written in Go against the standard library only and ships as one static binary. You run it with a secret in your pipeline, so you should be able to read every line of it.</p>
<h2>One binary, several ways to install it</h2>
<p>Releases are built with GoReleaser: binaries for Linux, macOS and Windows on amd64 and arm64, a <code>checksums.txt</code>, and a multi-arch image <code>ghcr.io/graphwake/agent</code> (distroless, with a busybox shell so GitLab CI and Jenkins can run scripts in it). Each platform gets the wrapper it expects, and every wrapper runs the same binary.</p>
<table>
<thead>
<tr>
<th>Where</th>
<th>How</th>
<th>What the wrapper does</th>
</tr>
</thead>
<tbody><tr>
<td>GitHub Actions</td>
<td><code>uses: graphwake/agent/github-action@v0.1.0</code></td>
<td>A composite action: downloads the pinned release for the runner's OS and architecture, checks its SHA-256 against <code>checksums.txt</code>, runs it</td>
</tr>
<tr>
<td>GitLab CI</td>
<td><code>include</code> the template and <code>extends: .graphwake-upload</code></td>
<td>Runs the image in merge request pipelines and on the default branch</td>
</tr>
<tr>
<td>Azure DevOps</td>
<td>A script step with the binary, or the <code>GraphwakeUpload@0</code> task where the Graphwake extension is installed</td>
<td>Downloads the binary, checks the checksum, runs it</td>
</tr>
<tr>
<td>Bitbucket Pipelines, Jenkins, anything else</td>
<td>The image <code>ghcr.io/graphwake/agent:0.1.0</code> or the binary</td>
<td>Nothing extra: you call <code>graphwake upload</code></td>
</tr>
</tbody></table>
<p>GitHub Actions, from the <a href="https://github.com/graphwake/agent">agent README</a>. The <code>closed</code> type lets the agent report pull requests closed without merging:</p>
<pre><code class="language-yaml">on:
  pull_request:
    types: [opened, synchronize, reopened, closed]
  push:
    branches: [main]

jobs:
  api-spec:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      # ... build and produce openapi.json
      - uses: graphwake/agent/github-action@v0.1.0
        with:
          spec: openapi.json
          key: ${{ secrets.GRAPHWAKE_KEY }}
</code></pre>
<p>GitLab CI. Add <code>GRAPHWAKE_KEY</code> as a masked CI/CD variable:</p>
<pre><code class="language-yaml">include:
  - remote: https://raw.githubusercontent.com/graphwake/agent/v0.1.0/gitlab/graphwake.gitlab-ci.yml

graphwake:
  extends: .graphwake-upload
  needs: [build]
  variables:
    GRAPHWAKE_SPEC: build/openapi.json
</code></pre>
<p>Where you install the binary yourself, verify it the same way the wrappers do. For the image, pin it by digest (<code>ghcr.io/graphwake/agent@sha256:...</code>) to get the same guarantee. This is adapted from the README's install script:</p>
<pre><code class="language-bash">VERSION=0.1.0
BASE=https://github.com/graphwake/agent/releases/download/v$VERSION
ARCHIVE=graphwake_${VERSION}_linux_amd64.tar.gz
curl -fsSLO "$BASE/$ARCHIVE" &amp;&amp; curl -fsSLO "$BASE/checksums.txt" &amp;&amp;
  sha256sum --check --ignore-missing checksums.txt &amp;&amp;
  tar -xzf "$ARCHIVE" graphwake &amp;&amp;
./graphwake upload --spec openapi.json
</code></pre>
<h2>Step one: which CI is this?</h2>
<p>Every CI system sets its own environment variables, and each has one that identifies it. The agent checks them in a fixed order and falls back to <code>other</code> when none matches. From <code>internal/ci/detect.go</code>:</p>
<pre><code class="language-go">switch {
case getenv("GITHUB_ACTIONS") == "true":
    return detectGitHub(getenv, readFile)
case getenv("GITLAB_CI") != "":
    return detectGitLab(getenv)
case strings.EqualFold(getenv("TF_BUILD"), "true"):
    return detectAzure(getenv)
case getenv("BITBUCKET_BUILD_NUMBER") != "" || getenv("BITBUCKET_COMMIT") != "":
    return detectBitbucket(getenv)
case getenv("JENKINS_URL") != "" || getenv("JENKINS_HOME") != "":
    return detectJenkins(getenv)
}
return Info{Platform: Other, Event: EventUnknown}
</code></pre>
<p><code>getenv</code> and <code>readFile</code> are injected, so every platform is tested from fixture environments and event payloads.</p>
<p>Each <code>detect*</code> function then reads that platform's own variables for the pull request number, link, author, commit and branch. Most of that is a lookup table. Four cases took more care.</p>
<p>On a GitHub pull request, <code>GITHUB_SHA</code> points at GitHub's temporary merge commit, not at the commit the author pushed. We read the pull request's head SHA from the event payload instead, so Graphwake records the author's commit.</p>
<p>Only <code>push</code>, <code>workflow_dispatch</code> and <code>schedule</code> may publish the live spec on GitHub. Events such as <code>issue_comment</code> or <code>workflow_run</code> also run on the default branch's ref, but they often build a pull request's code, so we don't trust them with the live spec.</p>
<p>Azure DevOps has two pull request ids. <code>SYSTEM_PULLREQUEST_PULLREQUESTNUMBER</code> is the GitHub number for GitHub-hosted repositories and <code>SYSTEM_PULLREQUEST_PULLREQUESTID</code> is the Azure Repos id. The agent takes the first one that is set.</p>
<p>On a change request, Jenkins puts <code>PR-5</code> in <code>BRANCH_NAME</code>. The real source branch is in <code>CHANGE_BRANCH</code>.</p>
<h2>Step two: what kind of build is it?</h2>
<p><img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/mjv5bl9m525256wp8dqr.png" alt="Flow diagram: a CI build becomes a pull request build, a live-branch build, or anything else. A pull request build stores a draft spec and consumer teams hear before the merge. A live-branch build sends the live spec, settles the pull request draft as merged, and notifications go out on merge. Anything else sends nothing: no spec, no alert." /></p>
<p><em>Three outcomes from one step. Only pull request builds and live-branch builds send anything.</em></p>
<table>
<thead>
<tr>
<th>Platform</th>
<th>Pull request build</th>
<th>Live build</th>
</tr>
</thead>
<tbody><tr>
<td>GitHub Actions</td>
<td><code>pull_request</code> / <code>pull_request_target</code> events. Closed without merging sends "closed" (no spec). A merged PR sends nothing: the default-branch build reports the merge.</td>
<td><code>push</code> (or a manual or scheduled run) on a live branch</td>
</tr>
<tr>
<td>GitLab CI</td>
<td>Merge request pipelines (<code>CI_MERGE_REQUEST_IID</code>)</td>
<td><code>CI_COMMIT_BRANCH</code> is a live branch (default <code>CI_DEFAULT_BRANCH</code>)</td>
</tr>
<tr>
<td>Azure DevOps</td>
<td><code>BUILD_REASON=PullRequest</code></td>
<td>Other builds of a live branch (default <code>main</code> / <code>master</code>)</td>
</tr>
<tr>
<td>Bitbucket Pipelines</td>
<td><code>BITBUCKET_PR_ID</code></td>
<td><code>BITBUCKET_BRANCH</code> is a live branch</td>
</tr>
<tr>
<td>Jenkins (multibranch)</td>
<td><code>CHANGE_ID</code>, <code>CHANGE_URL</code>, <code>CHANGE_AUTHOR</code></td>
<td><code>BRANCH_NAME</code> (or <code>GIT_BRANCH</code>) is a live branch</td>
</tr>
<tr>
<td>Anything else</td>
<td><code>--pr</code></td>
<td>Only with <code>--live</code></td>
</tr>
</tbody></table>
<p>A live branch is the platform's default branch when the platform says what it is (GitHub's event payload, GitLab's <code>CI_DEFAULT_BRANCH</code>), else <code>main</code> and <code>master</code>. Azure DevOps has no default-branch variable, so it uses the fallback. Teams that release from more than one branch list them. On GitHub Actions that's the action's <code>live-branch</code> input, and the same branches go in the workflow's <code>push</code> trigger. Elsewhere it's <code>GRAPHWAKE_LIVE_BRANCH=main,release/*</code>. The patterns are globs.</p>
<p>With the spec, the agent sends the build's context: the platform, the commit SHA, the commit time (the committer date from git when git is there, else the platform's value) and the branch the commit is on. If the branch name is longer than 255 characters we drop it rather than reject the request, so the spec still arrives.</p>
<p>Here's an example request body for a pull request build on GitHub Actions (<code>POST /api/ingest/specs</code>, with the key in the <code>Authorization</code> header):</p>
<pre><code class="language-json">{
  "spec": "openapi: 3.0.3\ninfo: ...",
  "context": {
    "kind": "pullRequest",
    "prId": "42",
    "prUrl": "https://github.com/example/orders-api/pull/42",
    "author": "john",
    "state": "open",
    "platform": "github",
    "commitSha": "9f2c1e4b7a0d",
    "commitTime": "2026-11-10T10:05:00Z",
    "branch": "orders-region-param"
  }
}
</code></pre>
<h2>An old re-run must not replace a newer live spec</h2>
<p>Live builds have a race that pull request builds don't. Say John merges pull request #41 at 10:00 and pull request #42 at 10:05. The 10:05 pipeline finishes first and publishes the live spec. Then the 10:00 pipeline finishes, or someone re-runs it an hour later. If it sent its spec as live, the API would look as if #42's changes had been reverted.</p>
<p>So before it sends a live spec, the agent checks two things.</p>
<ol>
<li><strong>The checked-out commit is the build's commit.</strong> <code>git rev-parse HEAD</code> must match the commit the platform says it is building. If they differ, the live spec is not sent.</li>
<li><strong>The build's commit is still the branch tip.</strong> On GitHub the agent asks the API with the job token (<code>GET /repos/{repo}/commits/{branch}</code>, which answers with the SHA). That call needs only <code>contents: read</code>. Elsewhere it runs <code>git ls-remote origin refs/heads/&lt;branch&gt;</code>. If the branch has moved on, nothing is sent and the newer build sends the live spec.</li>
</ol>
<p>This is the one line printed when a build is behind:</p>
<pre><code class="language-text">graphwake: this build is not the tip of main (a newer build sends the live spec), nothing sent
</code></pre>
<p>We made the check best effort on purpose. If it can't run, for example because the image has no git or the job has no credentials, the spec is sent anyway. git runs with <code>GIT_TERMINAL_PROMPT=0</code> and a 15-second timeout, so a missing credential can't hang the job waiting for a password. The GitLab template's image has no git, so the tip check is skipped there.</p>
<p>The server has its own check as well. Every send carries the commit SHA and commit time, so Graphwake can ignore a spec from an older commit than the live one.</p>
<h2>Which pull request did this merge come from?</h2>
<p>On a live build the agent also tries to find out whether the commit came from a merged pull request, because Graphwake handles the two cases differently. "Pull request #42 merged" settles the draft Graphwake already holds for #42. A plain live spec is a new change with no pull request behind it.</p>
<p>On GitHub the agent asks the API with the same job token: <code>GET /repos/{repo}/commits/{sha}/pulls</code>, keeping the pull request that has <code>merged_at</code> set and whose base is the branch being built. That works for merge commits, squash merges and rebase merges. This call needs <code>pull-requests: read</code>. If your workflow sets <code>permissions:</code>, list it there next to <code>contents: read</code>.</p>
<p>Everywhere else, or when the API doesn't answer, it reads the commit message and looks for each platform's default merge message. From <code>internal/ci/mergemsg.go</code>:</p>
<pre><code class="language-go">githubMerge  = `\AMerge pull request #(\d+)\b`
githubSquash = `\A[^\n]*\(#(\d+)\)[ \t]*(?:\r?\n|\z)`
gitlabMerge  = `See merge request [^\s!]*!(\d+)\b`
azureMerge   = `\AMerged PR (\d+):`
bitbucketPR  = `\(pull request #(\d+)\)`
</code></pre>
<p>Jenkins and unknown CIs try all of them, because they can sit in front of any git host.</p>
<p>That covers <code>Merge pull request #12</code>, a squash title ending in <code>(#12)</code>, GitLab's <code>See merge request group/project!12</code>, Azure's <code>Merged PR 12:</code> and Bitbucket's <code>(pull request #12)</code>. Teams that write custom merge messages still get the live spec. They just see the merge and the live change separately.</p>
<h2>Keeping the ingest key out of logs</h2>
<p>CI logs are widely readable and kept for a long time. The agent prints exactly one line per run, and that line goes through a single reporter that knows the secrets. From <code>internal/report/report.go</code>, where every value registered with <code>Hide</code> is replaced before anything is printed:</p>
<pre><code class="language-go">func (r *Reporter) clean(s string) string {
    for _, secret := range r.secrets {
        s = strings.ReplaceAll(s, secret, "***")
    }
    s = strings.Join(strings.Fields(s), " ") // one line, no control characters
    ...
}
</code></pre>
<p>A few more rules around that reporter:</p>
<ul>
<li>Secrets are registered before flags are parsed: <code>GRAPHWAKE_KEY</code>, the GitHub token, and any value given after <code>--key</code>. A typo in the flags can't echo the key back.</li>
<li>Flag errors never show values. We discard the parser's own output. An unknown flag is reported as "unknown flag" without its name, because a key pasted as <code>-dsk_...</code> would otherwise end up in the log.</li>
<li>The GitHub Action masks the key too (<code>::add-mask::</code>), even when it doesn't come from a secret.</li>
<li><code>--key</code> works, but command-line arguments show up in process lists. The README recommends <code>GRAPHWAKE_KEY</code> from a CI secret.</li>
<li>Pull requests from forks get no secrets. The step prints a warning and sends nothing.</li>
</ul>
<p>On Graphwake's side, an ingest key belongs to one service and is stored only as a SHA-256 hash. A repository with several services runs the step once per service, each with its own spec and key.</p>
<h2>What happens after the spec arrives</h2>
<ol>
<li><strong>Pull request build: a draft.</strong> The spec is stored as a draft for the pull request. Graphwake finds the breaking changes and scores the change set from 0 to 100 with <a href="https://graphwake.com/impact-score">public rules</a>.</li>
<li><strong>Consumer teams hear before the merge.</strong> With "Alerts on open pull requests" turned on in settings, the teams subscribed to the changed endpoints are told in Slack, Microsoft Teams or email. The alert names the endpoint, what changed, who changed it and which pull request did it.</li>
<li><strong>Merge: the spec goes live.</strong> The default-branch build sends the spec as live. If the commit came from a pull request, that pull request's draft notifications are settled as "merged" and the real notifications go out.</li>
<li><strong>Closed without merging: the draft goes away.</strong> Only GitHub reports a close. The agent sends it and the drafts are discarded. On the other platforms, drafts of a pull request that never merges close on their own after 14 days.</li>
</ol>
<p>So the teams that call a changed endpoint hear about it while it's still a pull request, not after a deploy, and only those teams: consumer teams subscribe to the endpoints they use, and nothing goes to a company-wide channel. The setup is the same two settings, the spec and the key, on all five CI systems, and the agent sends the OpenAPI file and the build's context, nothing else.</p>
<h2>Build it yourself</h2>
<p>The logic above lives in one Go module with no dependencies outside the standard library. The wrappers are a few lines of shell, YAML and Node, and each platform's environment has its own tests. It runs inside your pipeline with one of your secrets, so you shouldn't have to take its behaviour on trust:</p>
<pre><code class="language-bash">go vet ./... &amp;&amp; go test ./...
go build -o graphwake ./cmd/graphwake
./graphwake version
</code></pre>
<p>On any CI we don't detect, pass <code>--pr</code> on pull request builds and <code>--live</code> on release builds.</p>
<hr />
<p><em>Originally published on <a href="https://graphwake.com/guides/graphwake-ci-agent">graphwake.com</a>.</em></p>
<p>How many CI systems does your company run side by side? We went with environment sniffing plus a fallback flag; curious whether anyone has found a cleaner way to classify builds across platforms.</p>
]]></content:encoded></item><item><title><![CDATA[How we score the impact of an API change from 0 to 100]]></title><description><![CDATA[Four public rules with caps, three bands and a worked example: the arithmetic behind Graphwake's impact score, so you can check it.]]></description><link>https://graphwake.hashnode.dev/how-we-score-the-impact-of-an-api-change-from-0-to-100</link><guid isPermaLink="true">https://graphwake.hashnode.dev/how-we-score-the-impact-of-an-api-change-from-0-to-100</guid><category><![CDATA[APIs]]></category><category><![CDATA[OpenApi]]></category><category><![CDATA[Devops]]></category><category><![CDATA[code review]]></category><dc:creator><![CDATA[Graphwake]]></dc:creator><pubDate>Sat, 14 Nov 2026 19:03:01 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6af80cab97fffc20d7ebbf36/435c3741-a244-4304-aaee-9be5641aa44e.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>A risk number on a pull request is only useful if the reviewer can tell where it came from. When we added an impact score to <a href="https://graphwake.com/?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=impact-score">Graphwake</a>, we wanted one you could recompute on a napkin. So every change set gets a score from 0 to 100 made of four parts, each with a cap, and the rules are fixed and shown next to every score.</p>
<p>Here are the rules, in full.</p>
<h2>The four parts</h2>
<table>
<thead>
<tr>
<th>Part</th>
<th>Counts</th>
<th>Points each</th>
<th>Cap</th>
</tr>
</thead>
<tbody><tr>
<td>Breaking changes</td>
<td>Breaking changes in the change set</td>
<td>10</td>
<td>30</td>
</tr>
<tr>
<td>Affected teams</td>
<td>Consumer teams subscribed to an endpoint with a breaking change</td>
<td>12</td>
<td>36</td>
</tr>
<tr>
<td>People to notify</td>
<td>Distinct people in those teams</td>
<td>1</td>
<td>14</td>
</tr>
<tr>
<td>Blind spots</td>
<td>Removed endpoints, and breaking changes on endpoints nobody subscribes to</td>
<td>10</td>
<td>20</td>
</tr>
</tbody></table>
<p>Each part scores the lower of two numbers: its count times its points, or its cap. The caps add up to 100.</p>
<h2>The bands</h2>
<table>
<thead>
<tr>
<th>Score</th>
<th>Level</th>
</tr>
</thead>
<tbody><tr>
<td>0 to 29</td>
<td>Low</td>
</tr>
<tr>
<td>30 to 59</td>
<td>Medium</td>
</tr>
<tr>
<td>60 to 100</td>
<td>High</td>
</tr>
</tbody></table>
<p>A change set with no breaking change scores 0. Nothing breaks, so there's nothing to weigh.</p>
<p><img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/aqoylgj4ma8ya0tq6czl.png" alt="Example impact score card for pull request #1284 on orders-api, not merged yet: 79 out of 100, High. 3 breaking changes give 30 points, 3 consumer teams give 36, 13 people notified give 13, no blind spots give 0." /></p>
<p><em>Example: a different pull request (#1284) as it shows in Graphwake, each part with its arithmetic.</em></p>
<h2>A worked example</h2>
<p>Take a hypothetical change set that removes one response field and adds a required parameter on another endpoint.</p>
<ul>
<li>That's 2 breaking changes: 20 points.</li>
<li>Three teams subscribe to the affected endpoints. 3 times 12 is 36, which is exactly the cap: 36 points.</li>
<li>Together those teams have 9 people: 9 points.</li>
<li>One of the two endpoints has no subscribers. That's 1 blind spot: 10 points.</li>
</ul>
<p>20 + 36 + 9 + 10 = 75, which lands in High.</p>
<h2>Why a blind spot counts as risk</h2>
<p>A removed endpoint, or a breaking change on an endpoint with no subscribers, is a change whose reach is unknown. We could have scored it as zero, since nobody is subscribed and nobody gets alerted. That would reward exactly the situation you should worry about. Counting it pushes the team to find out who is affected before merging.</p>
<p>To help with that, Discovery suggests likely teams, with the reason for each suggestion.</p>
<h2>What the score is for</h2>
<p>It helps reviewers decide where to look first and who to talk to. Low means few or no teams are affected. High means several breaking changes, several teams, or blind spots, and the blind spots are where someone should go find out who calls those endpoints.</p>
<p>With the CI step, reviewers see the score while the change is still a pull request, before the merge.</p>
<p>Each score is shown with its four parts and the arithmetic, so "why did this get 75?" always has an answer on screen.</p>
<hr />
<p><em>Originally published on <a href="https://graphwake.com/impact-score">graphwake.com</a>.</em></p>
<p>Do you put any kind of risk number on API changes today? If you do, can your reviewers explain how it was computed?</p>
]]></content:encoded></item><item><title><![CDATA[Stop announcing API changes in a shared Slack channel]]></title><description><![CDATA[An #api-changes channel reaches everyone and gets read by almost no one. Send each change to the teams that call the endpoint instead.]]></description><link>https://graphwake.hashnode.dev/stop-announcing-api-changes-in-a-shared-slack-channel</link><guid isPermaLink="true">https://graphwake.hashnode.dev/stop-announcing-api-changes-in-a-shared-slack-channel</guid><category><![CDATA[APIs]]></category><category><![CDATA[Microservices]]></category><category><![CDATA[slack]]></category><category><![CDATA[Devops]]></category><dc:creator><![CDATA[Graphwake]]></dc:creator><pubDate>Sat, 14 Nov 2026 19:02:49 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6af80cab97fffc20d7ebbf36/c5d46f5a-782b-4fd3-9173-861e61e37245.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Many companies with more than a handful of services end up with a channel like <code>#api-changes</code>. Producer teams post there before a breaking change. It's the responsible thing to do, and it's what most of us were taught: announce early, announce widely.</p>
<p>Widely is the problem. An announcement is written for everyone, so it gets read as if it were for no one in particular. What you actually need is to reach the few teams whose code breaks, and a post written for everyone is a poor way to find them.</p>
<h2>Why the announcement channel feels right</h2>
<p>It's cheap: one post, no list to keep, and you don't need to know who your consumers are. It's visible, since anyone can scroll back and see what changed. And it covers you. After the incident you can point at the post and say "we announced it."</p>
<p>That last one is the honest reason. An announcement channel protects the sender. It does very little for the reader.</p>
<h2>Why it stops working</h2>
<p>Here's the shape of it, with made-up numbers (an example, not a measurement):</p>
<pre><code class="language-text">12 teams post in #api-changes
2 posts each a week  → 24 posts
you call endpoints of 2 teams
some of their posts touch them

→ 1 or 2 of 24 posts are yours
</code></pre>
<p>When 22 of 24 posts aren't about you, ignoring the channel is the rational choice. People skim, then they mute. The one post that was about you goes with the rest.</p>
<p>Every post also asks every reader to do the matching: do we call this endpoint? Most people can't answer that from memory, so they guess.</p>
<p>Often the team that needed the post isn't even in the channel. A new team, a team in another office, the contractor team that built the mobile app. Nobody added them.</p>
<p>Posted isn't received, either. The sender can't tell who read it, so "we announced it" and "they knew" turn into the same sentence in the postmortem.</p>
<p>Mentions make all of this worse. <code>@here</code> and <code>@channel</code> wake everyone up to tell a few. Slack itself <a href="https://slack.com/help/articles/202009646-Notify-a-channel-or-workspace">asks for confirmation</a> before one goes to a channel of six or more people, and <a href="https://slack.com/help/articles/360004635551-Manage-channel-posting-permissions-">lets owners restrict</a> who may use them.</p>
<h2>Telling is the sender's job</h2>
<p>An announcement moves the work to the reader, who has to read everything to find what's theirs. A targeted message keeps the work with the sender, who knows what changed, and with a list of who calls each endpoint, which nobody can keep in their head.</p>
<p>That list is the hard part, and it's why the shared channel survives. If you don't know who calls <code>GET /orders/{id}</code>, posting to everyone is the only way to reach them. Swapping in a better channel doesn't change that; you need the list of who calls what.</p>
<h2>What to do instead</h2>
<table>
<thead>
<tr>
<th>Instead of</th>
<th>Do this</th>
</tr>
</thead>
<tbody><tr>
<td>One channel for every change</td>
<td>A message in the channel each consuming team already reads</td>
</tr>
<tr>
<td>Every team reads every post</td>
<td>Each team gets only the endpoints it calls</td>
</tr>
<tr>
<td>The sender guesses who is affected</td>
<td>Consumer teams declare the endpoints they call, once</td>
</tr>
<tr>
<td><code>@here</code> to get attention</td>
<td>A small audience that reads because every message is theirs</td>
</tr>
<tr>
<td>Posting after the merge</td>
<td>Telling them while the change is still a pull request</td>
</tr>
<tr>
<td>A post per update</td>
<td>One message per change, updated as it moves</td>
</tr>
</tbody></table>
<p>What that message should say, and how to wire it into Slack and Microsoft Teams, is covered in <a href="https://graphwake.com/guides/api-change-notifications-slack-teams">API change notifications in Slack and Microsoft Teams</a>.</p>
<h2>How we built Graphwake around this</h2>
<p><a href="https://graphwake.com/?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=stop-announcing">Graphwake</a> is built on this opinion. Consumer teams subscribe to the endpoints they call, and a breaking change in your OpenAPI spec reaches only those teams, in the Slack, Microsoft Teams or email channel each one picked. Everyone else hears nothing, on purpose, apart from a one-time note to a team Discovery strongly suspects is a caller.</p>
<p><img src="https://dev-to-uploads.s3.us-east-2.amazonaws.com/uploads/articles/ech2uqqrns07r7y3vbab.png" alt="Example Graphwake alert in Slack for pull request #1284 on orders-api: GET /orders/{id} lost the total field and currency became required. Only checkout, mobile-app and reporting are told; search is not subscribed and stays quiet." /></p>
<p><em>Example: one breaking change, three subscribed teams told, one team left alone.</em></p>
<p>A few details that matter in practice:</p>
<ul>
<li>The subscriber list stays current because the readers keep it. Each consumer team marks the endpoints it calls.</li>
<li>Each team picks one Slack or Microsoft Teams channel, or email. We never add <code>@here</code> or <code>@channel</code>.</li>
<li>With the CI step, the alert goes out while the change is still a pull request, and the same message is updated when it merges or closes.</li>
<li>An endpoint nobody subscribes to counts as a blind spot in the <a href="https://graphwake.com/impact-score">impact score</a>. Discovery suggests teams that may use it, and a team suggested at 60% or more gets a one-time "This may affect you" note when a breaking change goes live.</li>
</ul>
<h2>When a shared channel alone is enough</h2>
<p>To be fair to the channel: in a small company where every engineer touches every service and there are a few changes a month, one channel already is the targeted channel. As a record, a feed of every change that nobody is expected to act on is a useful log. And for public APIs, customers outside your company aren't in your Slack anyway; a changelog and an email list reach them.</p>
<p>The trouble starts when the channel becomes the way consumers are supposed to find out about breaking changes.</p>
<hr />
<p><em>Originally published on <a href="https://graphwake.com/guides/stop-announcing-api-changes-in-slack">graphwake.com</a>.</em></p>
<p>Does your company have an <code>#api-changes</code> channel? Do people actually read it, or has it quietly become a log?</p>
]]></content:encoded></item><item><title><![CDATA[Catch OpenAPI breaking changes in pull requests with the oasdiff GitHub Action]]></title><description><![CDATA[Set up the free oasdiff GitHub Action line by line, read its output, tune it, and decide what to do with each breaking change it finds.]]></description><link>https://graphwake.hashnode.dev/catch-openapi-breaking-changes-in-pull-requests-with-the-oasdiff-github-action</link><guid isPermaLink="true">https://graphwake.hashnode.dev/catch-openapi-breaking-changes-in-pull-requests-with-the-oasdiff-github-action</guid><category><![CDATA[APIs]]></category><category><![CDATA[OpenApi]]></category><category><![CDATA[github-actions]]></category><category><![CDATA[Devops]]></category><dc:creator><![CDATA[Graphwake]]></dc:creator><pubDate>Sat, 14 Nov 2026 19:02:17 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6af80cab97fffc20d7ebbf36/6a43082b-d482-4e21-98e4-abe86ae53d77.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>If your API is described by an OpenAPI spec, you can catch most breaking changes before they merge, for free, with one workflow file. <a href="https://github.com/oasdiff/oasdiff">oasdiff</a> compares the spec in a pull request with the spec on the base branch and tells you what would break existing clients.</p>
<p>This post walks through the setup line by line, shows what the output looks like on a real change, how to tune it, and, the part most guides skip, what to actually do with each finding.</p>
<h2>What you need</h2>
<ul>
<li>A GitHub repository with an OpenAPI 3 spec committed to it. The examples use <code>openapi.yaml</code> in the repo root; change the path to yours.</li>
<li>If your spec is generated from code, a build step that writes it to a file first. The action compares files, not code.</li>
<li>That's it. The free actions run the oasdiff CLI inside your runner, with no account and no token.</li>
</ul>
<p>The <code>oasdiff/oasdiff-action</code> repository holds several actions, one per folder:</p>
<table>
<thead>
<tr>
<th>Action</th>
<th>What it does</th>
<th>Fails the job</th>
</tr>
</thead>
<tbody><tr>
<td><code>oasdiff/oasdiff-action/breaking@v0</code></td>
<td>Breaking changes (errors and warnings) as PR annotations</td>
<td>Only with <code>fail-on</code></td>
</tr>
<tr>
<td><code>oasdiff/oasdiff-action/changelog@v0</code></td>
<td>Every change that can affect clients, breaking or not</td>
<td>No</td>
</tr>
<tr>
<td><code>oasdiff/oasdiff-action/diff@v0</code></td>
<td>The full diff, including description-only edits</td>
<td>Only with <code>fail-on-diff: true</code></td>
</tr>
<tr>
<td><code>oasdiff/oasdiff-action/validate@v0</code></td>
<td>Checks one spec against the OpenAPI rules</td>
<td>On errors</td>
</tr>
</tbody></table>
<p>This post uses <code>breaking</code>. Note that <code>@v0</code> is a moving tag that follows every stable v0.x.y release; pin a release tag if you want to choose when to upgrade.</p>
<h2>Step 1: add the workflow</h2>
<p>Create <code>.github/workflows/oasdiff.yaml</code>:</p>
<pre><code class="language-yaml">name: oasdiff
on:
  pull_request:
    branches: [ "main" ]
permissions:
  contents: read
  pull-requests: write
jobs:
  breaking-changes:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - run: git fetch --depth=1 origin ${{ github.base_ref }}
      - uses: oasdiff/oasdiff-action/breaking@v0
        with:
          base: 'origin/${{ github.base_ref }}:openapi.yaml'
          revision: 'HEAD:openapi.yaml'
          fail-on: WARN
          github-token: ${{ github.token }}
</code></pre>
<p>What each part does:</p>
<ul>
<li><strong><code>on: pull_request</code></strong> runs on every PR into <code>main</code>. To skip PRs that don't touch the spec, add <code>paths: [ "openapi.yaml" ]</code> under <code>pull_request</code>.</li>
<li><strong><code>permissions</code></strong>: <code>contents: read</code> to check out the code; <code>pull-requests: write</code> lets the action post the review link as a PR comment. Without it, the link goes to the job summary instead.</li>
<li><strong><code>git fetch --depth=1</code></strong> fetches only the base branch so the action can read the old spec from git. Full history isn't needed.</li>
<li><strong><code>base</code> and <code>revision</code></strong> use oasdiff's <code>&lt;git ref&gt;:&lt;path&gt;</code> syntax: the spec on the base branch and the spec in the commit being tested. Each also accepts a plain file path or an http(s) URL.</li>
<li><strong><code>fail-on</code></strong> decides whether the job fails (step 2). Leave it out and the action only reports.</li>
<li><strong><code>github-token</code></strong> is used to post the review link. On PRs from forks the token is read-only, so the link falls back to the job summary.</li>
</ul>
<h2>Step 2: decide what fails the job</h2>
<p>oasdiff sorts each breaking change into a level:</p>
<ul>
<li><strong>error</strong>: a client that followed the old contract can stop working</li>
<li><strong>warning</strong>: it might; a human should look</li>
<li><strong>info</strong>: shown by <code>changelog</code>, not by <code>breaking</code></li>
</ul>
<p><code>fail-on</code> picks the lowest level that fails the job:</p>
<table>
<thead>
<tr>
<th><code>fail-on</code></th>
<th>Job fails when</th>
<th>Use it when</th>
</tr>
</thead>
<tbody><tr>
<td>not set (default)</td>
<td>never because of a change</td>
<td>you're trying it out, or the check should inform and not stop anyone</td>
</tr>
<tr>
<td><code>ERR</code></td>
<td>at least one error</td>
<td>you want a hard stop on changes oasdiff is sure about</td>
</tr>
<tr>
<td><code>WARN</code></td>
<td>at least one error or warning</td>
<td>every possible break needs a decision before merge</td>
</tr>
</tbody></table>
<blockquote>
<p><strong>One failure <code>fail-on</code> doesn't control:</strong> if oasdiff can't run at all (a spec that doesn't parse, a file missing on one side, an external <code>$ref</code> it isn't allowed to load) the step fails regardless. If the check must never fail the job, also add <code>continue-on-error: true</code> to the step.</p>
</blockquote>
<h2>Step 3: open a PR and read the output</h2>
<p>Here's a sample change: a PR that makes a new query parameter <code>region</code> required on <code>GET /orders</code>, removes the old <code>limit</code> parameter, and drops the required <code>status</code> field from the <code>GET /orders/{id}</code> response.</p>
<h3>Annotations on the PR</h3>
<p>The action emits GitHub annotations, titled with the oasdiff rule ID. They show up on the <em>Files changed</em> tab and in the run summary:</p>
<pre><code class="language-text">::error title=new-required-request-parameter,file=openapi.yaml,line=24,col=12::in API GET /orders added the new required `query` request parameter `region`
::error title=response-required-property-removed,file=openapi.yaml::in API GET /orders/{id} removed the required property `status` from the response with the `200` status
::warning title=request-parameter-removed,file=openapi.yaml::in API GET /orders deleted the `query` request parameter `limit`
</code></pre>
<p>One gotcha: an <em>added</em> parameter has a line in the new file, so its annotation sits on that line. A <em>removed</em> field has no line in the new file, so GitHub shows it at the top of the file. Read the run summary, not just the diff view.</p>
<h3>The full report</h3>
<p>The step output <code>breaking</code> only holds the first line (the count). For the whole report, set <code>output-to-file: oasdiff-breaking.txt</code> and upload it as an artifact, or run the CLI locally:</p>
<pre><code class="language-text">3 changes: 2 error, 1 warning, 0 info
error	[new-required-request-parameter] at openapi.yaml
	in API GET /orders
		added the new required `query` request parameter `region`

error	[response-required-property-removed] at openapi.yaml
	in API GET /orders/{id}
		removed the required property `status` from the response with the `200` status

warning	[request-parameter-removed] at openapi.yaml
	in API GET /orders
		deleted the `query` request parameter `limit`
</code></pre>
<h3>The side-by-side review link</h3>
<p>When there are breaking changes, the action uploads both specs to oasdiff.com (encrypted in CI first; the key stays in the link) and posts a side-by-side review link that expires after 7 days.</p>
<p>If your specs must not leave your runners, set <code>review: false</code>. Nothing is uploaded, and detection and annotations work the same.</p>
<h2>Step 4: tune it</h2>
<p>Keep settings in a <code>.oasdiff.yaml</code> file in the repo root instead of in every workflow. Inputs in the workflow's <code>with:</code> win over the file.</p>
<pre><code class="language-yaml"># .oasdiff.yaml (repository root)
fail-on: ERR
err-ignore: ./oasdiff-err-ignore.txt
</code></pre>
<p>When a breaking change is intended and agreed, an ignore file stops it from failing every later run. Each line is the method and path, then the change text as oasdiff printed it:</p>
<pre><code class="language-text"># oasdiff-err-ignore.txt: one accepted breaking change per line, with the PR that accepted it
# PR #1290: region is required from v2; the two known callers already send it
GET /orders added the new required `query` request parameter `region`
</code></pre>
<p>Two more inputs worth knowing:</p>
<ul>
<li><strong><code>allow-external-refs</code></strong> is <code>false</code> in the action (the CLI default is <code>true</code>), so an untrusted PR can't make the runner fetch URLs. Set it to <code>true</code> if your spec is split across files or references external URLs.</li>
<li><strong><code>composed: true</code></strong> compares many spec files at once. The side-by-side review isn't available in that mode.</li>
</ul>
<h2>Step 5: what to do with the output</h2>
<p>A red job tells the author something broke. It doesn't tell anyone what to do next. For each finding:</p>
<ul>
<li><strong>Error: look for an additive change first.</strong> Many errors have a non-breaking version: a new optional parameter instead of a required one, a new field next to the old one, a new endpoint or version. Ask for that first.</li>
<li><strong>Error you keep: name who calls the endpoint.</strong> If it has to break, list the teams that call the endpoint and tell them before the merge, with what changes and when. Then add the line to the ignore file <em>in the same PR</em>, so the reason lives in the history.</li>
<li><strong>Warning: decide and write it down.</strong> <code>request-parameter-removed</code> is breaking only if the server rejects input it doesn't know. Check, and put the answer in the PR description.</li>
<li><strong>No breaking changes: still read the changelog.</strong> New endpoints and fields don't break anyone, but consumers may want them. The <code>changelog</code> action lists them.</li>
</ul>
<h2>The step the action can't do</h2>
<p>The action answers <em>"what changed?"</em>. The hardest line in step 5, <em>"list the teams that call the endpoint"</em>, isn't in the spec. If every caller lives in the same repo, the PR reviewers are the right people and you're done. If other teams call your API from other repos, someone has to know who they are.</p>
<p>That's the gap we built <a href="https://graphwake.com/?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=oasdiff-action">Graphwake</a> for: consumer teams subscribe to the endpoints they call, and when a PR changes one in a breaking way, only those teams get an alert in Slack, Microsoft Teams or email. It runs as its own CI step, next to the oasdiff Action, which keeps its own <code>fail-on</code>.</p>
<hr />
<p><em>Originally published on <a href="https://graphwake.com/guides/oasdiff-github-action">graphwake.com</a>. Graphwake is not affiliated with the oasdiff project; oasdiff and its name belong to its authors.</em></p>
<p>Do you gate merges on breaking changes, or only report them? Curious where teams draw that line.</p>
]]></content:encoded></item><item><title><![CDATA[How to tell API consumers about a breaking change (when you don't know who they are)]]></title><description><![CDATA[Why email, changelogs and #general fail between teams in one company, what to do instead, and a copy-paste notice template.]]></description><link>https://graphwake.hashnode.dev/how-to-tell-api-consumers-about-a-breaking-change-when-you-don-t-know-who-they-are</link><guid isPermaLink="true">https://graphwake.hashnode.dev/how-to-tell-api-consumers-about-a-breaking-change-when-you-don-t-know-who-they-are</guid><category><![CDATA[APIs]]></category><category><![CDATA[Microservices]]></category><category><![CDATA[Devops]]></category><category><![CDATA[software architecture]]></category><dc:creator><![CDATA[Graphwake]]></dc:creator><pubDate>Sat, 14 Nov 2026 19:01:45 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6af80cab97fffc20d7ebbf36/4911fd31-d0ed-45b3-90ac-49868abb841a.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Most advice on notifying API consumers is written for public APIs. Publish a changelog, email your developer list, add a <code>Deprecation</code> header, done.</p>
<p>Inside a company the problem is different. You usually know <em>what</em> you are about to change. What you often don't know is <strong>who calls it</strong>. The service has been around for three years, two teams have been reorganized since, and somebody's batch job reads a field you are about to rename.</p>
<p>This post is a short playbook for that situation: find the consumers first, tell only them, give a date and an action, then follow up.</p>
<h2>Why the usual channels fail inside a company</h2>
<ul>
<li><strong>Email to everyone</strong> gets filtered out within a week. People learn that most of those mails are not about them.</li>
<li><strong>A changelog</strong> only helps the readers who go and look at it. Nobody checks the changelog of an internal service before every deploy.</li>
<li><strong>A shared channel</strong> (<code>#api-changes</code>, <code>#engineering</code>, <code>#general</code>) reaches lots of people who don't care and still misses the one team who does.</li>
<li><strong>Deprecation headers</strong> only help clients that log and alert on them. Most internal clients don't.</li>
</ul>
<p>All four share the same flaw: they broadcast. Broadcasting works when the audience is small or very motivated. Between 10 or 20 teams it turns into noise, and noise gets ignored.</p>
<h2>Step 1: find the consumers</h2>
<p>Before you write a single message, build the list of teams that actually call the endpoint. There are three common sources, and each one misses something:</p>
<p><strong>Ask.</strong> Post in a channel or ask around. Fast, but you only hear from the people who happen to read it.</p>
<p><strong>Search the code you can see.</strong> If most services live in one org, search for the path or the client method:</p>
<pre><code class="language-bash"># GitHub code search (or your own grep across checked-out repos)
"/customers/" "legacyId" org:your-org
</code></pre>
<p>This misses services in other orgs, generated clients, and anything that builds URLs dynamically.</p>
<p><strong>Check gateway or access logs.</strong> If traffic goes through a gateway, group recent calls by caller:</p>
<pre><code class="language-sql">-- example: access logs in a warehouse table
SELECT client_id, count(*) AS calls
FROM gateway_access_log
WHERE path_template = '/customers/{id}'
  AND ts &gt; now() - interval '30 days'
GROUP BY client_id
ORDER BY calls DESC;
</code></pre>
<p>This shows who called recently, but not who calls once a quarter, and a <code>client_id</code> is not a team.</p>
<p>Combine all three and you will get most of the way. The most reliable source, though, is a list the consumers keep themselves: <strong>each team subscribes to the endpoints it calls</strong>. The producer doesn't have to guess, and the list stays current when teams change.</p>
<h2>Step 2: tell those teams only</h2>
<p>Message the consumer teams directly, in the channel they already read: their team channel, their on-call alias, a ticket in their board. Not a company-wide channel.</p>
<p>A short message sent to five people gets read. A long one sent to five hundred does not.</p>
<h2>Step 3: give a date and an action</h2>
<p>Every notice needs two things:</p>
<ul>
<li><strong>A date.</strong> A change without a date gets ignored, because there is no reason to act now.</li>
<li><strong>An action.</strong> A change without a concrete "do this instead" gets postponed, because figuring it out is extra work.</li>
</ul>
<p>How far ahead? Long enough for the slowest affected team to change and ship. Agree on the date with the teams you found in step 1 rather than announcing it.</p>
<h2>A notice template</h2>
<p>Copy this, fill it in, send it to the teams from step 1:</p>
<pre><code class="language-text">What changes:   GET /customers/{id} no longer returns legacyId
                (removed from the response).
Who is affected: teams that read legacyId: [list the teams].
When:           merged on [date]; deployed on [date].
What to do:     use id instead. Before/after example: [link].
Questions:      reply in this thread or ask [owner].
</code></pre>
<p>Five lines. Each one answers a question the reader would otherwise have to ask you.</p>
<h2>Step 4: confirm and follow up</h2>
<p>Keep a simple list: who was told, who answered, who has moved. When the date arrives, check that every affected team has either shipped the change or asked for more time. Only then deploy.</p>
<p>That list is also useful later. When something does break, the first question in the incident review is "did they know?", and you will have the answer.</p>
<h2>Doing this automatically</h2>
<p>Steps 1 and 2 are the tedious part, and they repeat for every breaking change. We built <a href="https://graphwake.com/?utm_source=hashnode&amp;utm_medium=article&amp;utm_campaign=notify-consumers">Graphwake</a> to do them from your OpenAPI spec:</p>
<ul>
<li>Consumer teams subscribe to the endpoints they call, so the consumer list stays current.</li>
<li>When a pull request introduces a breaking change, the alert goes only to those teams, in Slack, Microsoft Teams or email.</li>
<li>The alert names the service, the endpoint, its owner and what changed.</li>
</ul>
<p>It works from a published spec URL or one CI step. Steps 3 and 4, the date and the conversation, are still yours.</p>
<hr />
<p><em>Originally published on <a href="https://graphwake.com/guides/notify-consumers-of-api-changes">graphwake.com</a>.</em> How does your team handle this today? A channel, a changelog, or just asking around? We'd like to hear what actually works.</p>
]]></content:encoded></item></channel></rss>