← Proto Gate / API
Tokens

Drive Proto Gate from your own code

Base URL https://api.skillsafe.ai/v1/app-api. One Protocol Buffers schema goes in — plus, for the compatibility lane, the previous revision of the same file — and one structured result comes back. Everything on this page uses the same token the web app uses, which you can read off the tokens page.

The task field comes first

This app has four lanes over one schema, and task selects which one you get. It is the field to decide before any other, because it changes the reply’s shape: the body key you get back, the posture vocabulary, the area vocabulary, the finding id prefix, the artifacts and the price all follow from it. Omit it and the model picks the lane your input best fits and names its choice in the first sentence of verdict — convenient interactively, not something to rely on in a script.

taskLaneWhat it readsBody keyId prefixposture
schemaSchema reviewEvery declaration in the file, in the order it bites: whether syntax is declared at all (without it protoc assumes proto2), the package and its version suffix, field numbers (duplicates, the 19000–19999 implementation-reserved block, numbers above 15 while low numbers are free, collisions with reserved), presence (required is permanent), enums (a zero value must exist, must be first in proto3, and should be *_UNSPECIFIED), map legality, oneof shape, well-known types, naming, imports, and service shape.rules[]SC-schema-clean / schema-fixable / schema-unsound
compatCompatibility gateThe difference between previous and proto, symbol by symbol, classified three ways: the wire (field number and wire type only), canonical JSON (field names and json_name, plus enum value names) and generated source (any rename, removal or retype). Requires previous; without it the lane returns an empty changes array and says so.changes[]CP-wire-safe / wire-risky / wire-breaking
contractRPC contractEvery rpc, one row: whether the streaming pattern is the right one, whether it is safe to retry and whether the schema says so, the deadline a caller should set, the gRPC status codes it should return, whether a list method is paginated or is one growth spurt from the 4 MB default message limit, and whether partial success is representable.methods[]CT-contract-solid / contract-thin / contract-unsafe
rolloutRollout planWho has to be able to read what before who starts writing it. Ordered steps across four phases (schema, server, client, cleanup), each with an observable gate, a rollback, and an honest note where the step cannot be undone. Uses previous where it is present, and the client-fleet numbers you put in notes.steps[]RO-rollout-ready / rollout-staged / rollout-blocked

A lane needs its subject to be in the request. The web app hides a lane it cannot honestly run; the API will run it anyway, and the result then says plainly that the input was missing rather than reviewing something it was never given. compat without previous and contract against a file with no service are the two that come back thin.

The envelope

Every response has the same shape. Check ok before touching data.

{"ok": true,  "data":  { ... }}
{"ok": false, "error": {"code": "VALIDATION_ERROR", "message": "...", "details": { ... }}}
HTTPerror.codeWhat to do
401UNAUTHORIZEDThe token is missing, malformed or expired. Get a fresh one from the tokens page.
402INSUFFICIENT_CREDITSThe balance is below min_credits. /estimate is free, so check it first.
403FORBIDDENA guest token tried a metered call. /run and /run-stream need a personal token.
404NOT_FOUNDUsually a job id that does not exist, or a mistyped path.
409IDEMPOTENCY_CONFLICTThe same Idempotency-Key was reused with a different body. Change the key or send the original body.
422VALIDATION_ERRORThe input object is the wrong shape. Note that the body is the input object — do not wrap it in an input key.
429RATE_LIMITEDBack off and retry; do not tight-loop.
503UPSTREAM_UNAVAILABLEThe model provider is briefly unavailable. Retry with the same idempotency key.

Input fields

The request body is the input object itself. Do not wrap it in an input key: a wrapped body returns 200 while hiding task from the model, so you silently get whichever lane it guessed.

