Guide
7 min read
Handling a breaking change
What happens when the gate blocks your push, and how to resolve it correctly.
This is the most common StateAnchor workflow: you make a change to your API spec, the gate detects a breaking change, and your push is blocked. This guide walks through the entire resolution process end to end.
Step 1 -- The push gets blocked
You remove the DELETE /users/:id endpoint from your spec, commit, and push. The GitHub Action runs the gate check and exits with a non-zero status:
$ git push origin main Enumerating objects: 5, done. Writing objects: 100% (3/3), 312 bytes | 312.00 KiB/s, done. Total 3 (delta 2), reused 0 (delta 0) remote: [StateAnchor] OIDC exchange: success (token expires in 300s) remote: [StateAnchor] Validating stateanchor.yaml... remote: [StateAnchor] Validation: pass (3 endpoints, 2 models) remote: [StateAnchor] Gate check: running... remote: [StateAnchor] Gate result: block (score: 40) remote: Lane: ERR remote: Breaking operations: remote: [40] REMOVED endpoint DELETE /users/:id remote: Block reason: BLOCK -- breaking removal detected remote: [StateAnchor] Sync run: a1b2c3d4-... (blocked) remote: [StateAnchor] x Gate BLOCKED -- exit code 1 To github.com:yourorg/your-repo.git ! [remote rejected] main → main (pre-receive hook declined) error: failed to push some refs to 'github.com:yourorg/your-repo.git'
The push is rejected. No artifacts are generated. No SDK is updated. The gate held the line.
Step 2 -- Understand the finding
Open your project in the StateAnchor dashboard and navigate to the blocked sync run. The run detail shows:
- Gate lane: ERR -- this lane always blocks, regardless of threshold settings.
- Finding: endpoint removed: DELETE /users/:id -- the specific change that triggered the block.
- Score: 40 -- the display-only severity score for this finding. Endpoint removals score 40 (the highest tier in KIND_CONFIG) because they break every consumer that calls this endpoint. Note: the categorical lane, not the score, drives the block decision.
Why is this ERR and not WARN? Because endpoint removal is a universally breaking change. Any client calling DELETE /users/:id will get a 404 after this change ships. The gate classifies these as ERR because there is no threshold at which removing an endpoint is safe -- it is always breaking for at least some consumers.
Other ERR-lane findings include: field_removed, type_changed,auth_changed, enum_value_removed, variant_removed. See the Gate engine docs for the complete lane classification.
Step 3 -- Choose a resolution path
You have three options:
| Option | When to use |
|---|---|
| A: Fix the spec | The change was a mistake or premature. Revert it. |
| B: Version the API | The change is intentional. Keep the old endpoint and add a new versioned one. |
| C: Add an exception | The change is intentional and you accept the breaking impact. Suppress the finding with an explicit exception. |
Step 4A -- Fix the spec (revert)
If the endpoint removal was a mistake, revert the change and push again:
# Revert the last commit $ git revert HEAD --no-edit [main abc1234] Revert "Remove delete endpoint" # Push the revert $ git push origin main remote: [StateAnchor] Gate result: proceed (score: 0) remote: Lane: INFO remote: No breaking operations detected remote: [StateAnchor] ok Done -- result: pass
The gate passes. Artifacts are regenerated. The endpoint is restored.
Step 4B -- Version the API
If you genuinely want to remove the delete endpoint but need to maintain backward compatibility, add a versioned replacement while keeping the old endpoint:
# stateanchor.yaml -- keep the old endpoint, add the new behavior
endpoints:
# Keep this -- removing it is a breaking change
- name: delete_user
method: DELETE
path: /users/{id}
description: "Delete a user (deprecated -- use deactivate_user instead)"
parameters:
- name: id
in: path
type: uuid
required: true
returns: User
# Add the new preferred endpoint
- name: deactivate_user
method: POST
path: /users/{id}/deactivate
description: "Soft-delete a user by setting status to inactive"
parameters:
- name: id
in: path
type: uuid
required: true
returns: UserThis passes the gate because no endpoints are removed -- you only added a new one (which is an INFO-lane change, never blocks). Later, when all consumers have migrated to the new endpoint, you can remove the old one with an exception.
Step 4C -- Add an exception
If the breaking change is intentional and you accept the impact, add a drift exception. Exceptions suppress a specific finding -- not the entire gate. The next push still runs a full gate check on everything else.
Navigate to your project settings and create an exception, or add it via the API:
// POST /api/projects/{projectId}/drift-exceptions
{
"endpoint": "DELETE /users/{id}",
"kind": "endpoint_removed",
"approver": "micah@stateanchor.dev",
"reason": "Intentional removal -- all consumers migrated to POST /users/{id}/deactivate",
"expires_at": "2026-06-29T00:00:00Z" // 90-day maximum TTL
}The exception is scoped to exactly this finding. It requires:
- endpoint -- the specific endpoint the exception covers.
- kind -- the change type (
endpoint_removed,field_removed, etc.). - approver -- the person who approved the breaking change (auditable).
- reason -- why this exception exists (auditable).
- expires_at -- maximum 90-day TTL. When it expires, the finding reactivates.
After creating the exception, push again. The gate will suppress theendpoint_removed finding for DELETE /users/{id} and the push passes.
Step 5 -- Confirm resolution
After resolving the block (via any of the three paths above), your next push should pass:
$ git push origin main remote: [StateAnchor] OIDC exchange: success (token expires in 300s) remote: [StateAnchor] Validating stateanchor.yaml... remote: [StateAnchor] Validation: pass (4 endpoints, 2 models) remote: [StateAnchor] Gate check: running... remote: [StateAnchor] Gate result: proceed (score: 0) remote: Lane: INFO remote: No breaking operations detected remote: [StateAnchor] Generating artifacts... remote: ok typescript-sdk generated (2.4 KB) remote: ok python-sdk generated (1.8 KB) remote: ok mcp-server generated (3.1 KB) remote: [StateAnchor] Artifacts anchored -- sha256:a3f9c2d1e8f4... remote: [StateAnchor] Sync run: e5f6a7b8-... (completed) remote: [StateAnchor] ok Done -- result: pass
Check the project detail page in the dashboard: the sync run shows PASS, all artifacts are generated, and the SHA-256 hashes confirm the new artifacts match the updated spec.
Common mistakes
Adding a blanket exception instead of fixing the issue
Exceptions are designed for intentional breaking changes, not for suppressing inconvenient findings. If you find yourself creating exceptions for every push, the spec is drifting from reality. Fix the spec to match what you actually ship.
Ignoring WARN-lane findings
WARN findings (like required_added) do not block by default when below the threshold, but they accumulate. A spec with 10 unaddressed WARN findings is a spec that is slowly drifting. Address WARN findings proactively -- they represent changes that may break some consumers even if they are not universally breaking.
Not updating consumer SDKs after a versioned change
If you version an endpoint (Step 4B), the old endpoint still exists in the generated SDK. Consumers need to know the old endpoint is deprecated and should migrate. Add adescription field with a deprecation notice -- it appears in the generated SDK docs and in the MCP server tool description.