Drive Migration Gate from your own code
Everything the web page does is available over HTTP: post a database migration, get the same
structured production-safety audit back. The natural use is a CI job that audits every migration a
pull request adds and fails the build when verdict comes back
block — the one value that means "running this as pasted, against a database
of the stated size, with the stated downtime budget, would take the application down or lose
data". The same call run over a directory of migrations gives you ten identical checks per file,
diffable row by row.
The app slug is migration-gate. Every example on this page uses the app's own bundled
example migration — the four-statement golang-migrate file that adds a
NOT NULL column, builds an index and backfills 40M rows inside one transaction — so
you can paste any of them and get a real answer.
Base URL and the envelope
Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses
the same envelope, so one helper covers the whole API:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }
Send your app slug as X-App-Slug: migration-gate and your token as
Authorization: Bearer … on every call. error.details is free-form and
endpoint-specific; the only field worth branching on is error.code.
Error codes
| code | status | what to do |
|---|---|---|
validation_error | 400 | The input object is missing a required field — migration is the usual one — or a field is the wrong type. prescan_facts must be an object with resources and flags arrays, not a bare array; dialect, scale and downtime must be one of their documented values. |
unauthorized | 401 | The token is missing, malformed or expired. Get a new one from the token page. |
payment_required | 402 | The balance is below min_credits. Call /estimate first, compare hold_credits against the balance from /me, and top up before submitting. The web app never submits into a 402: it disables the audit button and names the shortfall instead. |
forbidden | 403 | The token is valid but not for this app, or a guest token tried a metered run. Check the X-App-Slug header, and sign in for a personal token. |
not_found | 404 | Unknown job id, unknown collection, or the app slug does not exist. An audits collection that is not declared on the release you are calling also lands here. |
conflict | 409 | The same Idempotency-Key was replayed with a different body. Bump the attempt suffix, or send the original input back unchanged. |
rate_limited | 429 | Too many requests. Back off and retry; do not tight-loop the job poller — two seconds between polls is plenty. |
internal | 5xx | A server-side failure. Retry with the SAME Idempotency-Key so you are not billed twice. |
1. Get a token
The easiest route is the token page: it shows the token this browser
already holds for migration-gate, with Copy token and
Copy shell export buttons, and a sign-in button for a personal token. You never
need to open the developer console.
A guest token, minted by POST /guest, can call /me and
/estimate. Auditing a migration is metered, so /run and
/run-stream need a personal token from signing in — a guest token
that posts a run gets a 403 forbidden. Each POST /guest also mints a
new guest subject, which matters later: the audits collection is scoped to
the calling subject, so a fresh guest token sees an empty history.
# A guest token is enough for /me and /estimate. Auditing a migration is metered
# and needs a personal token: open the token page and press "Sign in".
#
# https://migration-gate.skillsafe.ai/tokens.html
#
# That page also gives you a ready-made shell export:
# export SKILLSAFE_TOKEN="..."
#
# To mint a guest token from the command line instead:
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" -H "X-App-Slug: migration-gate"
# {"ok":true,"data":{"token":"...","guest_id":"..."}}
# Open https://migration-gate.skillsafe.ai/tokens.html and press "Copy token".
# The token page exists so you never have to dig a token out of the browser
# yourself; it also prints the `export SKILLSAFE_TOKEN=...` line.
#
# A guest token, which can call /me and /estimate but cannot run:
guest = call("guest")
TOKEN = guest["token"]
// Open https://migration-gate.skillsafe.ai/tokens.html and press "Copy token".
// A guest token can call /me and /estimate but cannot run a metered audit.
const guest = await call("guest");
// Use guest.token as the bearer for subsequent calls.
// Open https://migration-gate.skillsafe.ai/tokens.html and press "Copy token".
// Or mint a guest token, which can call /me and /estimate but cannot run:
raw, err := call("guest", map[string]any{})
if err != nil {
panic(err)
}
var guest struct {
Token string `json:"token"`
}
_ = json.Unmarshal(raw, &guest)
// Open https://migration-gate.skillsafe.ai/tokens.html and press "Copy token".
// A guest token can call /me and /estimate but cannot run a metered audit.
String guest = call("guest", "{}");
System.out.println(guest);
# Open https://migration-gate.skillsafe.ai/tokens.html and press "Copy token".
# A guest token can call /me and /estimate but cannot run a metered audit.
guest = call("guest", {})
puts guest["token"]
<?php
// Open https://migration-gate.skillsafe.ai/tokens.html and press "Copy token".
// A guest token can call /me and /estimate but cannot run a metered audit.
$guest = call("guest", []);
echo $guest["token"];
// Open https://migration-gate.skillsafe.ai/tokens.html and press "Copy token".
// A guest token can call /me and /estimate but cannot run a metered audit.
var guest = await Gate.Call("guest", new { });
Console.WriteLine(guest.GetProperty("token").GetString());
2. A tiny client
One helper that adds the headers, unwraps data and raises on error.
# Every call is the same three things: the base URL, your bearer token,
# and a JSON body. Keep the token in a shell variable.
BASE="https://api.skillsafe.ai/v1/app-api"
SLUG="migration-gate"
TOKEN="YOUR_TOKEN" # from https://migration-gate.skillsafe.ai/tokens.html
call() { # call <path> [json-body]
if [ -n "$2" ]; then
curl -sS -X POST "$BASE/$1" \
-H "Authorization: Bearer $TOKEN" \
-H "X-App-Slug: $SLUG" \
-H "Content-Type: application/json" \
-d "$2"
else
curl -sS "$BASE/$1" -H "Authorization: Bearer $TOKEN" -H "X-App-Slug: $SLUG"
fi
}
import json, os, urllib.error, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "migration-gate"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://migration-gate.skillsafe.ai/tokens.html
def call(path, body=None):
"""Returns the unwrapped `data`, or raises with the API error code."""
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(f"{BASE}/{path}", data=data, method="POST" if body is not None else "GET")
req.add_header("Authorization", f"Bearer {TOKEN}")
req.add_header("X-App-Slug", SLUG)
if body is not None:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req) as r:
payload = json.load(r)
except urllib.error.HTTPError as e:
payload = json.load(e)
if not payload.get("ok"):
err = payload.get("error", {})
raise RuntimeError(f"{err.get('code')}: {err.get('message')}")
return payload["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "migration-gate";
const TOKEN = "YOUR_TOKEN"; // from https://migration-gate.skillsafe.ai/tokens.html
async function call(path, body) {
const res = await fetch(`${BASE}/${path}`, {
method: body ? "POST" : "GET",
headers: {
Authorization: `Bearer ${TOKEN}`,
"X-App-Slug": SLUG,
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const payload = await res.json();
if (!payload.ok) throw new Error(`${payload.error.code}: ${payload.error.message}`);
return payload.data;
}
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
const (
base = "https://api.skillsafe.ai/v1/app-api"
slug = "migration-gate"
)
var token = os.Getenv("SKILLSAFE_TOKEN") // from https://migration-gate.skillsafe.ai/tokens.html
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func call(path string, body any) (json.RawMessage, error) {
method := http.MethodGet
var rdr io.Reader
if body != nil {
method = http.MethodPost
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, base+"/"+path, rdr)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-App-Slug", slug)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
res, err := http.DefaultClient.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.*;
public class Gate {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "migration-gate";
static final String TOKEN = System.getenv().getOrDefault("SKILLSAFE_TOKEN", "YOUR_TOKEN");
static final HttpClient HTTP = HttpClient.newHttpClient();
static String call(String path, String jsonBody) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + "/" + path))
.header("Authorization", "Bearer " + TOKEN)
.header("X-App-Slug", SLUG);
if (jsonBody != null) {
b.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
} else {
b.GET();
}
HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
// The envelope is always {"ok":...,"data":...} or {"ok":false,"error":...}.
return res.body();
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "migration-gate"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://migration-gate.skillsafe.ai/tokens.html
def call(path, body = nil)
uri = URI("#{BASE}/#{path}")
req = body ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["X-App-Slug"] = SLUG
if body
req["Content-Type"] = "application/json"
req.body = JSON.generate(body)
end
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise "#{payload['error']['code']}: #{payload['error']['message']}" unless payload["ok"]
payload["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "migration-gate";
define("TOKEN", getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN"); // from /tokens.html
function call(string $path, ?array $body = null) {
$ch = curl_init(BASE . "/" . $path);
$headers = ["Authorization: Bearer " . TOKEN, "X-App-Slug: " . SLUG];
if ($body !== null) {
$headers[] = "Content-Type: application/json";
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$payload = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($payload["ok"])) {
throw new RuntimeException($payload["error"]["code"] . ": " . $payload["error"]["message"]);
}
return $payload["data"];
}
using System.Net.Http.Json;
using System.Text.Json;
static class Gate
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "migration-gate";
static readonly string Token =
Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
static readonly HttpClient Http = new();
public static async Task<JsonElement> Call(string path, object? body = null)
{
var req = new HttpRequestMessage(body is null ? HttpMethod.Get : HttpMethod.Post, $"{Base}/{path}");
req.Headers.Add("Authorization", $"Bearer {Token}");
req.Headers.Add("X-App-Slug", Slug);
if (body is not null) req.Content = JsonContent.Create(body);
var res = await Http.SendAsync(req);
var payload = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!payload.GetProperty("ok").GetBoolean())
{
var e = payload.GetProperty("error");
throw new Exception($"{e.GetProperty("code")}: {e.GetProperty("message")}");
}
return payload.GetProperty("data");
}
}
3. Check the session and the balance
GET /me tells you whether the token is a guest or a person, and what the balance is.
subject_type is user for a personal token and guest for a
guest one — branch on it before you spend a call finding out the hard way. Compare
credits against hold_credits from the next step before you run, so a
shortfall surfaces as your own clear message rather than a 402. The web app does exactly this: if
the balance is under the reservation it disables the audit button, names the shortfall in credits
and offers a top-up link instead of submitting.
call me
# {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}
#
# subject_type is "guest" for a guest token; a guest can /estimate but not /run.
# credits are platform credits: 10,000 credits is one US dollar.
me = call("me")
print(me["subject_type"], me.get("username"), me.get("credits"))
if me["subject_type"] != "user":
raise SystemExit("A personal token is required to audit a migration - see /tokens.html")
const me = await call("me");
console.log(me.subject_type, me.username, me.credits);
if (me.subject_type !== "user") {
throw new Error("A personal token is required to audit a migration - see /tokens.html");
}
raw, err := call("me", nil)
if err != nil {
panic(err)
}
var me struct {
SubjectType string `json:"subject_type"`
Username string `json:"username"`
Credits int `json:"credits"`
}
_ = json.Unmarshal(raw, &me)
fmt.Println(me.SubjectType, me.Username, me.Credits)
System.out.println(call("me", null));
// {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}
me = call("me")
puts "#{me['subject_type']} #{me['credits']}"
abort "A personal token is required to audit a migration" unless me["subject_type"] == "user"
<?php
$me = call("me");
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;
if ($me["subject_type"] !== "user") {
throw new RuntimeException("A personal token is required to audit a migration");
}
var me = await Gate.Call("me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
Console.WriteLine(me.GetProperty("credits").GetInt32());
4. Price the run
POST /estimate is free, creates no job and charges nothing. The body is the
input object itself — nothing wraps it. Use it to price the audit and to prove the
app is bound to the model you expect: the reply names both the alias and the concrete model it
currently resolves to.
hold_credits is a reservation, priced as though the audit ran to the full
output cap. It is not the price. The finished job carries charged_credits, which is
what you actually pay and is usually far lower. If the balance sits between
min_credits and hold_credits the run still executes, with a reduced
output cap, and the job comes back "truncated": true — treat that as an
incomplete audit and top up rather than trusting a clipped verdict.
# The body of /estimate IS the input object. Nothing wraps it.
read -r -d '' INPUT <<'JSON'
{
"migration": "-- file: 000004_add_display_name.up.sql\nBEGIN;\nALTER TABLE users ADD COLUMN display_name TEXT NOT NULL;\nCREATE INDEX idx_users_display_name ON users (display_name);\nUPDATE users SET display_name = username;\nCOMMIT;",
"dialect": "postgres",
"scale": "large",
"downtime": "zero",
"context": "users is about 40 million rows and takes constant writes. The app deploys an hour after the migration."
}
JSON
call estimate "$INPUT"
# {"ok":true,"data":{"hold_credits":1611,"min_credits":182,"model":"gpt-5.6-terra",
# "markup_bps":1000,"model_alias":"gpt-terra","sponsor_enabled":false,"byok":false}}
INPUT = {
"migration": open("000004_add_display_name.up.sql").read(),
"dialect": "postgres", # postgres | mysql | auto
"scale": "large", # small | medium | large | unknown
"downtime": "zero", # zero | brief | window
"context": "users is ~40M rows and takes constant writes.",
}
est = call("estimate", INPUT)
print(est["model_alias"], "->", est["model"], "at", est["markup_bps"], "bps")
print("reserves", est["hold_credits"], "credits; minimum", est["min_credits"])
me = call("me")
if me["credits"] < est["min_credits"]:
raise SystemExit(f"short by {est['min_credits'] - me['credits']} credits")
const INPUT = {
migration: await readFile("000004_add_display_name.up.sql", "utf8"),
dialect: "postgres", // postgres | mysql | auto
scale: "large", // small | medium | large | unknown
downtime: "zero", // zero | brief | window
context: "users is ~40M rows and takes constant writes."
};
const est = await call("estimate", INPUT);
console.log(`${est.model_alias} -> ${est.model}, reserves ${est.hold_credits} credits`);
const me = await call("me");
if (me.credits < est.min_credits) {
throw new Error(`short by ${est.min_credits - me.credits} credits`);
}
input := map[string]any{
"migration": string(sqlBytes),
"dialect": "postgres",
"scale": "large",
"downtime": "zero",
"context": "users is ~40M rows and takes constant writes.",
}
raw, err := call("estimate", input)
if err != nil {
panic(err)
}
var est struct {
HoldCredits int `json:"hold_credits"`
MinCredits int `json:"min_credits"`
Model string `json:"model"`
ModelAlias string `json:"model_alias"`
MarkupBps int `json:"markup_bps"`
}
_ = json.Unmarshal(raw, &est)
fmt.Printf("%s -> %s, reserves %d\n", est.ModelAlias, est.Model, est.HoldCredits)
String input = """
{"migration": %s, "dialect": "postgres", "scale": "large",
"downtime": "zero", "context": "users is ~40M rows."}
""".formatted(jsonString(sql));
String est = call("estimate", input);
System.out.println(est);
// {"ok":true,"data":{"hold_credits":1611,"min_credits":182,"model":"gpt-5.6-terra",...}}
input = {
"migration" => File.read("000004_add_display_name.up.sql"),
"dialect" => "postgres",
"scale" => "large",
"downtime" => "zero",
"context" => "users is ~40M rows and takes constant writes."
}
est = call("estimate", input)
puts "#{est['model_alias']} -> #{est['model']}, reserves #{est['hold_credits']}"
abort "not enough credits" if call("me")["credits"] < est["min_credits"]
<?php
$input = [
"migration" => file_get_contents("000004_add_display_name.up.sql"),
"dialect" => "postgres",
"scale" => "large",
"downtime" => "zero",
"context" => "users is ~40M rows and takes constant writes.",
];
$est = call("estimate", $input);
printf("%s -> %s, reserves %d credits\n", $est["model_alias"], $est["model"], $est["hold_credits"]);
var input = new Dictionary<string, object?>
{
["migration"] = File.ReadAllText("000004_add_display_name.up.sql"),
["dialect"] = "postgres",
["scale"] = "large",
["downtime"] = "zero",
["context"] = "users is ~40M rows and takes constant writes."
};
var est = await Gate.Call("estimate", input);
Console.WriteLine($"reserves {est.GetProperty("hold_credits").GetInt32()} credits");
5. Run it, then poll the job
POST /run takes the same input object and returns {"job_id": "job_..."}.
Poll GET /jobs/{job_id} until status is terminal. Always send an
Idempotency-Key: a retry with the same key returns the same job instead of starting
— and billing — a second one, which matters because a network blip mid-run is exactly
when you want to retry.
The audit arrives as a string in output.output, not as a nested
object: parse it a second time. Strip a leading ```json fence defensively before
parsing, as the web app does — the contract forbids fences, but a defensive strip costs one
line and saves a failed build.
# Idempotency-Key makes a retry safe: the same key returns the same job
# instead of starting - and billing - a second one.
KEY="mg:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"
JOB=$(curl -sS -X POST "$BASE/run" \
-H "Authorization: Bearer $TOKEN" \
-H "X-App-Slug: $SLUG" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d "$INPUT" | jq -r '.data.job_id')
# Poll until terminal.
while true; do
J=$(curl -sS "$BASE/jobs/$JOB" -H "Authorization: Bearer $TOKEN" -H "X-App-Slug: $SLUG")
ST=$(printf '%s' "$J" | jq -r '.data.status')
[ "$ST" = "succeeded" ] || [ "$ST" = "failed" ] && break
sleep 2
done
# The audit itself is a JSON string inside the job output.
printf '%s' "$J" | jq -r '.data.output.output' | jq '.verdict, .headline'
import hashlib, json, time
key = "mg:" + hashlib.sha256(json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:16] + ":a1"
job = call("run", INPUT, idempotency_key=key) # add the header in your helper
job_id = job["job_id"]
while True:
j = call(f"jobs/{job_id}")
if j["status"] in ("succeeded", "failed"):
break
time.sleep(2)
if j["status"] == "failed":
raise SystemExit(j.get("error", "run failed"))
raw = j["output"]["output"] # the model reply, as a string
raw = raw.strip().removeprefix("```json").removeprefix("```").removesuffix("```")
audit = json.loads(raw)
print(audit["verdict"], "-", audit["headline"])
print("charged", j.get("charged_credits"), "credits")
if j.get("truncated"):
print("WARNING: the balance capped the output; this audit is incomplete")
import { createHash } from "node:crypto";
const key = "mg:" + createHash("sha256").update(JSON.stringify(INPUT)).digest("hex").slice(0, 16) + ":a1";
const { job_id } = await call("run", INPUT, { "Idempotency-Key": key });
let job;
for (;;) {
job = await call(`jobs/${job_id}`);
if (job.status === "succeeded" || job.status === "failed") break;
await new Promise(r => setTimeout(r, 2000));
}
if (job.status === "failed") throw new Error(job.error || "run failed");
const raw = job.output.output.trim().replace(/^```[a-z]*\s*/i, "").replace(/```$/, "");
const audit = JSON.parse(raw);
console.log(audit.verdict, "-", audit.headline);
console.log("charged", job.charged_credits, "credits");
sum := sha256.Sum256(mustJSON(input))
key := "mg:" + hex.EncodeToString(sum[:])[:16] + ":a1"
raw, err := callWithHeader("run", input, "Idempotency-Key", key)
if err != nil {
panic(err)
}
var started struct {
JobID string `json:"job_id"`
}
_ = json.Unmarshal(raw, &started)
var job struct {
Status string `json:"status"`
Output struct{ Output string `json:"output"` } `json:"output"`
Charged int `json:"charged_credits"`
}
for {
raw, _ = call("jobs/"+started.JobID, nil)
_ = json.Unmarshal(raw, &job)
if job.Status == "succeeded" || job.Status == "failed" {
break
}
time.Sleep(2 * time.Second)
}
var audit map[string]any
_ = json.Unmarshal([]byte(job.Output.Output), &audit)
fmt.Println(audit["verdict"], audit["headline"])
String key = "mg:" + sha256Hex(input).substring(0, 16) + ":a1";
String started = callWithHeader("run", input, "Idempotency-Key", key);
String jobId = readString(started, "job_id");
String job;
while (true) {
job = call("jobs/" + jobId, null);
String status = readString(job, "status");
if (status.equals("succeeded") || status.equals("failed")) break;
Thread.sleep(2000);
}
// output.output is the audit, as a JSON string that must be parsed again.
System.out.println(readString(job, "output.output"));
require "digest"
key = "mg:#{Digest::SHA256.hexdigest(JSON.dump(input))[0, 16]}:a1"
job_id = call("run", input, "Idempotency-Key" => key)["job_id"]
job = nil
loop do
job = call("jobs/#{job_id}")
break if %w[succeeded failed].include?(job["status"])
sleep 2
end
abort job.fetch("error", "run failed") if job["status"] == "failed"
audit = JSON.parse(job["output"]["output"].strip.sub(/\A```[a-z]*\s*/i, "").sub(/```\z/, ""))
puts "#{audit['verdict']} - #{audit['headline']}"
<?php
$key = "mg:" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$started = call("run", $input, ["Idempotency-Key: $key"]);
$jobId = $started["job_id"];
do {
sleep(2);
$job = call("jobs/$jobId");
} while (!in_array($job["status"], ["succeeded", "failed"], true));
if ($job["status"] === "failed") {
throw new RuntimeException($job["error"] ?? "run failed");
}
$audit = json_decode(trim($job["output"]["output"]), true);
printf("%s - %s\n", $audit["verdict"], $audit["headline"]);
var key = "mg:" + Sha256Hex(JsonSerializer.Serialize(input))[..16] + ":a1";
var started = await Gate.Call("run", input, ("Idempotency-Key", key));
var jobId = started.GetProperty("job_id").GetString();
JsonElement job;
while (true)
{
job = await Gate.Call($"jobs/{jobId}");
var status = job.GetProperty("status").GetString();
if (status is "succeeded" or "failed") break;
await Task.Delay(2000);
}
var raw = job.GetProperty("output").GetProperty("output").GetString();
using var audit = JsonDocument.Parse(raw!);
Console.WriteLine(audit.RootElement.GetProperty("verdict").GetString());
6. Stream it instead
POST /run-stream is the same call over server-sent events. Deltas arrive as they are
generated, which is what drives the progress card in the web app: it watches for the section keys
("statements", "checks", "findings",
"safe_migration") appearing in the stream and advances a named stage as each one
lands. Concatenate every text delta and parse the result exactly as in step 5. The
same Idempotency-Key rules apply.
# Server-sent events. Same body, same Idempotency-Key; the deltas arrive as
# they are generated, which is what the web app's progress card is driven by.
curl -sS -N -X POST "$BASE/run-stream" \
-H "Authorization: Bearer $TOKEN" \
-H "X-App-Slug: $SLUG" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-H "Accept: text/event-stream" \
-d "$INPUT"
# event: delta data: {"text":"{\"audit_name\":\"add display_name"}
# event: job data: {"job_id":"job_..."}
# event: done data: {"status":"succeeded","charged_credits":934}
import urllib.request
req = urllib.request.Request(f"{BASE}/run-stream", data=json.dumps(INPUT).encode(), method="POST")
for h, v in (("Authorization", f"Bearer {TOKEN}"), ("X-App-Slug", SLUG),
("Content-Type", "application/json"), ("Idempotency-Key", key),
("Accept", "text/event-stream")):
req.add_header(h, v)
parts = []
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().rstrip("\n")
if not line.startswith("data:"):
continue
payload = json.loads(line[5:].strip())
if "text" in payload:
parts.append(payload["text"])
print(".", end="", flush=True) # your progress indicator
audit = json.loads("".join(parts))
print()
print(audit["verdict"], "-", audit["headline"])
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: {
"Authorization": `Bearer ${TOKEN}`,
"X-App-Slug": SLUG,
"Content-Type": "application/json",
"Idempotency-Key": key,
"Accept": "text/event-stream"
},
body: JSON.stringify(INPUT)
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", out = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop();
for (const line of lines) {
if (!line.startsWith("data:")) continue;
const d = JSON.parse(line.slice(5).trim());
if (d.text) out += d.text;
}
}
const audit = JSON.parse(out);
console.log(audit.verdict, "-", audit.headline);
body, _ := json.Marshal(input)
req, _ := http.NewRequest("POST", BASE+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("X-App-Slug", SLUG)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
req.Header.Set("Accept", "text/event-stream")
resp, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer resp.Body.Close()
var out strings.Builder
sc := bufio.NewScanner(resp.Body)
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
for sc.Scan() {
line := sc.Text()
if !strings.HasPrefix(line, "data:") {
continue
}
var d struct{ Text string `json:"text"` }
if json.Unmarshal([]byte(strings.TrimSpace(line[5:])), &d) == nil && d.Text != "" {
out.WriteString(d.Text)
}
}
fmt.Println(out.String())
HttpRequest req = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("X-App-Slug", SLUG)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(input))
.build();
StringBuilder out = new StringBuilder();
HttpClient.newHttpClient()
.send(req, HttpResponse.BodyHandlers.ofLines())
.body()
.filter(l -> l.startsWith("data:"))
.forEach(l -> out.append(textField(l.substring(5).trim())));
System.out.println(out);
require "net/http"
uri = URI("#{BASE}/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["X-App-Slug"] = SLUG
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req["Accept"] = "text/event-stream"
req.body = JSON.dump(input)
out = +""
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
next unless line.start_with?("data:")
d = JSON.parse(line[5..].strip) rescue next
out << d["text"] if d["text"]
end
end
end
end
audit = JSON.parse(out)
puts "#{audit['verdict']} - #{audit['headline']}"
<?php
$out = "";
$ch = curl_init("$BASE/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $token",
"X-App-Slug: $slug",
"Content-Type: application/json",
"Idempotency-Key: $key",
"Accept: text/event-stream",
],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$out) {
foreach (explode("\n", $chunk) as $line) {
if (str_starts_with($line, "data:")) {
$d = json_decode(trim(substr($line, 5)), true);
if (isset($d["text"])) { $out .= $d["text"]; }
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
$audit = json_decode($out, true);
echo $audit["verdict"], " - ", $audit["headline"], PHP_EOL;
var req = new HttpRequestMessage(HttpMethod.Post, $"{Base}/run-stream");
req.Headers.Add("Authorization", $"Bearer {Token}");
req.Headers.Add("X-App-Slug", Slug);
req.Headers.Add("Idempotency-Key", key);
req.Headers.Add("Accept", "text/event-stream");
req.Content = new StringContent(JsonSerializer.Serialize(input), Encoding.UTF8, "application/json");
using var resp = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await resp.Content.ReadAsStreamAsync());
var sb = new StringBuilder();
while (await reader.ReadLineAsync() is { } line)
{
if (!line.StartsWith("data:")) continue;
using var d = JsonDocument.Parse(line[5..].Trim());
if (d.RootElement.TryGetProperty("text", out var t)) sb.Append(t.GetString());
}
Console.WriteLine(sb.ToString());
7. Gate a pull request on the verdict
The reason to drive this from code rather than from the page: a migration that reaches production
unreviewed is the failure this app exists to prevent, and CI is where that gets caught. The script
below audits a migration file, prints every statement that is not safe, and exits
non-zero when the verdict is block or any finding is critical.
Two notes before you wire it in. First, this run is metered — a gate on
every push to a busy repository spends real credits, so scope it to pull requests that actually
touch your migrations directory. Second, the audit is a review of pasted text: it is a good gate
and a bad oracle. Keep it advisory on caution and blocking only on
block until you trust it on your own schema.
#!/usr/bin/env bash
# migration-gate-ci.sh - fail the build on a blocking migration.
set -euo pipefail
SQL_FILE="$1"
INPUT=$(jq -n --rawfile m "$SQL_FILE" \
'{migration: $m, dialect: "postgres", scale: "large", downtime: "zero",
context: "CI gate on the migrations directory."}')
JOB=$(curl -sS -X POST "$BASE/run" \
-H "Authorization: Bearer $TOKEN" -H "X-App-Slug: $SLUG" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: mg:$(shasum -a 256 "$SQL_FILE" | cut -c1-16):a1" \
-d "$INPUT" | jq -r '.data.job_id')
until [ "$(curl -sS "$BASE/jobs/$JOB" -H "Authorization: Bearer $TOKEN" \
-H "X-App-Slug: $SLUG" | jq -r '.data.status')" != "running" ]; do sleep 2; done
AUDIT=$(curl -sS "$BASE/jobs/$JOB" -H "Authorization: Bearer $TOKEN" \
-H "X-App-Slug: $SLUG" | jq -r '.data.output.output')
echo "$AUDIT" | jq -r '"\(.verdict | ascii_upcase): \(.headline)"'
echo "$AUDIT" | jq -r '.findings[] | select(.priority == "critical" or .priority == "high")
| " [\(.priority)] \(.statement_ref) \(.problem)"'
VERDICT=$(echo "$AUDIT" | jq -r '.verdict')
CRIT=$(echo "$AUDIT" | jq '[.findings[] | select(.priority == "critical")] | length')
if [ "$VERDICT" = "block" ] || [ "$CRIT" -gt 0 ]; then
echo "Migration Gate blocked this migration." >&2
exit 1
fi
#!/usr/bin/env python3
"""migration_gate_ci.py - exit non-zero on a blocking migration."""
import sys
def gate(path: str) -> int:
INPUT = {
"migration": open(path).read(),
"dialect": "postgres",
"scale": "large",
"downtime": "zero",
"context": "CI gate on the migrations directory.",
}
est = call("estimate", INPUT)
if call("me")["credits"] < est["min_credits"]:
print("not enough credits to audit", path, file=sys.stderr)
return 2
audit = run_and_wait(INPUT) # steps 5 and 6 above
print(f"{audit['verdict'].upper()}: {audit['headline']}")
for s in audit["statements"]:
if s["verdict"] != "safe":
print(f" {s['ref']:>3} {s['operation']:<20} {s['lock']:<24} {s['blocks']}")
blocking = [f for f in audit["findings"] if f["priority"] == "critical"]
for f in blocking:
print(f" [{f['priority']}] {f['statement_ref']} {f['problem']}", file=sys.stderr)
print(f" fix: {f['fix']}", file=sys.stderr)
if audit["verdict"] == "block" or blocking:
print("Migration Gate blocked this migration.", file=sys.stderr)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(gate(sys.argv[1]))
#!/usr/bin/env node
// migration-gate-ci.mjs - exit non-zero on a blocking migration.
import { readFile } from "node:fs/promises";
const path = process.argv[2];
const INPUT = {
migration: await readFile(path, "utf8"),
dialect: "postgres",
scale: "large",
downtime: "zero",
context: "CI gate on the migrations directory."
};
const est = await call("estimate", INPUT);
const me = await call("me");
if (me.credits < est.min_credits) {
console.error(`not enough credits to audit ${path}`);
process.exit(2);
}
const audit = await runAndWait(INPUT); // steps 5 and 6 above
console.log(`${audit.verdict.toUpperCase()}: ${audit.headline}`);
for (const s of audit.statements) {
if (s.verdict !== "safe") {
console.log(` ${s.ref} ${s.operation} - ${s.lock} - ${s.blocks}`);
}
}
const blocking = audit.findings.filter(f => f.priority === "critical");
for (const f of blocking) {
console.error(` [${f.priority}] ${f.statement_ref} ${f.problem}`);
console.error(` fix: ${f.fix}`);
}
if (audit.verdict === "block" || blocking.length) {
console.error("Migration Gate blocked this migration.");
process.exit(1);
}
func gate(path string) int {
sql, err := os.ReadFile(path)
if err != nil {
panic(err)
}
input := map[string]any{
"migration": string(sql),
"dialect": "postgres",
"scale": "large",
"downtime": "zero",
"context": "CI gate on the migrations directory.",
}
audit := runAndWait(input) // steps 5 and 6 above
fmt.Printf("%s: %s\n", strings.ToUpper(audit.Verdict), audit.Headline)
critical := 0
for _, f := range audit.Findings {
if f.Priority == "critical" {
critical++
fmt.Fprintf(os.Stderr, " [%s] %s %s\n", f.Priority, f.StatementRef, f.Problem)
}
}
if audit.Verdict == "block" || critical > 0 {
fmt.Fprintln(os.Stderr, "Migration Gate blocked this migration.")
return 1
}
return 0
}
// MigrationGateCi.java - exit non-zero on a blocking migration.
public static void main(String[] args) throws Exception {
String sql = Files.readString(Path.of(args[0]));
String input = """
{"migration": %s, "dialect": "postgres", "scale": "large",
"downtime": "zero", "context": "CI gate on the migrations directory."}
""".formatted(jsonString(sql));
JsonNode audit = runAndWait(input); // steps 5 and 6 above
System.out.printf("%s: %s%n",
audit.get("verdict").asText().toUpperCase(), audit.get("headline").asText());
int critical = 0;
for (JsonNode f : audit.get("findings")) {
if ("critical".equals(f.get("priority").asText())) {
critical++;
System.err.printf(" [critical] %s %s%n",
f.get("statement_ref").asText(), f.get("problem").asText());
}
}
if ("block".equals(audit.get("verdict").asText()) || critical > 0) {
System.err.println("Migration Gate blocked this migration.");
System.exit(1);
}
}
#!/usr/bin/env ruby
# migration_gate_ci.rb - exit non-zero on a blocking migration.
path = ARGV.fetch(0)
input = {
"migration" => File.read(path),
"dialect" => "postgres",
"scale" => "large",
"downtime" => "zero",
"context" => "CI gate on the migrations directory."
}
audit = run_and_wait(input) # steps 5 and 6 above
puts "#{audit['verdict'].upcase}: #{audit['headline']}"
audit["statements"].reject { |s| s["verdict"] == "safe" }.each do |s|
puts " #{s['ref']} #{s['operation']} - #{s['lock']} - #{s['blocks']}"
end
blocking = audit["findings"].select { |f| f["priority"] == "critical" }
blocking.each { |f| warn " [critical] #{f['statement_ref']} #{f['problem']}" }
exit 1 if audit["verdict"] == "block" || blocking.any?
<?php
// migration-gate-ci.php - exit non-zero on a blocking migration.
$path = $argv[1];
$input = [
"migration" => file_get_contents($path),
"dialect" => "postgres",
"scale" => "large",
"downtime" => "zero",
"context" => "CI gate on the migrations directory.",
];
$audit = run_and_wait($input); // steps 5 and 6 above
printf("%s: %s\n", strtoupper($audit["verdict"]), $audit["headline"]);
$blocking = array_filter($audit["findings"], fn($f) => $f["priority"] === "critical");
foreach ($blocking as $f) {
fprintf(STDERR, " [critical] %s %s\n", $f["statement_ref"], $f["problem"]);
}
if ($audit["verdict"] === "block" || $blocking) {
fwrite(STDERR, "Migration Gate blocked this migration.\n");
exit(1);
}
// MigrationGateCi.cs - exit non-zero on a blocking migration.
var path = args[0];
var input = new Dictionary<string, object?>
{
["migration"] = File.ReadAllText(path),
["dialect"] = "postgres",
["scale"] = "large",
["downtime"] = "zero",
["context"] = "CI gate on the migrations directory."
};
var audit = await RunAndWait(input); // steps 5 and 6 above
Console.WriteLine($"{audit.GetProperty("verdict").GetString()!.ToUpper()}: " +
$"{audit.GetProperty("headline").GetString()}");
var critical = 0;
foreach (var f in audit.GetProperty("findings").EnumerateArray())
{
if (f.GetProperty("priority").GetString() != "critical") continue;
critical++;
Console.Error.WriteLine($" [critical] {f.GetProperty("statement_ref").GetString()} " +
$"{f.GetProperty("problem").GetString()}");
}
if (audit.GetProperty("verdict").GetString() == "block" || critical > 0)
{
Console.Error.WriteLine("Migration Gate blocked this migration.");
Environment.Exit(1);
}