FieldTypeRequiredMeaning
taskstringrecommendedOne of schema, compat, contract, rollout.
protostringyesThe .proto file under review, as one string. Several files in one string are told apart by a marker line — // file: acme/orders/v1/order.proto is the canonical form, and #, /* file: x */, <!-- file: x -->, == x.proto ==, -- x.proto -- and a fenced ```proto x.proto opener are read the same way. With no marker the whole string is one file. Imports are not resolved: a type from another file is reported as unresolvable rather than guessed at.
previousstringfor compatThe previous revision of the same file — what your registry has published, or git show HEAD~1:path/to/file.proto. The diff is computed symbol by symbol from two parse trees, so reformatting, reordering and reindenting produce no changes at all. Send an empty string for the other three lanes; rollout uses it when it is there.
notesstringnoWhat you are about to do with it. The web app always sends this key, empty string included. Short and concrete sharpens the result a lot — “forty services and two mobile apps are on v1, and the mobile release train is three weeks” changes the rollout lane more than any other field you can set, because the deprecation window is computed from it.
clip_notestringnoSet this when you have truncated the schema yourself, so the model knows what it is missing. Clip on declaration boundaries and keep the header (syntax, package, import, option) whole — half a message is worse than an absent message. The web app clips the current revision at 48,000 characters and the previous one at 32,000, dropping whole declarations from the middle with a marker at the cut.
prescanobjectnoThe free in-browser reader’s output: facts, flags (each with a stable id), the rules table and the changes table. The web app always sends it and the prompt requires exactly one coverage_check entry per flag id. See below — this is the field that decides how accountable the answer is.

What prescan carries, and why sending it matters

The reader is a real Protocol Buffers parser that runs in the browser for free: a tokenizer and a recursive-descent parser for proto2, proto3 and editions, then a lint pass and — when previous is present — a symbol-keyed differ. Every finding it makes gets a stable id, and the prompt requires the run to answer every one of them.

{
  "facts": {
    "syntax":   "proto3",
    "edition":  "",
    "package":  "acme.orders",
    "imports":  ["google/protobuf/timestamp.proto", "google/protobuf/empty.proto"],
    "options":  ["go_package = github.com/acme/orders/gen;ordersv1"],
    "counts":   {"messages": 7, "enums": 1, "services": 1, "rpcs": 5, "fields": 20,
                 "maps": 1, "oneofs": 1, "repeated": 3, "deprecated": 0, "imports": 2,
                 "parse_errors": 0, "extra_tag_bytes": 2},
    "messages": [{"full": "Order", "fields": 9, "numbers": [1,2,3,4,6,7,11,12,13],
                  "reserved": [], "reserved_names": [], "oneofs": ["fulfilment"],
                  "documented": true}],
    "enums":    [{"full": "OrderStatus", "values": 4, "zero_value": "PENDING",
                  "first_value": "PENDING", "allow_alias": false}],
    "services": [{"name": "Orders", "rpcs": 5}],
    "rpcs":     [{"service": "Orders", "name": "ListOrders", "full": "Orders.ListOrders",
                  "input": "ListOrdersRequest", "output": "ListOrdersResponse",
                  "pattern": "unary", "documented": false, "options": []}],
    "wire":     [{"ref": "Order.line_items", "number": 11, "type": "LineItem",
                  "cardinality": "repeated", "tag_bytes": 1,
                  "json_name": "lineItems", "deprecated": false}],
    "previous": {"present": true, "package": "acme.orders", "syntax": "proto3",
                 "counts": {"messages": 7, "fields": 17}},
    "diff":     {"counts": {"total": 13, "breaking_wire": 2, "risky_wire": 4,
                            "breaking_json": 6, "breaking_source": 7,
                            "added": 6, "removed": 5},
                 "worst": {"wire": "breaking", "json": "breaking", "source": "breaking"}},
    "flag_counts": {"blocker": 7, "warn": 11, "note": 9}
  },
  "flags":   [{"id":       "PG-X02",
               "severity": "blocker" | "warn" | "note",
               "where":    "Order.line_items",
               "what":     "Field line_items moved from number 5 to 11. This silently drops ...",
               "fix":      "Put it back on 5. If the number really must change, add a new field ...",
               "line":     14}],
  "rules":   [{"ref": "Order.total_amount", "kind": "field",
               "rule": "money is not floating point",
               "verdict": "ok" | "watch" | "broken",
               "current": "double total_amount", "proposed": "int64 total_amount_cents",
               "note": "one sentence of why"}],
  "changes": [{"ref": "Order.line_items", "change": "renumbered",
               "wire": "breaking", "json": "safe", "source": "safe",
               "before": "line_items = 5", "after": "line_items = 11",
               "note": "The field number is the only identity the wire has ..."}]
}

