Before an Agent's .proto Edit Silently Breaks Binary Compatibility: Gating Wire Compat with buf breaking
When an agent edits your .proto files, the text can look perfect while wire compatibility quietly breaks. Here is how to stop field-number reuse and unsafe type changes with a working gate built from buf breaking and reserved.
A week after I asked an agent to "tidy up this unused field," a handful of users on an older build of the app started reporting numbers that arrived slightly wrong. If you define a small API with Protocol Buffers as an indie developer, this is entirely plausible.
Look at the diff and nothing seems wrong in the editor. One field is gone, one field is added. As text, it is a clean cleanup. What broke is not the text but the wire format, the compatibility of the serialized bytes.
Since late June, Antigravity's markdown and diff rendering added syntax highlighting for C++, Python, and Protobuf. Readable proto diffs are a welcome change. But readability is not a compatibility guarantee. Eyeballing a proto diff and deciding "looks fine" means guarding the most fragile part of the system with the vaguest possible method.
This article hands that judgment to a machine instead.
An Agent Fixes .proto Correctly as Text
Large language models are good at editing .proto files in a syntactically correct way. They remove unused fields, normalize naming, rewrite comments. That much is trustworthy.
The catch is that Protocol Buffers compatibility is decided not by source text but by serialized bytes. Each field is identified on the wire by its field number, not its name. So renaming a field preserves compatibility, but changing or reusing a field number makes existing binaries interpret those bytes as something else.
An agent optimizes how the source looks. In that frame, assigning a freed-up number to a new field looks like the "cleaner" change. Yet for clients that have not updated, it is an instruction to read the old field's value as the new field.
I have felt out this boundary firsthand more than once. What I let an agent do is meaning-preserving formatting; anything that changes what a number means must pass a gate, human or machine. Blur that line and your tests pass while production alone breaks.
The Common Ways Wire Compatibility Breaks
Failures tend to collapse into three shapes.
First, field-number reuse. Delete field 3 and later add a differently typed field 3, and the old client's "former 3" gets read by the new server as the "new 3."
Second, type changes. Keep the same number but change the type, and unless the two types share the same wire encoding, values break silently. The varint family (int32/int64/uint32/uint64/bool/enum) is interchangeable, but sint32/sint64 use zigzag encoding and are not compatible with int32.
Third, cardinality changes between optional and repeated, or adding/removing required in proto2. These destabilize parsing of the whole message.
Whether a type change breaks the wire can be judged from this table.
Original type
Compatible change targets
Wire type
int32, uint32, int64, uint64, bool, enum
Interchangeable (mind value ranges)
varint
sint32, sint64
Only with each other (not the varint family)
varint (zigzag)
string, bytes
Interchangeable (only if bytes are valid UTF-8)
length-delimited
fixed32, sfixed32, float
Interchangeable
32-bit
fixed64, sfixed64, double
Interchangeable
64-bit
Recalling this table to audit every diff is not realistic. So we give it to a machine.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦If you want to hand .proto edits to an agent but worry about breaking old clients through field-number reuse or type changes, you can now gate that automatically before every push
✦You can drop a wire-compatibility gate built from buf breaking, reserved declarations, and a field-number reuse check straight into your own CI and git hooks
✦You'll get a concrete boundary for how much to delegate to an agent versus verify by machine, applied to the fragile territory of schema changes
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Making buf breaking the Gatekeeper for Wire Compatibility
Buf's buf breaking compares the changed schema against the previous one and detects breaking changes. Narrow the category to WIRE and you allow source cleanup and renames while rejecting only changes that break byte-level compatibility.
Choosing WIRE is the point. The FILE category treats even field renames as breaking, which is too strict when you only want to protect wire compatibility. To leave the agent room to reformat while defending only semantic compatibility, WIRE is exactly right.
Use the main branch as the comparison baseline.
# compare the changed proto against main's protobuf breaking --against '.git#branch=main,subdir=.'
On a breaking change, buf prints the file, line, and violated rule and exits non-zero. A type change on a numbered field surfaces a FIELD_SAME_TYPE-style violation, for example, and a change to what a number means surfaces the specific rule.
Deleting a Field Comes Paired With reserved
buf breaking is powerful, but it only compares against the "previous state." Free a number now and reuse it in a later change, and each step alone can look non-breaking. So we seal the number at deletion time.
When asking an agent to delete, instruct it to replace with reserved rather than remove. Here is Before and After.
// Before: the risky shape the agent "tidied" intomessage UserProfile { string display_name = 1; string avatar_url = 2; // deleted legacy_score(3) and put a new nickname at 3 string nickname = 3;}
// After: reserve the number and old name so it is never reusedmessage UserProfile { string display_name = 1; string avatar_url = 2; string nickname = 4; // use the next free number reserved 3; // seal the number legacy_score used reserved "legacy_score"; // reserve the old field name too}
Once reserved is declared, the moment an agent later tries to use number 3, compilation itself fails. It stops right there, without waiting for human review. In indie development, this "stops mechanically at an early stage" is what I appreciate most. A pipeline that ran overnight and caused trouble by morning is far worse than one that turns red on the spot.
Stopping It in Front of a Human With a pre-push Hook
Catching it only in CI means a broken change already reached the remote once. Stopping it at the moment of the push gives faster feedback. Layering agent operation and a manual hook in two stages follows the same idea as the two-stage gate design of Antigravity CLI Hooks and git pre-push.
Here is the script for .git/hooks/pre-push.
#!/usr/bin/env bash# .git/hooks/pre-push — verify proto wire compatibility before pushingset -euo pipefail# pass immediately if no proto changes (do not slow down unrelated pushes)if git diff --quiet origin/main -- 'proto/**/*.proto'; then echo "no proto changes — skipping wire compat check" exit 0fiecho "proto changes detected — running buf breaking"if ! buf breaking --against '.git#branch=main,subdir=.'; then echo "" echo "Blocked: found a change that breaks wire compatibility." echo " Check for field-number reuse or type changes." echo " Delete fields by replacing them with 'reserved'." exit 1fiecho "wire compatibility OK"
The aim of this hook is not to slow the agent down. Pushes that never touched proto pass through, and the gate only rises when proto was touched. You pay the cost only for the one thing worth protecting.
Kill Field-Number Reuse by Machine
buf and reserved cover most of it, but explicitly confirming per commit whether "a deleted number is registered in reserved" adds confidence. Here is a small script that directly detects the case where an agent forgot the reserved declaration.
# check_reserved.py — confirm deleted numbers are registered in reservedimport reimport subprocessimport sysdef field_numbers(proto_text: str) -> set[int]: # collect numbers from field definitions like "name = 12;" nums = set() for line in proto_text.splitlines(): m = re.search(r"=\s*(\d+)\s*;", line) if m and "reserved" not in line: nums.add(int(m.group(1))) return numsdef reserved_numbers(proto_text: str) -> set[int]: nums = set() for m in re.finditer(r"reserved\s+([0-9,\s]+);", proto_text): for token in m.group(1).split(","): token = token.strip() if token.isdigit(): nums.add(int(token)) return numsdef read_main(path: str) -> str: return subprocess.run( ["git", "show", f"origin/main:{path}"], capture_output=True, text=True, ).stdoutdef main(paths: list[str]) -> int: failed = False for path in paths: old = read_main(path) if not old: continue # new file, nothing to compare with open(path, encoding="utf-8") as f: new = f.read() removed = field_numbers(old) - field_numbers(new) unreserved = removed - reserved_numbers(new) if unreserved: print(f"Blocked {path}: deleted numbers {sorted(unreserved)} are not reserved") failed = True return 1 if failed else 0if __name__ == "__main__": sys.exit(main(sys.argv[1:]))
It is a deliberately blunt regex implementation and does not strictly distinguish nested messages. Even so, it catches the most common gap, "deleted a number but forgot to seal it," with no external dependencies. buf breaking handles structural compatibility while this script handles number hygiene, a double layer.
How Much to Delegate to the Agent
None of this machinery is about distrusting the agent. The opposite: it is the foundation that lets me delegate with peace of mind.
My boundary is this. Adding fields, tidying comments, unifying naming, reorganizing package structure, I hand over directly. None of these break wire compatibility. On the other hand, deleting fields, changing numbers, changing types, all pass through the three layers of reserved, buf breaking, and the number check. Even if the agent proposes it, it cannot be pushed unless it clears the gate.
Since adopting this design, my hesitation around proto changes has dropped. Because I know the machine stops the breaking operations, I can hand off formatting without a second thought. Separating what to delegate from what to verify is, in the end, what lets me use the agent more deeply. On the question of how to hand over write access, it also connects to the production design of Permission Boundary for safely granting write access to an Antigravity Agent.
Wrapping Up
As a next step, drop a buf.yaml into your own proto repository, set breaking.use to WIRE, and run buf breaking --against '.git#branch=main,subdir=.' once by hand. You will immediately see whether the diff between your current main and working branch is safe from the wire's point of view. From there, extend into the hook and check script, and your unease about handing proto to an agent moves over to the side of the tooling.
How do you defend the plain, fragile territory of schema compatibility by machine? I am still polishing my own workflow, and I would be glad to keep learning alongside you.
Share
Thank You for Reading
Antigravity Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.