How Reusing a Protobuf Field Number During a Rolling Deploy Auto-Approved High-Risk Orders
← Back
October 29, 2026Architecture8 min read

How Reusing a Protobuf Field Number During a Rolling Deploy Auto-Approved High-Risk Orders

Published October 29, 20268 min read

10:21 UTC. Someone on the fraud team notices the manual review queue has gone quiet. Not slow, quiet. Zero items in seven minutes, on a Tuesday morning when the queue usually runs a steady trickle of twenty to thirty orders an hour. Nobody paged for it, because an empty queue doesn't look like an outage. It looks like a good morning.


the scramble

First theory: the fraud-scoring model got faster, or got better, and fewer orders are tripping the threshold that sends them to manual review. Someone pulls up the model-serving dashboard. Latency's flat, request volume's flat, score distribution for the last hour looks the same shape as yesterday's. Nothing changed there.

Second theory: a feature flag. The team has a flag, skip_manual_review_legacy_partners, that exempts a small, specific list of trusted wholesale accounts from the fraud queue entirely. If that flag's rollout percentage got bumped by accident, this is exactly what it would look like. The flag service's audit log for the last six hours shows no changes. Still at 2%, still pointed at the same eleven account IDs it's always pointed at.

Third theory, and the one that stalls the room for a few minutes: maybe nothing's wrong, and order volume is just genuinely low this morning. Someone checks total order count. 1,840 orders in the last hour, completely normal for a Tuesday. Orders are flowing. They're just not landing in the queue that's supposed to catch the risky ones.


the hunt

With the model and the flag both clean, the hunt moves to the orders that should have been flagged and weren't. Someone writes a quick query against the orders service's own risk-tier field, which is set independently of the fraud queue and should always agree with it.

orders_db, risk tier vs. queue outcome, last hour
SELECT risk_tier, COUNT(*), SUM(CASE WHEN sent_to_manual_review THEN 1 ELSE 0 END)
FROM orders
WHERE created_at > now() - interval '60 minutes'
GROUP BY risk_tier;

 risk_tier | count | sent_to_review
 LOW       |  1,612 |  0
 MEDIUM    |    148 |  0
 HIGH      |     68 |  0
 CRITICAL  |     12 |  0

Every single MEDIUM, HIGH, and CRITICAL order in the last hour skipped review. Not some of them, all 228 of them. That's not a scoring drift, that's a switch flipped to off for an entire category. But the flag is clean and the model is clean, so something between the two services is lying.

Orders and fraud-review talk over gRPC, one call per order, carrying a RiskTier enum set by the orders service. Someone checks whether this started at a specific time rather than drifting. It did: 10:14 UTC, almost to the minute. And 10:14 UTC is also when the orders service's rolling deploy for an unrelated change, a shipping-cost rounding fix, started rolling pods.

That's the first real lead, but it still doesn't explain the mechanism. The shipping fix touched nothing related to fraud. Someone diffs the two proto definitions, the one baked into the old pods still running and the one in the new build going out, looking for anything that moved.


the find

The orders.proto file has a field that was deprecated eight months ago:

orders.proto, as it's shipped for the last 8 months
message OrderEvent {
  string order_id = 1;
  string account_id = 2;
  // ...fields 3-6 omitted...

  // DEPRECATED 2026-02-11, legacy wholesale fee program ended.
  // Do not use. Will be removed.
  bool legacy_partner_waiver = 7;
}

The comment says "do not use." The comment is not protobuf. Nothing in the schema itself stops a field number from being reused, because a comment isn't a compiler constraint. Three weeks ago, a different engineer on a different team added fraud tiering to the same message, picked the next available number by habit, and field 7 looked available. It was not marked reserved, so nothing flagged it.

orders.proto, the change that shipped three weeks ago
enum RiskTier {
  LOW = 0;
  MEDIUM = 1;
  HIGH = 2;
  CRITICAL = 3;
}

message OrderEvent {
  string order_id = 1;
  string account_id = 2;
  // ...fields 3-6 omitted...

  RiskTier risk_tier = 7; // reused field number 7
}