The flags ids are what the reconciliation panel is built on. Send them and the reply carries one coverage_check entry per id, with confirmed, set-aside (with a stated reason) or contradicted (with the model’s own reasoning). Omit prescan and that whole accountability layer is simply absent: the answer may still be good, but nothing checks it.

You do not have to use our reader. Any object with a flags array of {id, severity, where, what, fix, line} works — a buf lint --error-format=json run mapped into that shape is a perfectly good prescan, and then the reply reconciles against your CI rather than against ours.

The output contract

Every lane returns the same envelope plus exactly one lane array. These are the fields the web app’s render path actually parses; anything else in the reply is ignored, and anything missing is filled with a defined empty value rather than left undefined.

FieldTypeNotes
taskstringEchoes the lane. If you omitted task, this is the lane the model chose.
titlestringOne line naming the schema and the lane.
postureenumPer lane, see the table above. An unrecognised value is coerced to the lane’s middle value.
confidenceenumhigh / medium / low.
verdictstringTwo or three sentences: the answer, and the single fact that decides it.
exec_summarystringOne paragraph, 120–220 words, for whoever approves the merge.
findings[]arrayid (lane prefix + -NNN), severity (critical/high/medium/low), area (per-lane enum), target, title, evidence, impact, remedy, blocks (bool), cites (prescan flag ids).
coverage_check[]array{id, status, note} — exactly one per prescan.flags[].id. status is confirmed, set-aside or contradicted.
artifacts[]array{name, language, content}. language is one of proto, markdown, json, yaml, go, bash, csv, text. An artifact with empty content is dropped.
assumptions[], open_questions[], next_steps[]string[]Plain strings. Empty arrays are legitimate answers and render as “None”.
summarystringTwo or three sentences, the part somebody pastes into the PR.

The lane arrays

LaneKeyRow shape
schemarules[]ref, kind (file/message/field/enum/enum_value/oneof/map/service/rpc), rule, verdict (ok/watch/broken), current, proposed, note. 8–30 rows, including the checks that passed and mattered.
compatchanges[]ref, change (added/removed/renamed/renumbered/retyped/cardinality/moved/streaming/reserved), wire, json, source (each safe/risky/breaking), before, after, who_breaks, note. One row per difference, safe ones included, worst first.
contractmethods[]method (Service.Rpc), pattern (unary/server-stream/client-stream/bidi), verdict (solid/thin/unsafe), idempotent (bool), deadline, errors, issue, fix. Exactly one row per rpc in the schema.
rolloutsteps[]order (number, from 1), phase (schema/server/client/cleanup), action, artifact, gate, rollback, blocking (bool). 4–14 steps; the last one is always in the cleanup phase.

The three columns in changes[] are independent, and that is the point of the lane. A rename is safe on the wire and breaking in JSON, because the wire carries only the field number and canonical JSON carries only the name. A renumber is the exact reverse. Neither one fails to compile and neither one raises an error at runtime — the field simply arrives unset. If you only read one column, read wire, and then ask whether anything downstream speaks JSON.

Step by step

Every sample below assumes TOKEN holds an app token for proto-gate. Get one from the tokens page — it has a “copy shell export” button — and keep it out of source control: it authorises calls as you.

1. A tiny client

