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.
task | Lane | What it reads | Body key | Id prefix | posture |
|---|---|---|---|---|---|
schema | Schema review | Every 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 |
compat | Compatibility gate | The 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 |
contract | RPC contract | Every 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 |
rollout | Rollout plan | Who 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": { ... }}}
| HTTP | error.code | What to do |
|---|---|---|
| 401 | UNAUTHORIZED | The token is missing, malformed or expired. Get a fresh one from the tokens page. |
| 402 | INSUFFICIENT_CREDITS | The balance is below min_credits. /estimate is free, so check it first. |
| 403 | FORBIDDEN | A guest token tried a metered call. /run and /run-stream need a personal token. |
| 404 | NOT_FOUND | Usually a job id that does not exist, or a mistyped path. |
| 409 | IDEMPOTENCY_CONFLICT | The same Idempotency-Key was reused with a different body. Change the key or send the original body. |
| 422 | VALIDATION_ERROR | The input object is the wrong shape. Note that the body is the input object — do not wrap it in an input key. |
| 429 | RATE_LIMITED | Back off and retry; do not tight-loop. |
| 503 | UPSTREAM_UNAVAILABLE | The 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.
| Field | Type | Required | Meaning |
|---|---|---|---|
task | string | recommended | One of schema, compat, contract, rollout. |
proto | string | yes | The .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. |
previous | string | for compat | The 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. |
notes | string | no | What 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_note | string | no | Set 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. |
prescan | object | no | The 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.
| Field | Type | Notes |
|---|---|---|
task | string | Echoes the lane. If you omitted task, this is the lane the model chose. |
title | string | One line naming the schema and the lane. |
posture | enum | Per lane, see the table above. An unrecognised value is coerced to the lane’s middle value. |
confidence | enum | high / medium / low. |
verdict | string | Two or three sentences: the answer, and the single fact that decides it. |
exec_summary | string | One paragraph, 120–220 words, for whoever approves the merge. |
findings[] | array | id (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”. |
summary | string | Two or three sentences, the part somebody pastes into the PR. |
The lane arrays
| Lane | Key | Row shape |
|---|---|---|
schema | rules[] | 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. |
compat | changes[] | 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. |
contract | methods[] | 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. |
rollout | steps[] | 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
}
import json, time, urllib.request, urllib.error
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://proto-gate.skillsafe.ai/tokens.html
class AppError(Exception):
def __init__(self, code, message, details=None):
super().__init__(f"{code}: {message}")
self.code, self.details = code, details or {}
def call(path, body=None, idempotency_key=None):
headers = {"Authorization": "Bearer " + TOKEN}
data = None
if body is not None:
headers["Content-Type"] = "application/json"
data = json.dumps(body).encode()
if idempotency_key:
headers["Idempotency-Key"] = idempotency_key
req = urllib.request.Request(BASE + "/" + path, data=data, headers=headers)
try:
raw = urllib.request.urlopen(req, timeout=180).read()
except urllib.error.HTTPError as ex:
raw = ex.read()
env = json.loads(raw)
if not env.get("ok"):
err = env.get("error") or {}
raise AppError(err.get("code", "UNKNOWN"), err.get("message", ""), err.get("details"))
return env["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // from https://proto-gate.skillsafe.ai/tokens.html
class AppError extends Error {
constructor(code, message, details) {
super(`${code}: ${message}`);
this.code = code;
this.details = details || {};
}
}
async function call(path, body, idempotencyKey) {
const headers = { Authorization: `Bearer ${TOKEN}` };
if (body !== undefined) headers["Content-Type"] = "application/json";
if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
const res = await fetch(`${BASE}/${path}`, {
method: body === undefined ? "GET" : "POST",
headers,
body: body === undefined ? undefined : JSON.stringify(body)
});
const env = await res.json();
if (!env.ok) {
const err = env.error || {};
throw new AppError(err.code || "UNKNOWN", err.message || "", err.details);
}
return env.data;
}
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"time"
)
const base = "https://api.skillsafe.ai/v1/app-api"
// Read the token from the environment so it never lands in source control.
// os.Getenv("SKILLSAFE_APP_TOKEN") is set by the "copy shell export" button
// on https://proto-gate.skillsafe.ai/tokens.html
var token = os.Getenv("SKILLSAFE_APP_TOKEN")
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
Details json.RawMessage `json:"details"`
} `json:"error"`
}
func call(path string, body any, idem string) (json.RawMessage, error) {
var rdr io.Reader
method := http.MethodGet
if body != nil {
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
method = http.MethodPost
}
req, _ := http.NewRequest(method, base+"/"+path, rdr)
req.Header.Set("Authorization", "Bearer "+token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
if idem != "" {
req.Header.Set("Idempotency-Key", idem)
}
res, err := (&http.Client{Timeout: 180 * time.Second}).Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if !env.OK {
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.*;
import java.time.Duration;
public final class ProtoGate {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = "YOUR_TOKEN"; // tokens.html has a copy button
static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(20)).build();
static String call(String path, String jsonBody, String idempotencyKey)
throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/" + path))
.timeout(Duration.ofMinutes(3))
.header("Authorization", "Bearer " + TOKEN);
if (idempotencyKey != null) b.header("Idempotency-Key", idempotencyKey);
if (jsonBody == null) {
b.GET();
} else {
b.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
}
HttpResponse<String> r = HTTP.send(b.build(),
HttpResponse.BodyHandlers.ofString());
// Parse with your JSON library of choice; check "ok" before "data".
if (!r.body().contains("\"ok\":true")) {
throw new IllegalStateException("call failed: " + r.body());
}
return r.body();
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://proto-gate.skillsafe.ai/tokens.html
class AppError < StandardError
attr_reader :code, :details
def initialize(code, message, details = {})
super("#{code}: #{message}")
@code, @details = code, details
end
end
def call(path, body = nil, idempotency_key: nil)
uri = URI("#{BASE}/#{path}")
req = body.nil? ? Net::HTTP::Get.new(uri) : Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Idempotency-Key"] = idempotency_key if idempotency_key
unless body.nil?
req["Content-Type"] = "application/json"
req.body = JSON.generate(body)
end
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true,
read_timeout: 180) { |h| h.request(req) }
env = JSON.parse(res.body)
unless env["ok"]
err = env["error"] || {}
raise AppError.new(err["code"] || "UNKNOWN", err["message"] || "", err["details"])
end
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // from https://proto-gate.skillsafe.ai/tokens.html
function call(string $path, ?array $body = null, ?string $idem = null): array {
$headers = ["Authorization: Bearer " . TOKEN];
if ($idem !== null) { $headers[] = "Idempotency-Key: " . $idem; }
$opts = ["http" => [
"method" => $body === null ? "GET" : "POST",
"timeout" => 180,
"ignore_errors" => true,
]];
if ($body !== null) {
$headers[] = "Content-Type: application/json";
$opts["http"]["content"] = json_encode($body);
}
$opts["http"]["header"] = implode("\r\n", $headers);
$raw = file_get_contents(BASE . "/" . $path, false,
stream_context_create($opts));
$env = json_decode($raw, true);
if (empty($env["ok"])) {
$err = $env["error"] ?? [];
throw new RuntimeException(($err["code"] ?? "UNKNOWN") . ": " .
($err["message"] ?? ""));
}
return $env["data"];
}
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
public static class ProtoGate
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Token = "YOUR_TOKEN"; // from tokens.html
static readonly HttpClient Http = new HttpClient
{
Timeout = TimeSpan.FromMinutes(3)
};
public static async Task<JsonElement> Call(
string path, object body = null, string idempotencyKey = null)
{
var req = new HttpRequestMessage(
body == null ? HttpMethod.Get : HttpMethod.Post, $"{Base}/{path}");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
if (idempotencyKey != null)
req.Headers.Add("Idempotency-Key", idempotencyKey);
if (body != null)
req.Content = new StringContent(JsonSerializer.Serialize(body),
Encoding.UTF8, "application/json");
var res = await Http.SendAsync(req);
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
var root = doc.RootElement.Clone();
if (!root.GetProperty("ok").GetBoolean())
{
var err = root.GetProperty("error");
throw new InvalidOperationException(
err.GetProperty("code").GetString() + ": " +
err.GetProperty("message").GetString());
}
return root.GetProperty("data").Clone();
}
}
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":"..."}}
guest = call("guest", {"slug": "proto-gate"})
TOKEN = guest["token"] # rebind the module-level token
print(guest["type"], guest.get("expires_at"))
const guest = await call("guest", { slug: "proto-gate" });
// keep guest.token; it is what every later call authorises with
console.log(guest.type, guest.expires_at);
raw, err := call("guest", map[string]string{"slug": "proto-gate"}, "")
if err != nil {
panic(err)
}
var guest struct {
Token string `json:"token"`
Type string `json:"type"`
}
_ = json.Unmarshal(raw, &guest)
token = guest.Token // rebind the package-level token
String guest = ProtoGate.call("guest",
"{\"slug\":\"proto-gate\"}", null);
// pull data.token out with your JSON library and use it as the bearer
guest = call("guest", { "slug" => "proto-gate" })
TOKEN_VALUE = guest["token"]
puts "#{guest["type"]} expires #{guest["expires_at"]}"
$guest = call("guest", ["slug" => "proto-gate"]);
$token = $guest["token"];
echo $guest["type"], PHP_EOL;
var guest = await ProtoGate.Call("guest", new { slug = "proto-gate" });
var token = guest.GetProperty("token").GetString();
Console.WriteLine(guest.GetProperty("type").GetString());
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}}
me = call("me")
print(me["type"], me["credits"], "credits")
const me = await call("me");
console.log(me.type, me.credits, "credits");
raw, err := call("me", nil, "")
if err != nil {
panic(err)
}
var me struct {
Type string `json:"type"`
Credits int `json:"credits"`
}
_ = json.Unmarshal(raw, &me)
fmt.Println(me.Type, me.Credits)
String me = ProtoGate.call("me", null, null);
System.out.println(me);
me = call("me")
puts "#{me["type"]} #{me["credits"]} credits"
$me = call("me");
echo $me["type"], " ", $me["credits"], " credits", PHP_EOL;
var me = await ProtoGate.Call("me");
Console.WriteLine($"{me.GetProperty("type").GetString()} " +
$"{me.GetProperty("credits").GetInt32()} credits");
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}}
payload = {
"task": "compat",
"proto": open("acme/orders/v1/order.proto").read(),
"previous": open("/tmp/order.released.proto").read(),
"notes": "merging Thursday; forty services and two mobile apps are on v1",
}
est = call("estimate", payload)
assert est["model_alias"] == "gpt-terra", est
assert est["markup_bps"] == 1000, est
me = call("me")
if me["credits"] < est["hold_credits"]:
raise SystemExit(
f"short by {est['hold_credits'] - me['credits']} credits - top up first")
print("hold", est["hold_credits"], "min", est["min_credits"])
const payload = {
task: "compat",
proto: currentProto, // a string: the .proto under review
previous: releasedProto, // a string: the revision already published
notes: "merging Thursday; forty services and two mobile apps are on v1"
};
const est = await call("estimate", payload);
if (est.model_alias !== "gpt-terra" || est.markup_bps !== 1000) {
throw new Error(`unexpected binding: ${est.model_alias} @ ${est.markup_bps}`);
}
const me = await call("me");
if (me.credits < est.hold_credits) {
throw new Error(`short by ${est.hold_credits - me.credits} credits`);
}
console.log("hold", est.hold_credits, "min", est.min_credits);
payload := map[string]any{
"task": "compat",
"proto": currentProto,
"previous": releasedProto,
"notes": "merging Thursday; forty services are on v1",
}
raw, err := call("estimate", payload, "")
if err != nil {
panic(err)
}
var est struct {
Model string `json:"model"`
ModelAlias string `json:"model_alias"`
MarkupBps int `json:"markup_bps"`
HoldCredits int `json:"hold_credits"`
MinCredits int `json:"min_credits"`
}
_ = json.Unmarshal(raw, &est)
if est.ModelAlias != "gpt-terra" || est.MarkupBps != 1000 {
panic(fmt.Sprintf("unexpected binding: %s @ %d", est.ModelAlias, est.MarkupBps))
}
fmt.Println("hold", est.HoldCredits, "min", est.MinCredits)
String body = """
{
"task": "compat",
"proto": "%s",
"previous": "%s",
"notes": "merging Thursday; forty services are on v1"
}
""".formatted(escapedCurrentProto, escapedReleasedProto);
String est = ProtoGate.call("estimate", body, null);
// Assert model_alias == "gpt-terra" and markup_bps == 1000 before you run,
// then compare hold_credits against the credits from /me.
System.out.println(est);
payload = {
"task" => "compat",
"proto" => File.read("acme/orders/v1/order.proto"),
"previous" => File.read("/tmp/order.released.proto"),
"notes" => "merging Thursday; forty services are on v1"
}
est = call("estimate", payload)
raise "unexpected binding" unless est["model_alias"] == "gpt-terra" &&
est["markup_bps"] == 1000
me = call("me")
if me["credits"] < est["hold_credits"]
abort "short by #{est["hold_credits"] - me["credits"]} credits"
end
puts "hold #{est["hold_credits"]} min #{est["min_credits"]}"
$payload = [
"task" => "compat",
"proto" => file_get_contents("acme/orders/v1/order.proto"),
"previous" => file_get_contents("/tmp/order.released.proto"),
"notes" => "merging Thursday; forty services are on v1",
];
$est = call("estimate", $payload);
if ($est["model_alias"] !== "gpt-terra" || $est["markup_bps"] !== 1000) {
throw new RuntimeException("unexpected model binding");
}
$me = call("me");
if ($me["credits"] < $est["hold_credits"]) {
throw new RuntimeException("short by " .
($est["hold_credits"] - $me["credits"]) . " credits");
}
echo "hold ", $est["hold_credits"], PHP_EOL;
var payload = new
{
task = "compat",
proto = currentProto,
previous = releasedProto,
notes = "merging Thursday; forty services are on v1"
};
var est = await ProtoGate.Call("estimate", payload);
if (est.GetProperty("model_alias").GetString() != "gpt-terra" ||
est.GetProperty("markup_bps").GetInt32() != 1000)
{
throw new InvalidOperationException("unexpected model binding");
}
var hold = est.GetProperty("hold_credits").GetInt32();
var me = await ProtoGate.Call("me");
if (me.GetProperty("credits").GetInt32() < hold)
throw new InvalidOperationException($"short by credits for a {hold} hold");
Console.WriteLine($"hold {hold}");
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"
import hashlib, json, time
def idem_key(task, proto, previous, notes, attempt=0):
h = hashlib.sha256(("|".join([proto, previous, notes])).encode()).hexdigest()[:16]
return f"proto-gate:{task}:{h}:a{attempt}"
key = idem_key(payload["task"], payload["proto"],
payload["previous"], payload["notes"])
job = call("run", payload, idempotency_key=key)
deadline = time.time() + 300
while time.time() < deadline:
j = call(f"jobs/{job['job_id']}")
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
if j["status"] != "succeeded":
raise SystemExit("run " + j["status"] + ": " + str(j.get("error")))
# The reply is one JSON object. Strip fences defensively before parsing.
text = j["output"].strip().removeprefix("```json").removeprefix("```").removesuffix("```")
result = json.loads(text[text.index("{"):text.rindex("}") + 1])
print(result["task"], result["posture"], len(result["changes"]), "changes")
import { createHash } from "node:crypto";
function idemKey(task, proto, previous, notes, attempt = 0) {
const h = createHash("sha256")
.update([proto, previous, notes].join("|")).digest("hex").slice(0, 16);
return `proto-gate:${task}:${h}:a${attempt}`;
}
const key = idemKey(payload.task, payload.proto, payload.previous, payload.notes);
const job = await call("run", payload, key);
let j;
const deadline = Date.now() + 300000;
while (Date.now() < deadline) {
j = await call(`jobs/${job.job_id}`);
if (["succeeded", "failed", "cancelled"].includes(j.status)) break;
await new Promise((r) => setTimeout(r, 2000));
}
if (j.status !== "succeeded") throw new Error(`run ${j.status}`);
const t = j.output.trim().replace(/^```[a-z]*\s*/i, "").replace(/```\s*$/, "");
const result = JSON.parse(t.slice(t.indexOf("{"), t.lastIndexOf("}") + 1));
console.log(result.task, result.posture, result.changes.length, "changes");
import "crypto/sha256"
func idemKey(task, proto, previous, notes string, attempt int) string {
sum := sha256.Sum256([]byte(proto + "|" + previous + "|" + notes))
return fmt.Sprintf("proto-gate:%s:%x:a%d", task, sum[:8], attempt)
}
key := idemKey("compat", currentProto, releasedProto, notes, 0)
raw, err := call("run", payload, key)
if err != nil {
panic(err)
}
var job struct{ JobID string `json:"job_id"` }
_ = json.Unmarshal(raw, &job)
var st struct {
Status string `json:"status"`
Output string `json:"output"`
}
for deadline := time.Now().Add(5 * time.Minute); time.Now().Before(deadline); {
raw, err = call("jobs/"+job.JobID, nil, "")
if err != nil {
panic(err)
}
_ = json.Unmarshal(raw, &st)
if st.Status == "succeeded" || st.Status == "failed" || st.Status == "cancelled" {
break
}
time.Sleep(2 * time.Second)
}
fmt.Println(st.Status, len(st.Output), "chars of JSON")
String key = "proto-gate:compat:" +
Integer.toHexString((currentProto + releasedProto).hashCode()) + ":a0";
String started = ProtoGate.call("run", body, key);
String jobId = /* data.job_id from your JSON library */ "";
String state = "queued";
long deadline = System.currentTimeMillis() + 300_000L;
while (System.currentTimeMillis() < deadline) {
String s = ProtoGate.call("jobs/" + jobId, null, null);
if (s.contains("\"status\":\"succeeded\"")
|| s.contains("\"status\":\"failed\"")) {
state = s;
break;
}
Thread.sleep(2000L);
}
System.out.println(state);
require "digest"
def idem_key(task, proto, previous, notes, attempt = 0)
h = Digest::SHA256.hexdigest([proto, previous, notes].join("|"))[0, 16]
"proto-gate:#{task}:#{h}:a#{attempt}"
end
key = idem_key(payload["task"], payload["proto"],
payload["previous"], payload["notes"])
job = call("run", payload, idempotency_key: key)
state = nil
deadline = Time.now + 300
while Time.now < deadline
state = call("jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(state["status"])
sleep 2
end
abort "run #{state["status"]}" unless state["status"] == "succeeded"
text = state["output"].strip.sub(/\A```[a-z]*\s*/i, "").sub(/```\s*\z/, "")
result = JSON.parse(text[text.index("{")..text.rindex("}")])
puts "#{result["task"]} #{result["posture"]} #{result["changes"].size} changes"
$key = "proto-gate:" . $payload["task"] . ":" .
substr(hash("sha256", $payload["proto"] . "|" . $payload["previous"]), 0, 16) .
":a0";
$job = call("run", $payload, $key);
$state = null;
$deadline = time() + 300;
while (time() < $deadline) {
$state = call("jobs/" . $job["job_id"]);
if (in_array($state["status"], ["succeeded", "failed", "cancelled"], true)) {
break;
}
sleep(2);
}
if ($state["status"] !== "succeeded") {
throw new RuntimeException("run " . $state["status"]);
}
$text = trim($state["output"]);
$result = json_decode(substr($text, strpos($text, "{"),
strrpos($text, "}") - strpos($text, "{") + 1), true);
echo $result["task"], " ", $result["posture"], PHP_EOL;
using System.Security.Cryptography;
static string IdemKey(string task, string proto, string previous, int attempt = 0)
{
using var sha = SHA256.Create();
var h = Convert.ToHexString(
sha.ComputeHash(Encoding.UTF8.GetBytes(proto + "|" + previous)))[..16];
return $"proto-gate:{task}:{h}:a{attempt}".ToLowerInvariant();
}
var key = IdemKey("compat", currentProto, releasedProto);
var job = await ProtoGate.Call("run", payload, key);
var jobId = job.GetProperty("job_id").GetString();
JsonElement state = default;
var deadline = DateTime.UtcNow.AddMinutes(5);
while (DateTime.UtcNow < deadline)
{
state = await ProtoGate.Call($"jobs/{jobId}");
var s = state.GetProperty("status").GetString();
if (s is "succeeded" or "failed" or "cancelled") break;
await Task.Delay(2000);
}
Console.WriteLine(state.GetProperty("status").GetString());
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}
import urllib.request
req = urllib.request.Request(
BASE + "/run-stream",
data=json.dumps(payload).encode(),
headers={
"Authorization": "Bearer " + TOKEN,
"Content-Type": "application/json",
"Accept": "text/event-stream",
"Idempotency-Key": key,
},
)
buf = []
with urllib.request.urlopen(req, timeout=300) as res:
for line in res:
line = line.decode().rstrip("\n")
if not line.startswith("data:"):
continue
evt = json.loads(line[5:].strip())
if "delta" in evt:
buf.append(evt["delta"])
elif evt.get("status") == "failed":
raise SystemExit("run failed: " + str(evt.get("error")))
text = "".join(buf)
result = json.loads(text[text.index("{"):text.rindex("}") + 1])
print(result["posture"], "-", result["verdict"][:80])
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
Accept: "text/event-stream",
"Idempotency-Key": key
},
body: JSON.stringify(payload)
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let carry = "", out = "";
for (;;) {
const { value, done } = await reader.read();
if (done) break;
carry += dec.decode(value, { stream: true });
const lines = carry.split("\n");
carry = lines.pop();
for (const line of lines) {
if (!line.startsWith("data:")) continue;
const evt = JSON.parse(line.slice(5).trim());
if (evt.delta) out += evt.delta;
}
}
const result = JSON.parse(out.slice(out.indexOf("{"), out.lastIndexOf("}") + 1));
console.log(result.posture, result.changes.length, "changes");
import "bufio"
b, _ := json.Marshal(payload)
req, _ := http.NewRequest(http.MethodPost, base+"/run-stream", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
req.Header.Set("Idempotency-Key", key)
res, err := (&http.Client{Timeout: 5 * time.Minute}).Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var out bytes.Buffer
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 1<<20), 1<<22)
for sc.Scan() {
line := sc.Text()
if len(line) < 6 || line[:5] != "data:" {
continue
}
var evt struct {
Delta string `json:"delta"`
Status string `json:"status"`
}
if json.Unmarshal([]byte(line[5:]), &evt) == nil && evt.Delta != "" {
out.WriteString(evt.Delta)
}
}
fmt.Println(out.Len(), "chars of JSON")
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(ProtoGate.BASE + "/run-stream"))
.timeout(Duration.ofMinutes(5))
.header("Authorization", "Bearer " + ProtoGate.TOKEN)
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
StringBuilder out = new StringBuilder();
ProtoGate.HTTP.send(req, HttpResponse.BodyHandlers.ofLines())
.body()
.filter(l -> l.startsWith("data:"))
.forEach(l -> out.append(extractDelta(l.substring(5))));
System.out.println(out.length() + " chars of JSON");
uri = URI("#{BASE}/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Accept"] = "text/event-stream"
req["Idempotency-Key"] = key
req.body = JSON.generate(payload)
out = +""
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true, read_timeout: 300) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
next unless line.start_with?("data:")
evt = JSON.parse(line[5..].strip) rescue next
out << evt["delta"] if evt["delta"]
end
end
end
end
result = JSON.parse(out[out.index("{")..out.rindex("}")])
puts "#{result["posture"]} #{result["changes"].size} changes"
$ctx = stream_context_create(["http" => [
"method" => "POST",
"timeout" => 300,
"header" => implode("\r\n", [
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
"Accept: text/event-stream",
"Idempotency-Key: " . $key,
]),
"content" => json_encode($payload),
]]);
$fh = fopen(BASE . "/run-stream", "r", false, $ctx);
$out = "";
while (($line = fgets($fh)) !== false) {
if (strncmp($line, "data:", 5) !== 0) { continue; }
$evt = json_decode(trim(substr($line, 5)), true);
if (isset($evt["delta"])) { $out .= $evt["delta"]; }
}
fclose($fh);
$result = json_decode(substr($out, strpos($out, "{"),
strrpos($out, "}") - strpos($out, "{") + 1), true);
echo $result["posture"], PHP_EOL;
var req = new HttpRequestMessage(HttpMethod.Post,
"https://api.skillsafe.ai/v1/app-api/run-stream");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", token);
req.Headers.Add("Accept", "text/event-stream");
req.Headers.Add("Idempotency-Key", key);
req.Content = new StringContent(JsonSerializer.Serialize(payload),
Encoding.UTF8, "application/json");
using var res = await Http.SendAsync(
req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
var sb = new StringBuilder();
string line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (!line.StartsWith("data:")) continue;
using var doc = JsonDocument.Parse(line[5..].Trim());
if (doc.RootElement.TryGetProperty("delta", out var d))
sb.Append(d.GetString());
}
Console.WriteLine($"{sb.Length} chars of JSON");
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
/estimate,/meand/guestare free and can be called freely; back off on a 429 rather than tight-looping./runand/run-streamare metered against the caller’s credit balance, with a 10% publisher markup (markup_bps: 1000).- The hold prices the full output cap. What is actually charged is usually far lower — read
charged_creditsafter settlement, nothold_credits. - If the balance sits between
min_creditsandhold_creditsthe run still executes with a reduced output cap and the reply carriestruncated: true. A truncated JSON object still holds complete sections; do not throw the whole thing away. - Guest tokens are refused (403) on the metered paths. Sign in for a personal token.