Protobuf's wire format encodes both a bool and an enum as wire type 0, a plain varint. A decoder doesn't know or care which Python/Go struct field that varint is destined for, only that field number 7 is a varint and here's its value. Someone pulls the raw bytes off the wire during the deploy window to confirm it directly.

captured gRPC payload, decoded raw
$ protoc --decode_raw < order_event_capture.bin
1: "ord_8f3ac1"
2: "acct_44210"
7: 2

# New pod encodes field 7 as RiskTier.HIGH (value 2).
# Old pod, still running the pre-deploy binary, decodes field 7
# as legacy_partner_waiver. Any nonzero varint reads as boolean true.

During the rolling deploy, new pods were producing OrderEvent messages with risk_tier encoded at field 7. Old fraud-review pods, not yet rolled, were still running the binary that interpreted field 7 as legacy_partner_waiver. Any order tagged MEDIUM, HIGH, or CRITICAL (values 1, 2, or 3, all nonzero) decoded as legacy_partner_waiver = true on those old pods, which triggers an auto-approve path that was built for trusted wholesale accounts and was never supposed to see a risk tier at all. The higher the actual fraud risk, the more certain the auto-approve.


the fix

The immediate fix was finishing the rolling deploy, which naturally stopped new waivers the moment every fraud-review pod ran the new binary and started treating field 7 as the enum it was supposed to be. That took four more minutes. The real fix had three parts.

First, remove the dangerous field properly instead of commenting around it:

orders.proto, after
message OrderEvent {
  string order_id = 1;
  string account_id = 2;
  // ...fields 3-6 omitted...

  reserved 7; // formerly legacy_partner_waiver, retired 2026-02-11
  RiskTier risk_tier = 8; // moved off the reused number
}

Second, make the schema reject this category of change at CI time instead of relying on a human reading a comment. The team already had buf in the toolchain for linting, just not wired into the pipeline that gates proto changes:

.github/workflows/proto-check.yml
- name: Check for breaking proto changes
  run: |
    buf breaking --against 'https://github.com/org/protos.git#branch=main'

buf breaking flags field-number reuse as a breaking change by default, because it is one, even when the old field was never formally removed. This check alone would have failed the PR that introduced risk_tier = 7 before it ever shipped.

Third, an audit of every other message in the repo for deprecated-but-not-reserved fields. The grep was blunt but effective:

finding every unreserved landmine in the repo
grep -rB1 "DEPRECATED" --include="*.proto" . | grep -v "reserved"

Thirty-one other fields across nine proto files matched: deprecated in a comment, never marked reserved, free to be silently reused by the next engineer who didn't read eight-month-old comments before picking a field number. All thirty-one got backfilled with reserved in the same cleanup PR.


the aftermath

228 MEDIUM/HIGH/CRITICAL orders auto-approved during the 19-minute deploy window
$6,240 Charged back on the 3 orders that turned out to be fraudulent after manual replay

All 228 affected orders were replayed against the current fraud model the same day. Most cleared on a second look, nine were flagged as genuinely high-risk and three of those were confirmed fraudulent, already past the chargeback window by the time the replay ran.

  • A deprecation comment is not a schema constraint. If a field number needs to stay retired, the only thing that actually enforces that is reserved, not a note telling the next person not to touch it.
  • Protobuf's wire format doesn't distinguish a bool from an enum from an int32 at decode time for a given field number. A mismatched schema between producer and consumer doesn't crash, it just silently produces a value that happens to parse.
  • A rolling deploy creates a window where two valid schema versions are both live at once. Any change that isn't both forward- and backward-compatible across that window is a live bug for the duration of the rollout, not just a risk on paper.
  • Breaking-change detection belongs in CI, gating the merge, not in a human remembering to check a comment against a number before picking the next one.

The shipping-cost rounding fix that triggered this deploy shipped clean. Nobody on that team touched fraud logic, reviewed fraud logic, or had any reason to think their rollout intersected with it at all. The two features were connected by nothing more than a field number neither team was actually watching.

Share this
← All Posts8 min read