Four things every call needs: the base URL, the bearer token, a JSON body, and unwrapping the {ok, data, error} envelope so a failure raises instead of returning a shape your code then treats as success.

BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN"          # from https://proto-gate.skillsafe.ai/tokens.html
SLUG="proto-gate"

# every call looks like this
call() {                     # call <path> [json-body]
  if [ -n "$2" ]; then
    curl -sS -X POST "$BASE/$1" \
      -H "Authorization: Bearer $TOKEN" \
      -H "Content-Type: application/json" \
      -H "X-App-Slug-Hint: $SLUG" \
      -d "$2"
  else
    curl -sS "$BASE/$1" -H "Authorization: Bearer $TOKEN"
  fi
}

2. Get a token

A guest token is enough for /me and /estimate, so you can price every lane without an account and without a charge. /run and /run-stream are metered and need a personal token, which comes from signing in on the tokens page. A guest token is also a fresh subject: run history is scoped to the subject that wrote it.

curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"proto-gate"}'
# -> {"ok":true,"data":{"token":"aut_...","type":"guest","expires_at":"..."}}

3. Check the session and the balance

GET /me is free. It tells you whether the token is personal or guest and what the credit balance is, which is what you compare against hold_credits before submitting. A 402 after submit is a failure of your preflight, not of the platform.

curl -sS "https://api.skillsafe.ai/v1/app-api/me" \
  -H "Authorization: Bearer $TOKEN"
# -> {"ok":true,"data":{"id":"sub_...","type":"user","credits":18400}}

4. Price the lane — free

POST /estimate costs nothing, creates no job, and returns the model binding as well as the price. It is the cheapest way to confirm you are wired to the right model at the right markup: model should read gpt-5.6-terra, model_alias should read gpt-terra, and markup_bps should be 1000. hold_credits differs per lane, so estimate the lane you are about to run — never show lane A’s hold for lane B.

curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/estimate" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -d @- <<'JSON'
{
  "task": "compat",
  "proto": "syntax = \"proto3\";\npackage acme.orders;\nmessage Order {\n  string order_id = 1;\n  repeated LineItem line_items = 11;\n}",
  "previous": "syntax = \"proto3\";\npackage acme.orders;\nmessage Order {\n  string order_id = 1;\n  repeated LineItem line_items = 5;\n}",
  "notes": "merging Thursday; forty services are on v1"
}
JSON
# -> {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
#                       "markup_bps":1000,"hold_credits":4120,"min_credits":260,
#                       "sponsor_enabled":false}}

5. Run it, and poll

POST /run returns a job_id immediately; poll GET /jobs/{id} until status is terminal. Always send an Idempotency-Key, and derive it from (task, input, attempt): two lanes over the same schema are two distinct runs and must not collide on one key, and a network blip that makes you resend must not bill twice. If the reply is not valid JSON and you retry for a reformat, reuse the same key.

KEY="proto-gate:compat:$(shasum -a 256 acme/orders/v1/order.proto | cut -c1-16):a0"

