There's a short, almost embarrassed thread on V2EX that stuck with me. Someone asks whether their team uses an API spec linter or an oasdiff-style compatibility checker to stop AI from quietly introducing breaking changes. Their own summary: "AI writes more and more code, faster and faster, and we genuinely can't review it all." Then the quiet part out loud: "we've got nothing, feels a bit like a fly-by-night operation."
I've been on that team. Not that exact one, but the shape of it. The model cranks out a new handler, the diff looks plausible, the reviewer is three PRs deep and skims it, and two weeks later a mobile client is throwing 400s because a field that used to be optional is now required. Nobody decided to break the contract. It just happened, one reasonable-looking edit at a time.
Why the laptop check isn't enough anymore
The usual answer is to run a compatibility tool locally or in CI. That's fine as far as it goes. But when AI is the one writing most of the changes, the review bottleneck moves. The tool runs on someone's laptop, prints red or green, and the output evaporates. Nobody can point at a durable record of *what* was flagged, *when*, and *why it was allowed through*. When a breaking change does slip out, the postmortem turns into archaeology.
What I actually wanted was boring: a gate that always runs in the same place, classifies every change, and keeps a record I can open later and hand to anyone. Not a smarter checker. A checker whose decisions don't disappear.
So I rebuilt the idea as a hosted VicroCode project. Three moving parts: a Python routine that diffs an old and new OpenAPI spec and classifies each change, a SQLite table that records every verdict, and an HTML report anyone can open in a browser. Nothing exotic. The value is that it lives somewhere permanent instead of in a terminal scrollback.
The diff routine
The core is a plain comparison between two parsed specs. I don't try to be clever about semantics I can't prove. I classify three well-understood cases and treat everything else as "needs a human."
- A removed endpoint or removed operation is breaking.
- A field that goes from optional to required is breaking.
- A property whose type changes (say `string` to `integer`) is breaking.
Everything additive, like a new optional field or a brand-new endpoint, is safe. I'd rather under-claim and flag ambiguous cases than confidently wave through something risky.
Here's the shape of the classifier. It's small on purpose, and it's the kind of thing you can run Python online without dragging a whole toolchain onto every machine:
import json
def load(path):
with open(path) as f:
return json.load(f)
def classify(old_spec, new_spec):
verdicts = []
old_paths = old_spec.get("paths", {})
new_paths = new_spec.get("paths", {})
# Removed endpoints / operations
for path, methods in old_paths.items():
if path not in new_paths:
verdicts.append((path, "*", "removed_endpoint", "breaking"))
continue
for method in methods:
if method not in new_paths[path]:
verdicts.append((path, method, "removed_operation", "breaking"))
# New endpoints are additive
for path in new_paths:
if path not in old_paths:
verdicts.append((path, "*", "added_endpoint", "safe"))
# Schema-level checks on request bodies
for path, methods in old_paths.items():
if path not in new_paths:
continue
for method, op in methods.items():
new_op = new_paths[path].get(method)
if not new_op:
continue
verdicts.extend(_schema_diff(path, method, op, new_op))
return verdicts
def _request_schema(op):
try:
return op["requestBody"]["content"]["application/json"]["schema"]
except (KeyError, TypeError):
return {}
def _schema_diff(path, method, old_op, new_op):
out = []
old_s = _request_schema(old_op)
new_s = _request_schema(new_op)
old_required = set(old_s.get("required", []))
new_required = set(new_s.get("required", []))
# Optional -> required is breaking
for field in new_required - old_required:
out.append((path, method, f"tightened_required:{field}", "breaking"))
# Type changes are breaking
old_props = old_s.get("properties", {})
new_props = new_s.get("properties", {})
for name, spec in old_props.items():
if name in new_props:
old_t = spec.get("type")
new_t = new_props[name].get("type")
if old_t and new_t and old_t != new_t:
out.append(
(path, method, f"changed_type:{name}:{old_t}->{new_t}", "breaking")
)
return outA couple of honest caveats. This walks `application/json` request bodies and doesn't chase `$ref` indirection or response schemas yet. That's a deliberate first cut, not a finished product. The point is that the rules are readable, so when the gate flags something you can argue with it instead of trusting a black box. Whether this catches every breaking change in your specific API surface is unverified until you run it against your real history.
Logging every verdict to SQLite
The part that actually changes the team's day isn't the diff. It's that the diff's decisions become a record. Every run writes its verdicts into a table, tagged with a run identifier and a timestamp, so the gate stops being a one-shot terminal check and becomes something you can query.
import sqlite3
import datetime
def record(db_path, run_id, verdicts):
conn = sqlite3.connect(db_path)
conn.execute(
"""
CREATE TABLE IF NOT EXISTS verdicts (
id INTEGER PRIMARY KEY,
run_id TEXT NOT NULL,
checked_at TEXT NOT NULL,
path TEXT NOT NULL,
method TEXT NOT NULL,
change TEXT NOT NULL,
verdict TEXT NOT NULL
)
"""
)
now = datetime.datetime.utcnow().isoformat()
conn.executemany(
"INSERT INTO verdicts (run_id, checked_at, path, method, change, verdict) "
"VALUES (?, ?, ?, ?, ?, ?)",
[(run_id, now, p, m, c, v) for (p, m, c, v) in verdicts],
)
conn.commit()
conn.close()Once it's in a table, the questions you couldn't answer before become one-liners. How many breaking changes did we flag last month? Which endpoint keeps getting tightened?
Did anyone override a breaking verdict?
When something does go wrong, you open the SQLite editor, filter to `verdict = 'breaking'` around the incident window, and you have your timeline in seconds instead of grepping CI logs that may already have rotated out.
This is also where the "fly-by-night" feeling from that V2EX post starts to fade. It's not that you suddenly have a mature process. It's that your gate now produces evidence, and evidence is what lets a small team act like a serious one.
An inspectable HTML report
A database is great for you and useless for the frontend engineer who just wants to know if their client is about to break. So the last step renders the latest run as a page. Green means the change set is additive. Red means someone needs to look before this ships.
def render_report(verdicts):
rows = "".join(
f"<tr class='{v}'><td>{p}</td><td>{m}</td>"
f"<td>{c}</td><td>{v}</td></tr>"
for (p, m, c, v) in verdicts
)
breaking = sum(1 for *_, v in verdicts if v == "breaking")
banner = "BLOCKED" if breaking else "OK"
return f"""<!doctype html>
<html><head><meta charset="utf-8"><title>Compatibility gate</title>
<style>
body {{ font-family: system-ui, sans-serif; margin: 2rem; }}
.breaking {{ background: #fde8e8; }}
.safe {{ background: #e8f6ec; }}
table {{ border-collapse: collapse; width: 100%; }}
td, th {{ border: 1px solid #ddd; padding: 6px 10px; text-align: left; }}
</style></head>
<body>
<h1>OpenAPI compatibility gate: {banner}</h1>
<p>{breaking} breaking change(s) in this run.</p>
<table>
<tr><th>Path</th><th>Method</th><th>Change</th><th>Verdict</th></tr>
{rows}
</table>
</body></html>"""Because the report is just a self-contained HTML file, you can run HTML online and share the link with reviewers, mobile engineers, or whoever integrates against the API. No build step, no local server, no "works on my machine." The gate's answer lives at a URL that anyone with the link can open.
What's inside the boundary, and what isn't
Worth being straight about scope. This whole thing fits VicroCode's confirmed capabilities cleanly: Python to run the diff, SQLite to store verdicts, hosted HTML to publish the report. It does not wire itself into your Git provider's CI, gate merges automatically, or block a deploy pipeline on its own. If you want it to fail a build, you'd still be triggering the check from wherever your pipeline lives and reading the verdict back. That integration sits outside what I'm describing here, and I'm not going to pretend the platform does it for you.
The realistic workflow is: your CI or your agent posts the two specs, the hosted routine diffs and records them, and the report URL becomes the thing a human looks at before approving. It's a decision surface, not an enforcement daemon.
The quieter reason this matters
There's a second V2EX thread running alongside the tooling one, about programmers realizing that knowing how to code isn't the same as being able to earn from it independently. Different topic on the surface, same undercurrent. The thing that has value isn't the raw ability to produce code, which AI is happily commoditizing. It's the ability to package a real problem into something someone can rely on and inspect.
A compatibility gate is a small, unglamorous example of exactly that. The market signal is right there in that first post: teams are drowning in AI-generated changes and don't have a durable way to catch the dangerous ones. Whether you build this for your own team or turn it into something you publish and share, the useful move is the same. Take a friction everyone feels, make its decisions permanent and legible, and put the result somewhere people can actually look. That's a lot more defensible than another handler the model could have written anyway.