エージェントに「この使っていないフィールドを整理しておいてください」と頼んだ翌週、App Store と Google Play で配布している古いバージョンのアプリを使う一部のユーザーからだけ、届く数値がずれているという報告が来ます。個人開発で小さな API を Protocol Buffers で定義していると、これは十分に起こりうる事故です。
diff を見ても、エディタ上では何も間違っていません。フィールドがひとつ消えて、別のフィールドがひとつ増えています。テキストとしては完璧に整理されています。壊れているのは、テキストではなく wire フォーマット、つまりバイト列の互換性のほうです。
Antigravity の 6 月末以降の更新で、マークダウンや diff の描画が C++/Python/Protobuf の構文ハイライトに対応しました。エージェントが提案する .proto の差分が読みやすくなったことは歓迎すべきことです。ただ、読みやすさは互換性の保証ではありません。人間の目で proto の diff を眺めて「大丈夫そう」と判断するのは、最も壊れやすい部分を最も曖昧な方法で守っていることになります。
ここでは、その判断を機械へ肩代わりさせていきます。
エージェントは .proto を「テキストとして」正しく直す
大規模言語モデルは、.proto を構文的に正しく編集することは得意です。使われていないフィールドを消し、命名を整え、コメントを付け直す。ここまでは信頼できます。
問題は、Protocol Buffers の互換性がソースコードのテキストではなく、シリアライズされたバイト列で決まる点にあります。各フィールドはワイヤ上で名前ではなくフィールド番号で識別されます。つまり、フィールド名を変えても互換性は保たれますが、フィールド番号を変えたり再利用したりすると、既存のバイナリは別の意味でそのバイトを解釈します。
エージェントはソースの見た目を最適化しようとします。そのとき、削除して空いた番号を新しいフィールドにあてがうのは、テキスト上はむしろ「きれいな」変更に見えます。しかしこれは、まだ更新していないクライアントに対して、古いフィールドの値を新しいフィールドとして読ませる指示になります。この落とし穴は本番でだけ表面化し、回避するには番号の意味を変えないことが要点になります。
私自身、この線引きを何度か肌で確かめてきました。エージェントに任せてよいのは「意味を変えない整形」までで、「番号の意味を変える操作」は必ず人間か機械の関門を通す。ここを曖昧にすると、テストが通ったまま本番でだけ壊れます。
wire 互換が壊れる典型パターン
事故のかたちはおおむね三つに集約されます。
フィールド番号の再利用
同じ番号のままの型変更
カーディナリティ(optional/repeated/required)の変更
ひとつめは、フィールド番号の再利用です。フィールド 3 を削除し、あとから別の型のフィールド 3 を足すと、旧クライアントが送ってきた「かつての 3」を新サーバは「新しい 3」として解釈します。
ふたつめは、型の変更です。同じ番号のまま型を変えると、その二つの型がワイヤ上で同じエンコードを共有していない限り、値は静かに壊れます。varint 系(int32/int64/uint32/uint64/bool/enum)は互いに読み替え可能ですが、sint32/sint64 は zigzag エンコードのため int32 とは互換がありません。
みっつめは、optional/repeated のカーディナリティ変更や、required の付け外し(proto2)です。これはメッセージ全体のパースを不安定にします。
型を変えたときにワイヤが壊れるかどうかは、次の対応表で判断できます。
元の型 互換のある変更先 wire type
int32, uint32, int64, uint64, bool, enum 相互に変更可(値域に注意) varint
sint32, sint64 相互のみ(varint 系とは非互換) varint (zigzag)
string, bytes 相互可(bytes が妥当な UTF-8 のときのみ) length-delimited
fixed32, sfixed32, float 相互に変更可 32-bit
fixed64, sfixed64, double 相互に変更可 64-bit
この表を毎回頭に浮かべて diff を審査するのは現実的ではありません。だから機械に持たせます。
buf breaking を wire 互換の門番にする
Buf の buf breaking は、変更後のスキーマを変更前のスキーマと突き合わせ、破壊的変更を検出します。カテゴリを WIRE に絞ると、ソースの整形や命名変更は許しつつ、バイト列の互換性を壊す変更だけを弾けます。
リポジトリのルートに buf.yaml を置きます。
# buf.yaml
version : v2
modules :
- path : proto
breaking :
use :
- WIRE # バイナリ互換のみを審査(命名・整形は許容する)
ignore_unstable_packages : true
lint :
use :
- STANDARD
WIRE を選ぶのが要点です。個人的には、この局面では WIRE を推奨します。FILE カテゴリはフィールド名の変更まで破壊的と見なしますが、wire 互換だけを守りたい局面では厳しすぎます。エージェントに整形の自由を残しつつ、意味の互換だけを死守するには WIRE が過不足ありません。
比較のベースラインには main ブランチを使います。
# 変更後の proto を main の proto と突き合わせる
buf breaking --against '.git#branch=main,subdir=.'
破壊的変更があると、buf は該当ファイルと行、違反したルールを表示して非ゼロで終了します。たとえばフィールド番号の型変更なら FIELD_SAME_TYPE 系の違反が、番号そのものの意味変更なら該当ルールが具体的に出ます。
フィールド削除は reserved とセットで
buf breaking は強力ですが、あくまで「前の状態」との比較です。いったん番号を空けて次の変更でその番号を再利用すると、各ステップ単体では破壊的に見えないことがあります。だから削除時点で番号を封じます。
エージェントに削除を頼むときは、削除ではなく reserved への置き換えを指示します。Before と After を並べます。
// Before: エージェントが「整理」した危険な形
message UserProfile {
string display_name = 1 ;
string avatar_url = 2 ;
// legacy_score(3) を消して、新しい nickname を 3 に置いた
string nickname = 3 ;
}
// After: 番号と旧名を予約して二度と再利用させない
message UserProfile {
string display_name = 1 ;
string avatar_url = 2 ;
string nickname = 4 ; // 空いていた次の番号を使う
reserved 3 ; // legacy_score が使っていた番号を封印
reserved "legacy_score" ; // 旧フィールド名も予約しておく
}
reserved を宣言しておけば、あとからエージェントが番号 3 を使おうとした瞬間、コンパイル自体が失敗します。人間のレビューを待たずに、その場で止まります。個人開発では、この「早い段階で機械的に止まる」ことが何よりありがたく感じます。深夜に自動実行したパイプラインが翌朝に問題を起こしているより、その場で赤くなってくれるほうが遥かに安全です。
pre-push フックで人間の目の前に止める
CI で弾くだけでは、壊れた変更が一度リモートに乗ってしまいます。手元の push の瞬間に止めるほうが、フィードバックが速くなります。エージェント運用と手動フックを二段で重ねる考え方は、Antigravity CLI Hooks と git pre-push の二段ゲート設計 と同じ発想です。
.git/hooks/pre-push に置くスクリプトです。
#!/usr/bin/env bash
# .git/hooks/pre-push — proto の wire 互換を push 前に検証する
set -euo pipefail
# proto に変更が無ければ即通過(無関係な push を遅くしない)
if git diff --quiet origin/main -- 'proto/**/*.proto' ; then
echo "proto に変更なし — wire 互換チェックをスキップします"
exit 0
fi
echo "proto の変更を検出 — buf breaking を実行します"
if ! buf breaking --against '.git#branch=main,subdir=.' ; then
echo ""
echo "⛔ wire 互換を壊す変更が見つかりました。"
echo " フィールド番号の再利用・型変更がないか確認してください。"
echo " 削除は 'reserved' への置き換えで行ってください。"
exit 1
fi
echo "✅ wire 互換 OK"
このフックの狙いは、エージェントの生産性を落とさないことです。proto を触っていない push は素通りし、触ったときだけ関門が立ち上がります。守るべき一点にだけコストを払う設計にします。
フィールド番号の再利用だけは機械で潰す
buf と reserved で大半は守れますが、「削除された番号が reserved に登録されているか」を、コミット単位で明示的に確かめると安心感が増します。エージェントが reserved の宣言を忘れるケースを直接検出する小さなスクリプトです。
# check_reserved.py — 削除された番号が reserved に登録されているか確認する
import re
import subprocess
import sys
def field_numbers (proto_text: str ) -> set[ int ]:
# "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 nums
def 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 nums
def read_main (path: str ) -> str :
return subprocess.run(
[ "git" , "show" , f "origin/main: { path } " ],
capture_output = True , text = True ,
).stdout
def main (paths: list[ str ]) -> int :
failed = False
for path in paths:
old = read_main(path)
if not old:
continue # 新規ファイルは比較対象なし
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 "⛔ { path } : 削除された番号 { sorted (unreserved) } が reserved 未登録です" )
failed = True
return 1 if failed else 0
if __name__ == "__main__" :
sys.exit(main(sys.argv[ 1 :]))
正規表現ベースの割り切った実装で、ネストしたメッセージの厳密な区別まではしません。それでも「番号を消したのに封印し忘れた」という最も多い抜けを、依存パッケージなしで拾えます。buf breaking が構造的な互換を、このスクリプトが番号の衛生を担うという二重化です。
どこまでエージェントに任せるか
ここまでの仕組みは、エージェントを信用しないためのものではありません。むしろ逆で、安心して任せる範囲を広げるための土台です。
私はこの線引きを守っています。具体的には次のとおりです。フィールドの追加、コメントの整備、命名の統一、パッケージ構成の整理は、そのまま任せます。これらは wire 互換を壊しません。一方で、フィールドの削除、番号の変更、型の変更は、必ず reserved・buf breaking・番号チェックの三枚を通します。エージェントが提案しても、関門を通らなければ push できません。
この設計にしてから、proto の変更に対する心理的なためらいが減りました。壊れる操作は機械が止めてくれると分かっているので、整形はためらわず任せられます。任せる範囲と検証する範囲を分けることが、結果としてエージェントをより深く活用することにつながります。権限の渡し方という観点では、Antigravity Agent に書き込み権限を安全に渡す Permission Boundary の本番設計 の考え方とも通じます。
まとめ
次の一歩として、まず自分の proto リポジトリに buf.yaml を置き、breaking.use に WIRE を指定して buf breaking --against '.git#branch=main,subdir=.' を一度手で走らせてみてください。今の main と作業ブランチの差分が、wire の観点で安全かどうかが即座に分かります。そこからフックとチェックスクリプトへ広げていけば、エージェントに proto を任せる不安は、仕組みの側へ移し替えられます。
スキーマ互換という地味で壊れやすい領域を、どう機械で守るか。私自身まだ手元の運用を磨いている途中ですが、共に学んでいけたら嬉しいです。