JOB=$(curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/run" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  --data-binary @payload.json | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["job_id"])')

until STATUS=$(curl -sS "https://api.skillsafe.ai/v1/app-api/jobs/$JOB" \
        -H "Authorization: Bearer $TOKEN" \
        | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["status"]) '); \
      [ "$STATUS" = "succeeded" ] || [ "$STATUS" = "failed" ]; do
  sleep 2
done
echo "$STATUS"

6. Or stream it

POST /run-stream is the same call over server-sent events. It is what the web app uses, because a four-lane reply is long enough to want progress: the section headings arriving in the delta stream ("posture", "findings", the lane key, "coverage_check", "artifacts") are the only real progress signal a JSON reply gives. Accumulate the deltas and parse once at the end — and if the stream dies, a truncated JSON object still holds complete sections worth showing.

curl -sS -N -X POST "https://api.skillsafe.ai/v1/app-api/run-stream" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: text/event-stream" \
  -H "Idempotency-Key: $KEY" \
  --data-binary @payload.json
# event: delta   data: {"delta":"{\"task\":\"compat\","}
# event: delta   data: {"delta":"\"posture\":\"wire-breaking\","}
# event: job     data: {"job_id":"job_...","status":"succeeded"}
# event: done    data: {"truncated":false}

One worked example per lane

Four requests over the same pair of revisions, and the shape each one comes back in. The prescan object is elided here for length — it is the object described above, and every request the web app makes carries it.

task: "schema"

Is the schema sound? Note that the reply carries rules and no other lane array.

POST /v1/app-api/estimate   (then /run or /run-stream with the same body)

{
  "task": "schema",
  "proto": "syntax = \"proto3\";\npackage acme.orders;\n\nmessage Order {\n  string order_id = 1;\n  double total_amount = 3;\n  map<double, string> promo_weights = 12;\n  string CustomerFacingNote = 19005;\n}\n\nenum OrderStatus {\n  PENDING = 0;\n  PAID = 1;\n}\n",
  "previous": "",
  "notes": "new service, nothing consumes it yet",
  "prescan": { "...": "the reader output" }
}

-> {"ok": true, "data": {"job_id": "job_...", "status": "queued"}}

GET /v1/app-api/jobs/job_...   -> data.output is one JSON object:

{
  "task": "schema",
  "title": "acme.orders order.proto - schema review",
  "posture": "schema-unsound",
  "confidence": "high",
  "verdict": "protoc rejects this file twice before any style question applies: map<double, string> is not a legal map key, and field number 19005 sits inside the 19000-19999 block protobuf reserves for its own implementation. Both are one-line fixes; the package having no version suffix is the decision that is hard to undo.",
  "exec_summary": "...",
  "findings": [
    {
      "id": "SC-001",
      "severity": "critical",
      "area": "maps",
      "target": "acme.orders.Order.promo_weights",
      "title": "map<double, string> is not a legal map type",
      "evidence": "map<double, string> promo_weights = 12;",
      "impact": "protoc refuses the file, so nothing downstream builds.",
      "remedy": "map<string, double> promo_weights = 12;  // key must be integral or string",
      "blocks": true,
      "cites": ["PG-T05"]
    }
  ],
  "rules": [
    {"ref": "acme.orders", "kind": "file", "rule": "package version suffix",
     "verdict": "watch", "current": "acme.orders", "proposed": "acme.orders.v1",
     "note": "An unversioned package leaves nowhere to put a v2."},
    {"ref": "acme.orders.OrderStatus", "kind": "enum",
     "rule": "zero value is *_UNSPECIFIED", "verdict": "broken",
     "current": "PENDING = 0", "proposed": "ORDER_STATUS_UNSPECIFIED = 0",
     "note": "proto3 cannot tell unset from zero, so every order that never set status looks PENDING."}
  ],
  "coverage_check": [{"id": "PG-T05", "status": "confirmed", "note": "..."}],
  "artifacts": [{"name": "order.proto", "language": "proto", "content": "syntax = \"proto3\";\n..."}],
  "assumptions": ["Nothing has consumed this schema yet, per notes."],
  "open_questions": ["Is promo_weights keyed by a discount rate? A string key would be safer."],
  "next_steps": ["Fix the map key.", "Move CustomerFacingNote to a free low number."],
  "summary": "..."
}

task: "compat"

The only lane that needs previous. Every row carries three independent verdicts and a who_breaks that names a direction, not just a party.

{
  "task": "compat",
  "proto": "... repeated LineItem line_items = 11; ... int64 quantity = 2; ...",
  "previous": "... repeated LineItem line_items = 5;  ... int32 quantity = 2; ... string coupon_code = 8; ...",
  "notes": "merging Thursday; forty services and two mobile apps are on v1, mobile train is three weeks",
  "prescan": { "...": "the reader output, including its own changes[] table" }
}

-> {
  "task": "compat",
  "posture": "wire-breaking",
  "confidence": "high",
  "verdict": "Two of the thirteen differences break the wire and cannot be sequenced around: line_items moved from 5 to 11, and rpc ApplyCoupon was removed. ...",
  "changes": [
    {
      "ref": "acme.orders.Order.line_items",
      "change": "renumbered",
      "wire": "breaking", "json": "safe", "source": "safe",
      "before": "repeated LineItem line_items = 5",
      "after": "repeated LineItem line_items = 11",
      "who_breaks": "both directions: a new server reading an order written by an old client sees no line items, and an old client reading a new server's response sees none either. Neither side errors.",
      "note": "The field number is the only identity the wire has; 5 becomes an unknown field on one side and 11 on the other."
    },
    {
      "ref": "acme.orders.Order.coupon_code",
      "change": "removed",
      "wire": "risky", "json": "breaking", "source": "breaking",
      "before": "string coupon_code = 8",
      "after": "(removed, not reserved)",
      "who_breaks": "anything replaying order messages written before the deploy - the number is free for a future edit to point a different type at.",
      "note": "Add reserved 8; and reserved \"coupon_code\"; so the number can never be reused."
    },
    {
      "ref": "acme.orders.LineItem.quantity",
      "change": "retyped",
      "wire": "risky", "json": "risky", "source": "breaking",
      "before": "int32 quantity = 2",
      "after": "int64 quantity = 2",
      "who_breaks": "an old int32 reader parsing a new writer's message: both are varint so the bytes parse, but any quantity above 2147483647 truncates.",
      "note": "Widening is the safe direction in practice; the generated accessor type still changes."
    },
    {
      "ref": "acme.orders.ShippingInfo",
      "change": "added",
      "wire": "safe", "json": "safe", "source": "safe",
      "before": "(absent)", "after": "message ShippingInfo",
      "who_breaks": "nobody",
      "note": "A new message. Nothing existing refers to it."
    }
  ],
  "coverage_check": [{"id": "PG-X02", "status": "confirmed", "note": "..."}],
  "artifacts": [
    {"name": "compat-report.md", "language": "markdown", "content": "# Compatibility report\n..."},
    {"name": "order.proto", "language": "proto", "content": "// reserved statements to add\n..."}
  ],
  "...": "the rest of the common envelope"
}

task: "contract"

Exactly one methods row per rpc in the schema — including the ones that are fine.

{
  "task": "contract",
  "proto": "... service Orders { rpc ListOrders (ListOrdersRequest) returns (ListOrdersResponse); rpc HealthCheck (google.protobuf.Empty) returns (google.protobuf.Empty); } ...",
  "previous": "",
  "notes": "",
  "prescan": { "...": "the reader output" }
}

-> {
  "task": "contract",
  "posture": "contract-unsafe",
  "methods": [
    {
      "method": "Orders.ListOrders",
      "pattern": "unary",
      "verdict": "unsafe",
      "idempotent": true,
      "deadline": "2s. It is a read against an indexed customer_id; anything slower is a query problem, not a client problem.",
      "errors": "INVALID_ARGUMENT for a malformed customer_id, NOT_FOUND is wrong here (an empty list is not an error), RESOURCE_EXHAUSTED once paging exists and page_size is over the cap.",
      "issue": "Returns repeated Order orders = 1 with no page_size, page_token or next_page_token. The response grows with the customer's order count and the first oversized customer trips the 4 MB default receive limit in production.",
      "fix": "Add int32 page_size = 2 and string page_token = 3 to ListOrdersRequest, and string next_page_token = 2 to ListOrdersResponse. Cap page_size server-side at 100 and document the default."
    },
    {
      "method": "Orders.HealthCheck",
      "pattern": "unary",
      "verdict": "thin",
      "idempotent": true,
      "deadline": "1s, and it should be shorter than the probe interval that calls it.",
      "errors": "UNAVAILABLE when a dependency is down. Never OK-with-a-body: a health check that returns Empty cannot say which dependency failed.",
      "issue": "google.protobuf.Empty on both sides, so there is nowhere to put the dependency status a real health check needs.",
      "fix": "Use grpc.health.v1.Health instead of hand-rolling this, or give it a HealthCheckResponse with a repeated dependency status."
    }
  ],
  "artifacts": [{"name": "operations.md", "language": "markdown", "content": "..."}],
  "...": "the rest of the common envelope"
}

task: "rollout"

Ordered steps across four phases, each with an observable gate. The numbers in notes are what the deprecation window is computed from, so put them there.

{
  "task": "rollout",
  "proto": "...", "previous": "...",
  "notes": "forty services and two mobile apps are on v1; the mobile release train is three weeks",
  "prescan": { "...": "the reader output" }
}

-> {
  "task": "rollout",
  "posture": "rollout-blocked",
  "verdict": "This is not a sequencing problem. line_items moving from 5 to 11 and ApplyCoupon being deleted cannot be ordered safely against a released client fleet; the plan below replaces them with additive equivalents first.",
  "steps": [
    {
      "order": 1,
      "phase": "schema",
      "action": "Revert line_items to number 5 and delete the map<double, string> and the field numbered 19005 so the file compiles.",
      "artifact": "acme/orders/v1/order.proto",
      "gate": "buf build succeeds and buf breaking --against '.git#branch=main' reports zero WIRE failures in CI.",
      "rollback": "Reverting the commit is enough; nothing has shipped.",
      "blocking": true
    },
    {
      "order": 2,
      "phase": "schema",
      "action": "Add reserved 8, 9; and reserved \"coupon_code\", \"internal_note\"; to Order, and re-add ApplyCoupon marked [deprecated = true].",
      "artifact": "acme/orders/v1/order.proto",
      "gate": "The published descriptor in the registry shows both reserved ranges; a grep of the generated code shows the deprecation attribute on ApplyCoupon.",
      "rollback": "The reserved lines can be removed, but any message already written under 8 or 9 cannot be un-written - treat this as one-way.",
      "blocking": true
    },
    {
      "order": 3,
      "phase": "server",
      "action": "Deploy the servers reading both buyer_id and customer_id, writing both.",
      "artifact": "orders-service",
      "gate": "The dual-write counter for buyer_id is non-zero on every pod, and the customer_id read counter has not dropped - checked on the service dashboard over one full traffic cycle.",
      "rollback": "Roll the deployment back; both fields are populated so no data is lost.",
      "blocking": true
    },
    {
      "order": 4,
      "phase": "client",
      "action": "Ship the client releases that read buyer_id.",
      "artifact": "mobile v1.9, forty service go.mod bumps",
      "gate": "The gateway user-agent breakdown shows under 1% of order traffic from clients older than v1.9, sustained for seven days. At a three-week mobile train that is two releases, so budget six to nine weeks.",
      "rollback": "Clients still read customer_id, so an older build keeps working - this step is reversible for as long as step 5 has not run.",
      "blocking": true
    },
    {
      "order": 5,
      "phase": "cleanup",
      "action": "Stop writing customer_id, reserve its number, and delete the dual-write path.",
      "artifact": "acme/orders/v1/order.proto, orders-service",
      "gate": "The customer_id read counter has been flat at zero for fourteen days and the deprecated-field metric shows no callers.",
      "rollback": "Not reversible once the number is reserved and clients have stopped writing it.",
      "blocking": false
    }
  ],
  "artifacts": [
    {"name": "rollout.md", "language": "markdown", "content": "- [ ] 1. ...\n"},
    {"name": "order.proto", "language": "proto", "content": "// reserved statements and the deprecated rpc\n..."}
  ],
  "...": "the rest of the common envelope"
}

Rate limits and cost