Proof Desk — API

Paste the “it’s done” report and the output behind it — get a per-claim verdict.

API tokens Open the app

Gate a completion claim from your own scripts

Send the completion report — your status message, a PR description, a coding agent's “it's done” summary — together with whatever terminal, test or CI output you actually have, and get back one JSON object: a pass / conditional / blocked gate, every claim in the report as a ledger row with a status, the verbatim line of the log that supports it, the exact command that would settle it and what output would count as proof, plus the commands to run next in cheapest-first order. Everything this app does goes through the SkillSafe App API — plain JSON over HTTPS — so the gate can sit wherever completion is claimed: in front of a merge queue, at the end of an agent's run, or in the pipeline step after the tests, reading the pipeline's own log. Pick a language once and the whole page follows.

Basics

Base URL: https://api.skillsafe.ai/v1/app-api, app slug proof-desk. Every request sends Authorization: Bearer <token> and JSON bodies with Content-Type: application/json. Responses are wrapped in an envelope: {"data": …} on success, {"error": {"code", "message"}} on failure. The gate itself is produced by the gpt-terra model. Estimates are free; runs are metered against your credit balance. There is a single run task — one report plus one log in, one gate out, no follow-up calls and no session state to carry. Nothing is ever executed on your behalf: the service reads the log you paste and writes down the commands that would settle what the log does not.

StatusMeaning
401Missing, malformed or expired token — mint a new one (step 1) and retry once.
402Not enough credits to place the hold for this gate — top up at skillsafe.ai/account/credits. The estimate's min_credits is the floor below which a run will not start.
404Unknown job id (or a job belonging to another subject). Job ids are only readable by the token that created them.
422The body did not validate — claims missing or empty, or a bad enum in stakes / strictness. The message names the field. The app itself also refuses reports under 40 characters client-side; that is a UI rule, not a server one.
429Too many requests in flight for this subject. Back off and retry — reuse the same Idempotency-Key so a retry cannot start a second, double-charged run.
5xxTransient platform error — retry with backoff, again with the same Idempotency-Key.

Browsers enforce CORS for this API, so run these examples from a server, script or terminal — not from another website's frontend.

Step 0 — A tiny client

Every task below is a single HTTP call, so start with a short helper that adds the auth header, sends JSON and unwraps the data envelope. The later steps reuse it.

export API="https://api.skillsafe.ai/v1/app-api"
export TOKEN="YOUR_TOKEN"      # see step 1

# every call looks like:
#   curl -s "$API/..." -H "Authorization: Bearer $TOKEN" [-d '{json}']
# jq is used below to pull fields out of the {"data": ...} envelope
import json, requests

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN"  # see step 1 — read it from your shell environment in real code

def api(method, path, body=None, **headers):
    res = requests.request(method, API + path, json=body,
                           headers={"Authorization": f"Bearer {TOKEN}", **headers})
    payload = res.json()
    if not res.ok:
        raise RuntimeError(payload.get("error", {}).get("message", res.reason))
    return payload["data"]
// Node 18+ (built-in fetch)
const API = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // see step 1 — read it from your shell environment in real code

async function api(method, path, body, extraHeaders = {}) {
  const res = await fetch(API + path, {
    method,
    headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", ...extraHeaders },
    body: body === undefined ? undefined : JSON.stringify(body),
  });
  const json = await res.json();
  if (!res.ok) throw new Error(json.error?.message ?? res.statusText);
  return json.data;
}
package main

import (
	"bytes"
	"encoding/json"
	"fmt"
	"net/http"
	"os"
)

const API = "https://api.skillsafe.ai/v1/app-api"

var token = os.Getenv("SKILLSAFE_TOKEN") // see step 1

func call(method, path string, body, out any) error {
	var buf bytes.Buffer
	if body != nil {
		json.NewEncoder(&buf).Encode(body)
	}
	req, _ := http.NewRequest(method, API+path, &buf)
	req.Header.Set("Authorization", "Bearer "+token)
	req.Header.Set("Content-Type", "application/json")
	res, err := http.DefaultClient.Do(req)
	if err != nil {
		return err
	}
	defer res.Body.Close()
	var env struct {
		Data  json.RawMessage `json:"data"`
		Error *struct{ Message string `json:"message"` } `json:"error"`
	}
	json.NewDecoder(res.Body).Decode(&env)
	if res.StatusCode >= 400 {
		return fmt.Errorf("api %s %s: %s", method, path, env.Error.Message)
	}
	if out == nil {
		return nil
	}
	return json.Unmarshal(env.Data, out)
}
// Java 17+, no dependencies. Pair with your JSON library (Jackson, Gson…)
// to read fields out of the returned envelope.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;

public class SkillSafe {
    static final String API = "https://api.skillsafe.ai/v1/app-api";
    static final String TOKEN = System.getenv("SKILLSAFE_TOKEN"); // see step 1
    static final HttpClient HTTP = HttpClient.newHttpClient();

    static String api(String method, String path, String jsonBody) throws Exception {
        var req = HttpRequest.newBuilder(URI.create(API + path))
            .header("Authorization", "Bearer " + TOKEN)
            .header("Content-Type", "application/json")
            .method(method, jsonBody == null
                ? HttpRequest.BodyPublishers.noBody()
                : HttpRequest.BodyPublishers.ofString(jsonBody))
            .build();
        var res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
        if (res.statusCode() >= 400) throw new RuntimeException(res.body());
        return res.body(); // envelope: {"data": …}
    }
}
require "net/http"
require "json"

API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN") # see step 1

def api(method, path, body = nil)
  uri = URI(API + path)
  req = Net::HTTP.const_get(method.capitalize).new(uri)
  req["Authorization"] = "Bearer #{TOKEN}"
  req["Content-Type"] = "application/json"
  req.body = body.to_json if body
  res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
  payload = JSON.parse(res.body)
  raise (payload.dig("error", "message") || res.message) unless res.is_a?(Net::HTTPSuccess)
  payload["data"]
end
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN"); // see step 1

function api(string $method, string $path, ?array $body = null): mixed {
    global $TOKEN;
    $ch = curl_init(API . $path);
    curl_setopt_array($ch, [
        CURLOPT_CUSTOMREQUEST  => $method,
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_HTTPHEADER     => [
            "Authorization: Bearer $TOKEN",
            "Content-Type: application/json",
        ],
        CURLOPT_POSTFIELDS     => $body === null ? null : json_encode($body),
    ]);
    $payload = json_decode(curl_exec($ch), true);
    $status  = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
    curl_close($ch);
    if ($status >= 400) {
        throw new Exception($payload["error"]["message"] ?? "HTTP $status");
    }
    return $payload["data"];
}
// .NET 8+
using System.Net.Http.Json;
using System.Text.Json;

static class SkillSafe
{
    const string Api = "https://api.skillsafe.ai/v1/app-api";
    static readonly HttpClient Http = new();

    static SkillSafe() =>
        Http.DefaultRequestHeaders.Authorization =
            new("Bearer", Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN")); // see step 1

    public static async Task<JsonElement> ApiAsync(HttpMethod method, string path, object? body = null)
    {
        var req = new HttpRequestMessage(method, Api + path);
        if (body != null) req.Content = JsonContent.Create(body);
        var res = await Http.SendAsync(req);
        var json = await res.Content.ReadFromJsonAsync<JsonElement>();
        if (!res.IsSuccessStatusCode)
            throw new Exception(json.GetProperty("error").GetProperty("message").GetString());
        return json.GetProperty("data");
    }
}

Step 1 — Get a token

POST /guest

A guest token lets you check balances and estimate costs for free. For metered gate runs billed to your own account, use your personal token: open the token page, sign in with SkillSafe, and press Copy shell export — it puts export SKILLSAFE_TOKEN="…" on your clipboard, which every example below reads. Treat the token like a password: it can spend your credits. For fully headless scripts — a CI job that has no browser at all — POST /guest mints a guest token with no browser involved.

curl -s -X POST "$API/guest" \
  -H "Content-Type: application/json" \
  -d '{"slug":"proof-desk"}' | jq -r '.data.token'
token = api("POST", "/guest", {"slug": "proof-desk"})["token"]
const { token } = await api("POST", "/guest", { slug: "proof-desk" });
var guest struct{ Token string `json:"token"` }
err := call("POST", "/guest", map[string]string{"slug": "proof-desk"}, &guest)
String envelope = api("POST", "/guest", """
    {"slug":"proof-desk"}""");
// token is at data.token in the returned JSON
token = api("POST", "/guest", { slug: "proof-desk" })["token"]
$token = api("POST", "/guest", ["slug" => "proof-desk"])["token"];
var guest = await SkillSafe.ApiAsync(HttpMethod.Post, "/guest",
    new { slug = "proof-desk" });
var token = guest.GetProperty("token").GetString();

The app stores this browser's token under the localStorage key skillsafe_app_token:proof-desk, on the app's own origin. The token page reads and manages it for you — you never need to open developer tools.

Step 2 — Check who you are and your balance

GET /me

Returns subject_type ("user" or "guest"), subject_id and your credits balance, in credits where 10,000 credits is one US dollar. Check this before a pipeline starts gating every merge: a guest subject can estimate but cannot spend, and a run that starts below the estimate's min_credits is a 402.

curl -s "$API/me" -H "Authorization: Bearer $TOKEN" | jq '.data'
# => {"subject_type":"user","subject_id":"usr_...","credits":184213}
me = api("GET", "/me")
print(me["subject_type"], me["credits"], "credits = $%.2f" % (me["credits"] / 10000))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits, "credits = $" + (me.credits / 10000).toFixed(2));
var me struct {
	SubjectType string `json:"subject_type"`
	SubjectID   string `json:"subject_id"`
	Credits     int64  `json:"credits"`
}
err := call("GET", "/me", nil, &me)
fmt.Printf("%s %d credits = $%.2f\n", me.SubjectType, me.Credits, float64(me.Credits)/10000)
String envelope = api("GET", "/me", null);
// data.subject_type, data.subject_id, data.credits
me = api("GET", "/me")
puts "#{me["subject_type"]}: #{me["credits"]} credits"
$me = api("GET", "/me");
echo "{$me['subject_type']}: {$me['credits']} credits\n";
var me = await SkillSafe.ApiAsync(HttpMethod.Get, "/me");
Console.WriteLine($"{me.GetProperty("subject_type")}: {me.GetProperty("credits")} credits");

Step 3 — Estimate the cost, and prove the model binding

POST /estimate

Send exactly the input you would send to /run; the response carries model, model_alias, markup_bps, hold_credits, min_credits and sponsor_enabled. Nothing is charged and no job is created, so estimating is free. Present hold_credits as reserved, never as the price: the hold covers the full output cap and the settled charged_credits is usually much lower. This is also the cheapest place to assert that you are talking to the model you think you are — model_alias is gpt-terra and markup_bps is 1000 for this app.

Input fieldTypeNotes
claimsstring, requiredThe completion report: a status message, a PR description, a standup update, a commit message, a handoff note, or a coding agent's final summary. This is the thing being checked — every sentence of it that asserts a state becomes a ledger row. Cut middle-out at 40,000 characters (the opening framing and the closing claims both matter), with the cut announced inline as [... clipped N characters from the middle ...].
evidencestringThe output you actually have: terminal transcripts, test-runner output, build logs, CI summaries, lint output, git diff / git status, deploy logs, manual steps written out. May be empty — an empty log is a finding, not an error: every claim then comes back unverified, which is a statement about the evidence and not about the work. Cut tail-heavy at 80,000 characters (only 25% of the budget goes to the head), because a test run's summary line and a traceback's diagnosis both live at the end.
stakesstringpr | commit | deploy | handoff | unknown — what happens next if the claim is wrong. Risk ratings rise and the gate tightens as the stakes rise; a deploy deserves a harder gate than a local commit. Defaults to pr in the app's own form.
strictnessstringstandard | strict. Under strict, a claim is verified only if the exact proof for its kind is present, and convenient near-misses — a test run standing in for a bugfix's symptom test, a manual note standing in for a requirements walk — are downgraded to partially-verified.
contextstring, optionalFree text: the project, the real test and build commands, which tests are known to be flaky, which warnings have been deliberately accepted, what the CI job already does. Cut middle-out at 20,000 characters.
prescan_factsobject, optionalWhat a client-side scanner already matched, as {"resources": [{id, label}], "flags": [{id, label}]}. resources are the claims and evidence kinds it detected; flags are its mechanical suspicions. Every flag id you send comes back in coverage_check exactly once. The web UI fills this from its own regex prescan; API callers may omit the field entirely — see the note below for what that changes.
retry_notestring, optionalOnly set by the app's automatic reformat retry when a first reply was not valid JSON. Leave it out. It is an instruction about the shape of the reply and nothing else: the prompt requires that it never appear in the ledger or in coverage_check, and that it never change a status, a risk rating or the gate — a reply rejected for its formatting says nothing about whether the work was verified. Do not use it as a channel for extra facts or instructions; anything you want the gate to weigh belongs in context, where it is treated as author-supplied and still has to be evidenced.
cat > report.md <<'REPORT'
Done! Fixed the flaky timeout in the uploader and added a regression test.
The full suite passes and the build is clean.
The sub-agent reported it updated the docs too. Deploying to staging next.
REPORT

cat > run.log <<'LOG'
$ pytest -q
....................F......
FAILED tests/test_uploader.py::test_retry_backoff - assert 3 == 5
26 passed, 1 failed in 12.41s
$ ruff check .
All checks passed!
LOG

jq -n --rawfile claims report.md --rawfile evidence run.log \
  '{claims: $claims,
    evidence: $evidence,
    stakes: "pr",
    strictness: "standard",
    context: "Python service, pytest + ruff. CI runs pytest -q on every PR.",
    prescan_facts: {resources: [], flags: []}}' > input.json

curl -s -X POST "$API/estimate" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -d @input.json > est.json

# the model binding is part of the contract — assert it, don't assume it
jq -e '.data.model_alias == "gpt-terra" and .data.markup_bps == 1000' est.json > /dev/null \
  || { echo "unexpected model binding for proof-desk"; exit 1; }

jq -r '.data | "reserve up to \(.hold_credits) credits (min \(.min_credits)) on \(.model)"' est.json
REPORT = """Done! Fixed the flaky timeout in the uploader and added a regression test.
The full suite passes and the build is clean.
The sub-agent reported it updated the docs too. Deploying to staging next.
"""

LOG = """$ pytest -q
....................F......
FAILED tests/test_uploader.py::test_retry_backoff - assert 3 == 5
26 passed, 1 failed in 12.41s
$ ruff check .
All checks passed!
"""

payload = {
    "claims": REPORT,
    "evidence": LOG,
    "stakes": "pr",
    "strictness": "standard",
    "context": "Python service, pytest + ruff. CI runs pytest -q on every PR.",
    "prescan_facts": {"resources": [], "flags": []},
}

est = api("POST", "/estimate", payload)

# the model binding is part of the contract — assert it, don't assume it
assert est["model_alias"] == "gpt-terra", est["model_alias"]
assert est["markup_bps"] == 1000, est["markup_bps"]
print("reserve up to $%.4f" % (est["hold_credits"] / 10000), "on", est["model"])
if me["credits"] < est["min_credits"]:
    raise SystemExit("balance below the model minimum — top up before running")
import assert from "node:assert";

const report = [
  "Done! Fixed the flaky timeout in the uploader and added a regression test.",
  "The full suite passes and the build is clean.",
  "The sub-agent reported it updated the docs too. Deploying to staging next.",
].join("\n");

const log = [
  "$ pytest -q",
  "....................F......",
  "FAILED tests/test_uploader.py::test_retry_backoff - assert 3 == 5",
  "26 passed, 1 failed in 12.41s",
  "$ ruff check .",
  "All checks passed!",
].join("\n");

const payload = {
  claims: report,
  evidence: log,
  stakes: "pr",
  strictness: "standard",
  context: "Python service, pytest + ruff. CI runs pytest -q on every PR.",
  prescan_facts: { resources: [], flags: [] },
};

const est = await api("POST", "/estimate", payload);

// the model binding is part of the contract — assert it, don't assume it
assert.equal(est.model_alias, "gpt-terra");
assert.equal(est.markup_bps, 1000);
console.log("reserve up to $" + (est.hold_credits / 10000).toFixed(4), "on", est.model);
const report = `Done! Fixed the flaky timeout in the uploader and added a regression test.
The full suite passes and the build is clean.
The sub-agent reported it updated the docs too. Deploying to staging next.`

const log = `$ pytest -q
....................F......
FAILED tests/test_uploader.py::test_retry_backoff - assert 3 == 5
26 passed, 1 failed in 12.41s
$ ruff check .
All checks passed!`

payload := map[string]any{
	"claims":     report,
	"evidence":   log,
	"stakes":     "pr",
	"strictness": "standard",
	"context":    "Python service, pytest + ruff. CI runs pytest -q on every PR.",
	"prescan_facts": map[string]any{
		"resources": []any{}, "flags": []any{},
	},
}

var est struct {
	Model       string `json:"model"`
	ModelAlias  string `json:"model_alias"`
	MarkupBps   int    `json:"markup_bps"`
	HoldCredits int64  `json:"hold_credits"`
	MinCredits  int64  `json:"min_credits"`
}
if err := call("POST", "/estimate", payload, &est); err != nil {
	log.Fatal(err)
}
// the model binding is part of the contract — assert it, don't assume it
if est.ModelAlias != "gpt-terra" || est.MarkupBps != 1000 {
	log.Fatalf("unexpected model binding: %s / %d bps", est.ModelAlias, est.MarkupBps)
}
fmt.Printf("reserve up to $%.4f on %s\n", float64(est.HoldCredits)/10000, est.Model)
String report = """
    Done! Fixed the flaky timeout in the uploader and added a regression test.
    The full suite passes and the build is clean.
    The sub-agent reported it updated the docs too. Deploying to staging next.
    """;

String log = """
    $ pytest -q
    ....................F......
    FAILED tests/test_uploader.py::test_retry_backoff - assert 3 == 5
    26 passed, 1 failed in 12.41s
    $ ruff check .
    All checks passed!
    """;

String jsonPayload = """
    {"claims": %s,
     "evidence": %s,
     "stakes": "pr",
     "strictness": "standard",
     "context": "Python service, pytest + ruff. CI runs pytest -q on every PR.",
     "prescan_facts": {"resources": [], "flags": []}}
    """.formatted(toJsonString(report), toJsonString(log));

String envelope = api("POST", "/estimate", jsonPayload);
// assert data.model_alias equals "gpt-terra" and data.markup_bps equals 1000
// before spending anything; the reserve is at data.hold_credits.
REPORT = <<~TEXT
  Done! Fixed the flaky timeout in the uploader and added a regression test.
  The full suite passes and the build is clean.
  The sub-agent reported it updated the docs too. Deploying to staging next.
TEXT

LOG = <<~TEXT
  $ pytest -q
  ....................F......
  FAILED tests/test_uploader.py::test_retry_backoff - assert 3 == 5
  26 passed, 1 failed in 12.41s
  $ ruff check .
  All checks passed!
TEXT

payload = { claims: REPORT,
            evidence: LOG,
            stakes: "pr",
            strictness: "standard",
            context: "Python service, pytest + ruff. CI runs pytest -q on every PR.",
            prescan_facts: { resources: [], flags: [] } }

est = api("POST", "/estimate", payload)

# the model binding is part of the contract — assert it, don't assume it
raise "unexpected model binding" unless est["model_alias"] == "gpt-terra" &&
                                        est["markup_bps"] == 1000
puts "reserve up to $#{'%.4f' % (est["hold_credits"] / 10000.0)} on #{est["model"]}"
$report = <<<'TEXT'
Done! Fixed the flaky timeout in the uploader and added a regression test.
The full suite passes and the build is clean.
The sub-agent reported it updated the docs too. Deploying to staging next.
TEXT;

$log = <<<'TEXT'
$ pytest -q
....................F......
FAILED tests/test_uploader.py::test_retry_backoff - assert 3 == 5
26 passed, 1 failed in 12.41s
$ ruff check .
All checks passed!
TEXT;

$payload = [
    "claims"        => $report,
    "evidence"      => $log,
    "stakes"        => "pr",
    "strictness"    => "standard",
    "context"       => "Python service, pytest + ruff. CI runs pytest -q on every PR.",
    "prescan_facts" => ["resources" => [], "flags" => []],
];

$est = api("POST", "/estimate", $payload);

// the model binding is part of the contract — assert it, don't assume it
if ($est["model_alias"] !== "gpt-terra" || $est["markup_bps"] !== 1000) {
    throw new Exception("unexpected model binding for proof-desk");
}
printf("reserve up to $%.4f on %s\n", $est["hold_credits"] / 10000, $est["model"]);
var report = """
    Done! Fixed the flaky timeout in the uploader and added a regression test.
    The full suite passes and the build is clean.
    The sub-agent reported it updated the docs too. Deploying to staging next.
    """;

var log = """
    $ pytest -q
    ....................F......
    FAILED tests/test_uploader.py::test_retry_backoff - assert 3 == 5
    26 passed, 1 failed in 12.41s
    $ ruff check .
    All checks passed!
    """;

var payload = new {
    claims = report,
    evidence = log,
    stakes = "pr",
    strictness = "standard",
    context = "Python service, pytest + ruff. CI runs pytest -q on every PR.",
    prescan_facts = new {
        resources = Array.Empty<object>(), flags = Array.Empty<object>(),
    },
};

var est = await SkillSafe.ApiAsync(HttpMethod.Post, "/estimate", payload);

// the model binding is part of the contract — assert it, don't assume it
if (est.GetProperty("model_alias").GetString() != "gpt-terra" ||
    est.GetProperty("markup_bps").GetInt32() != 1000)
    throw new Exception("unexpected model binding for proof-desk");

Console.WriteLine($"reserve up to {est.GetProperty("hold_credits").GetInt64() / 10000.0:F4} on " +
                  est.GetProperty("model").GetString());

Omitting prescan_facts is fine, and it costs you exactly one thing. The field is optional: send it, send {"resources": [], "flags": []}, or leave it out entirely, and the gate reads both texts in full either way. What changes is that with no flags to reconcile there is nothing for the gate to answer for, so coverage_check comes back as an empty array. Everything else — gate, the claim ledger, blocking, next_commands — is unaffected. If you do send flags, every id you send appears in coverage_check exactly once, which makes it the field to assert on in a CI check: nothing you flagged can be silently dropped.

Step 4 — Run the gate and wait for the verdict

POST /run
GET /jobs/{job_id}

/run takes the same input as /estimate, places a credit hold and returns a job_id. Poll /jobs/{job_id} every 1–2 seconds until status is succeeded or failed (a gate typically takes 30–90 s, since every claim carries its evidence quote, a proof command and the output that would count). Always send an Idempotency-Key header so a network retry can't start a second, double-charged run — step 6 derives that key from the input itself, which is what you want in CI. The gate is in output — usually nested as output.output, and as a JSON string, so parse defensively. The samples below print the verdict, the ledger and the commands to run next, then save the whole object to gate.json.

JOB_ID=$(curl -s -X POST "$API/run" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: pd-$(date +%s)" \
  -d @input.json | jq -r '.data.job_id')

while :; do
  JOB=$(curl -s "$API/jobs/$JOB_ID" -H "Authorization: Bearer $TOKEN")
  STATUS=$(echo "$JOB" | jq -r '.data.status')
  [ "$STATUS" = "succeeded" ] || [ "$STATUS" = "failed" ] && break
  sleep 2
done

# unwrap the gate once, then read it
echo "$JOB" | jq -r '.data.output.output' > gate.json

jq -r '
  "\(.report_name) [\(.gate)]: \(.verdict)",
  "",
  "LEDGER",
  (.claims[] | "  \(.id) [\(.status) · \(.risk)] \(.kind) — \(.claim)"),
  (.claims[] | select(.evidence == "") | "  \(.id) has no supporting line in the log"),
  "",
  "BLOCKING",
  (.blocking[] | "  - \(.)"),
  "",
  "NEXT COMMANDS",
  (.next_commands[] | "  \(.step). \(.command)\n     proves: \(.proves)\n     expected: \(.expected)"),
  "",
  "COVERAGE",
  (.coverage_check[] | "  \(.id): \(if .addressed then "covered" else "SET ASIDE" end) - \(.note)")' \
  gate.json
import time

job_id = api("POST", "/run", payload,
             **{"Idempotency-Key": "pd-001"})["job_id"]

while True:
    job = api("GET", f"/jobs/{job_id}")
    if job["status"] in ("succeeded", "failed"):
        break
    time.sleep(1.5)

if job["status"] == "failed":
    raise RuntimeError(job.get("error", "run failed"))

raw = job["output"]
if isinstance(raw, dict) and "output" in raw:
    raw = raw["output"]
gate = json.loads(raw) if isinstance(raw, str) else raw

print(f'{gate["report_name"]} [{gate["gate"]}]: {gate["verdict"]}')
for a in gate["assumptions"]:
    print("  assumption:", a)
for q in gate["open_questions"]:
    print("  open question:", q)
for c in gate["claims"]:
    print(f'  {c["id"]} [{c["status"]:>18} · {c["risk"]:>6}] {c["kind"]:<12} {c["claim"]}')
    print(f'      evidence: {c["evidence"] or "— none in the log"}')
    print(f'      why: {c["why"]}')
    if c["proof_command"]:
        print(f'      $ {c["proof_command"]}')
        print(f'      counts as proof only if: {c["expected"]}')
for c in gate["coverage_check"]:
    print(f'  {c["id"]}: {"covered" if c["addressed"] else "SET ASIDE"} - {c["note"]}')
for b in gate["blocking"]:
    print("  blocking:", b)
for s in gate["next_commands"]:
    print(f'  {s["step"]}. {s["command"]}  # proves {s["proves"]}; expect {s["expected"]}')
print(gate["summary"])

with open("gate.json", "w", encoding="utf-8") as fh:
    json.dump(gate, fh, indent=2)
import { writeFileSync } from "node:fs";

const { job_id } = await api("POST", "/run", payload,
  { "Idempotency-Key": crypto.randomUUID() });

let job;
do {
  await new Promise((r) => setTimeout(r, 1500));
  job = await api("GET", `/jobs/${job_id}`);
} while (job.status !== "succeeded" && job.status !== "failed");

if (job.status === "failed") throw new Error(job.error ?? "run failed");

const raw = job.output?.output ?? job.output;
const gate = typeof raw === "string" ? JSON.parse(raw) : raw;

console.log(`${gate.report_name} [${gate.gate}]: ${gate.verdict}`);
for (const a of gate.assumptions) console.log(`  assumption: ${a}`);
for (const q of gate.open_questions) console.log(`  open question: ${q}`);
for (const c of gate.claims) {
  console.log(`  ${c.id} [${c.status} · ${c.risk}] ${c.kind} — ${c.claim}`);
  console.log(`      evidence: ${c.evidence || "— none in the log"}`);
  console.log(`      why: ${c.why}`);
  if (c.proof_command) console.log(`      $ ${c.proof_command}  (expect: ${c.expected})`);
}
for (const c of gate.coverage_check) {
  console.log(`  ${c.id}: ${c.addressed ? "covered" : "SET ASIDE"} - ${c.note}`);
}
for (const b of gate.blocking) console.log(`  blocking: ${b}`);
for (const s of gate.next_commands) {
  console.log(`  ${s.step}. ${s.command}  # proves ${s.proves}`);
}
console.log(gate.summary);

writeFileSync("gate.json", JSON.stringify(gate, null, 2));
var started struct{ JobID string `json:"job_id"` }
if err := call("POST", "/run", payload, &started); err != nil {
	log.Fatal(err)
}

var job struct {
	Status string          `json:"status"`
	Error  string          `json:"error"`
	Output json.RawMessage `json:"output"`
}
for {
	if err := call("GET", "/jobs/"+started.JobID, nil, &job); err != nil {
		log.Fatal(err)
	}
	if job.Status == "succeeded" || job.Status == "failed" {
		break
	}
	time.Sleep(1500 * time.Millisecond)
}

// job.Output is {"output": "<json string>"} — unwrap, then unmarshal:
type Gate struct {
	ReportName    string   `json:"report_name"`
	Gate          string   `json:"gate"`
	Verdict       string   `json:"verdict"`
	ExecSummary   string   `json:"exec_summary"`
	Assumptions   []string `json:"assumptions"`
	OpenQuestions []string `json:"open_questions"`
	Claims        []struct {
		ID, Claim, Kind, Status, Risk string
		Evidence, Why                 string
		ProofCommand                  string `json:"proof_command"`
		Expected                      string `json:"expected"`
	} `json:"claims"`
	CoverageCheck []struct {
		ID, Note  string
		Addressed bool
	} `json:"coverage_check"`
	Blocking     []string `json:"blocking"`
	NextCommands []struct {
		Step                        int
		Command, Proves, Expected   string
	} `json:"next_commands"`
	Summary string `json:"summary"`
}
var wrapper struct{ Output string `json:"output"` }
json.Unmarshal(job.Output, &wrapper)
var gate Gate
json.Unmarshal([]byte(wrapper.Output), &gate)

fmt.Printf("%s [%s]: %s\n", gate.ReportName, gate.Gate, gate.Verdict)
for _, c := range gate.Claims {
	fmt.Printf("  %s [%s / %s] %s — %s\n", c.ID, c.Status, c.Risk, c.Kind, c.Claim)
	fmt.Printf("      evidence: %q\n", c.Evidence)
	if c.ProofCommand != "" {
		fmt.Printf("      $ %s  (expect: %s)\n", c.ProofCommand, c.Expected)
	}
}
for _, s := range gate.NextCommands {
	fmt.Printf("  %d. %s  # proves %s\n", s.Step, s.Command, s.Proves)
}
os.WriteFile("gate.json", []byte(wrapper.Output), 0o644)
String envelope = api("POST", "/run", jsonPayload);
String jobId = /* data.job_id via your JSON library */;

while (true) {
    String job = api("GET", "/jobs/" + jobId, null);
    String status = /* data.status */;
    if (status.equals("succeeded") || status.equals("failed")) break;
    Thread.sleep(1500);
}
// The gate is at data.output.output as a JSON string — parse it again, then read
// report_name, gate, verdict, exec_summary, assumptions[], open_questions[],
// claims[] (id/claim/kind/status/risk/evidence/why/proof_command/expected),
// coverage_check[] (id/addressed/note), blocking[],
// next_commands[] (step/command/proves/expected) and summary.
// Finally keep the gate on disk:
//   Files.writeString(Path.of("gate.json"), gateJson);
started = api("POST", "/run", payload)

job = nil
loop do
  job = api("GET", "/jobs/#{started["job_id"]}")
  break if %w[succeeded failed].include?(job["status"])
  sleep 1.5
end
raise (job["error"] || "run failed") if job["status"] == "failed"

raw = job["output"].is_a?(Hash) ? job["output"].fetch("output", job["output"]) : job["output"]
gate = raw.is_a?(String) ? JSON.parse(raw) : raw

puts "#{gate["report_name"]} [#{gate["gate"]}]: #{gate["verdict"]}"
gate["claims"].each do |c|
  puts "  #{c["id"]} [#{c["status"]} · #{c["risk"]}] #{c["kind"]} — #{c["claim"]}"
  puts "      evidence: #{c["evidence"].empty? ? "— none in the log" : c["evidence"]}"
  puts "      why: #{c["why"]}"
  puts "      $ #{c["proof_command"]} (expect: #{c["expected"]})" unless c["proof_command"].empty?
end
gate["coverage_check"].each { |c| puts "  #{c["id"]}: #{c["addressed"] ? "covered" : "SET ASIDE"}" }
gate["blocking"].each { |b| puts "  blocking: #{b}" }
gate["next_commands"].each { |s| puts "  #{s["step"]}. #{s["command"]} # proves #{s["proves"]}" }
puts gate["summary"]

File.write("gate.json", JSON.pretty_generate(gate))
$started = api("POST", "/run", $payload);

do {
    sleep(2);
    $job = api("GET", "/jobs/" . $started["job_id"]);
} while (!in_array($job["status"], ["succeeded", "failed"]));

if ($job["status"] === "failed") {
    throw new Exception($job["error"] ?? "run failed");
}

$raw = is_array($job["output"]) ? ($job["output"]["output"] ?? $job["output"]) : $job["output"];
$gate = is_string($raw) ? json_decode($raw, true) : $raw;

echo "{$gate['report_name']} [{$gate['gate']}]: {$gate['verdict']}\n";
foreach ($gate["claims"] as $c) {
    echo "  {$c['id']} [{$c['status']} · {$c['risk']}] {$c['kind']} — {$c['claim']}\n";
    echo "      evidence: " . ($c["evidence"] ?: "— none in the log") . "\n";
    echo "      why: {$c['why']}\n";
    if ($c["proof_command"]) {
        echo "      $ {$c['proof_command']} (expect: {$c['expected']})\n";
    }
}
foreach ($gate["coverage_check"] as $c) {
    echo "  {$c['id']}: " . ($c["addressed"] ? "covered" : "SET ASIDE") . " - {$c['note']}\n";
}
foreach ($gate["blocking"] as $b) {
    echo "  blocking: $b\n";
}
foreach ($gate["next_commands"] as $s) {
    echo "  {$s['step']}. {$s['command']} # proves {$s['proves']}\n";
}
echo $gate["summary"] . "\n";

file_put_contents("gate.json", json_encode($gate, JSON_PRETTY_PRINT));
var started = await SkillSafe.ApiAsync(HttpMethod.Post, "/run", payload);
var jobId = started.GetProperty("job_id").GetString();

JsonElement job;
while (true)
{
    job = await SkillSafe.ApiAsync(HttpMethod.Get, $"/jobs/{jobId}");
    var status = job.GetProperty("status").GetString();
    if (status is "succeeded" or "failed") break;
    await Task.Delay(1500);
}

var rawText = job.GetProperty("output").GetProperty("output").GetString();
using var doc = JsonDocument.Parse(rawText!);
var gate = doc.RootElement;

Console.WriteLine($"{gate.GetProperty("report_name")} " +
                  $"[{gate.GetProperty("gate")}]: {gate.GetProperty("verdict")}");
foreach (var c in gate.GetProperty("claims").EnumerateArray())
{
    Console.WriteLine($"  {c.GetProperty("id")} [{c.GetProperty("status")} / " +
                      $"{c.GetProperty("risk")}] {c.GetProperty("kind")} — {c.GetProperty("claim")}");
    Console.WriteLine($"      evidence: {c.GetProperty("evidence")}");
    Console.WriteLine($"      $ {c.GetProperty("proof_command")}");
}
foreach (var s in gate.GetProperty("next_commands").EnumerateArray())
{
    Console.WriteLine($"  {s.GetProperty("step")}. {s.GetProperty("command")}");
}

await File.WriteAllTextAsync("gate.json", rawText!);

The model is asked for one JSON object and nothing else, but a stray code fence or preamble is always possible. Strip a leading ```json fence, take the text between the first { and the last }, and only then parse — that is what the app does before it falls back to a retry_note reformat run.

The gate object — output schema

One JSON object, always the same shape. Every array is present. The gate is grounded in the two texts you sent and nothing else: every evidence string is a short verbatim quote from the pasted log, or the empty string when the log contains nothing that supports the claim — never a paraphrase of what the log probably said. No test name, file path, tool or number appears that is not in the input, with one deliberate exception: proof_command, where the job is precisely to design the command that should be run. Every sentence of the report that makes a claim gets its own row; adjacent claims are not merged and vague ones are not dropped, they become a generic row that says it is too imprecise to verify as written.

FieldTypeMeaning
report_namestringA short specific name for what was claimed, 3–9 words. Falls back to Untitled completion claim if the model returns nothing usable.
gatestringpass | conditional | blocked. This is the field to branch a pipeline on. Two invariants are re-applied on the client — see below. Anything unrecognised becomes conditional.
verdictstringOne sentence: what is actually proven, and what the reader must not yet believe.
exec_summarystringTwo to four short paragraphs separated by blank lines: what the report claims, what the log shows, where the two part company, and the smallest set of commands that would settle it.
assumptionsstring[]Every assumption that had to be made about the project, the stack or the commands. Read these first — a wrong assumption invalidates the row built on it.
open_questionsstring[]Questions only the author can answer that would change a verdict.
claimsarrayThe ledger — at least one entry, ids C-001, C-002, … in the order the claims appear in the report. Columns below. A missing id is filled in positionally, so ids are always present.
coverage_checkarray{id, addressed, note} — one entry per prescan_facts.flags id you sent, each appearing exactly once. Empty when you sent no flags. addressed is coerced to a real boolean.
blockingstring[]What must happen before this claim can honestly be made. Empty when nothing strictly blocks — which is not the same as everything being proven; the open rows in claims are still open.
next_commandsarray{step, command, proves, expected} — ordered so the cheapest command that could most change the verdict runs first, one step per open claim and no steps for claims already verified. Commands are single-line and non-interactive; entries with an empty command are dropped, and any newline inside a command is folded to a space.
summarystringA closing paragraph the author could paste into their status update, stating what is proven and what is not.

Each entry in claims:

ColumnMeaning
idSequential C-001, C-002, … — the stable handle next_commands[].proves refers back to.
claimThe claim, quoted or tightly paraphrased from the report.
kindOne of eleven: tests, lint, build, regression, bugfix, agent, requirements, deploy, perf, manual, generic. The kind decides what would count as proof — see the table below. Anything else becomes generic.
statusverified | partially-verified | unverified | contradicted | not-applicable. Anything else becomes unverified — the safe direction. unverified means the log does not show it, not that it is false.
risklow | medium | high — how much damage this claim being wrong would do, given stakes. Anything else becomes medium.
evidenceA short verbatim quote from the pasted log, or the empty string. An empty string here is the point, not an omission: it is what unverified looks like when it is stated honestly.
whyOne or two sentences on what the evidence does or does not establish.
proof_commandThe exact command that would settle it — empty when the claim is already verified. This is the one field allowed to name a command that is not in the input.
expectedWhat output would count as proof: the specific line, count or exit code to look for. Where the stack is unknown, this field says so rather than a framework being guessed in the command.

The three gate values:

gateWhat it means
passEvery claim in the report is verified against evidence in the log. Used, and meant — a gate that never passes is a gate nobody consults. When it passes, the output is spent on what is still worth checking before the next stakes level, not on manufacturing doubt.
conditionalNothing is contradicted, but at least one claim is unverified or partially-verified at medium or low risk. The work may well be fine; the report overstates what is known.
blockedAny claim is contradicted, or a high-risk claim is unverified, or the log is empty while the report claims tests, build or deploy state.

The two client-side gate invariants

The gate value is the model's call, but the app re-applies two invariants after parsing so the badge can never contradict the ledger under it. If you reimplement the render path, apply the same two rules or your badge will disagree with the app's. Let contradicted be any claim with status == "contradicted" and open be any claim whose status is unverified or partially-verified:

RuleEffect
Any contradicted claimThe gate becomes blocked, whatever the model said. A failure signal in the log beats any claim in the report, so a ledger holding a contradiction can never render as pass or conditional.
pass with at least one open claimDowngraded to conditional. A pass badge over a ledger with an unverified row is the exact mistake this app exists to prevent.
blocked with nothing contradicted and nothing openUpgraded to conditional — the mirror of the rule above, so a blocked badge always has a row under it explaining itself.

In order: if anything is contradicted, the gate is blocked; otherwise if the gate says blocked and nothing is open, it becomes conditional; otherwise if the gate says pass and anything is open, it becomes conditional. Two further normalisations are worth copying: a ledger with no usable row at all (no claim and no why on any entry) is treated as a failed parse rather than an empty pass, and an unrecognised gate value falls back to conditional rather than to pass.

A real result for the input in step 3, trimmed for length:

{
  "report_name": "uploader timeout fix and regression test",
  "gate": "blocked",
  "verdict": "The log you pasted contains a failing test in the file you changed, so the claim that the suite passes is contradicted; nothing else in the report is shown either way.",
  "exec_summary": "The report says the suite passes and the build is clean. The log says 26 passed, 1 failed, and the one that failed is test_retry_backoff in the uploader you just changed. That single line settles the tests claim against you, and it is why this gate is blocked rather than merely conditional.\n\nThe rest of the report is not contradicted, it is unevidenced. The regression test is claimed but never shown failing with the fix reverted; the sub-agent's docs change is reported by the sub-agent itself with no diff to read; and ruff passing is a lint result standing in for a build claim. None of that makes the work bad, it makes four of the five sentences unsupportable as written.\n\nThe cheapest thing that would change this verdict is re-running the one failing test.",
  "assumptions": [
    "The command in the log, pytest -q, is the full suite for this project and not a filtered subset.",
    "The uploader change and the pasted run happened in that order, in this pass."
  ],
  "open_questions": [
    "Was test_retry_backoff already failing before this change - is it the flake you set out to fix?",
    "Which files did the sub-agent touch? Without a diff the docs claim cannot be checked at all."
  ],
  "claims": [
    { "id": "C-001", "claim": "The flaky timeout in the uploader is fixed.",
      "kind": "bugfix", "status": "unverified", "risk": "high",
      "evidence": "",
      "why": "Nothing in the log exercises the original timeout symptom. The only uploader test in the run is the one that failed, which points the other way.",
      "proof_command": "pytest tests/test_uploader.py -k timeout -v",
      "expected": "The test that reproduced the original timeout runs and passes, named in the output." },
    { "id": "C-002", "claim": "A regression test was added.",
      "kind": "regression", "status": "partially-verified", "risk": "medium",
      "evidence": "26 passed, 1 failed in 12.41s",
      "why": "The suite grew and ran, but a test never seen to fail with the fix reverted proves nothing about the bug it is meant to pin.",
      "proof_command": "git stash && pytest tests/test_uploader.py::test_retry_backoff; git stash pop",
      "expected": "The new test FAILS with the fix reverted and passes with it restored - both halves in one transcript." },
    { "id": "C-003", "claim": "The full suite passes.",
      "kind": "tests", "status": "contradicted", "risk": "high",
      "evidence": "FAILED tests/test_uploader.py::test_retry_backoff - assert 3 == 5",
      "why": "The log carries a failure for the exact area the report says is fixed. A failure signal in the log beats any claim in the report.",
      "proof_command": "pytest -q",
      "expected": "A summary line reading 0 failed, from a run started against the current working tree." },
    { "id": "C-004", "claim": "The build is clean.",
      "kind": "build", "status": "unverified", "risk": "medium",
      "evidence": "All checks passed!",
      "why": "That line is ruff, a linter. A linter does not compile or type-check, and there is no build or typecheck output in the log at all.",
      "proof_command": "python -m mypy src/",
      "expected": "The type checker's own final line reporting no issues, with exit code 0." },
    { "id": "C-005", "claim": "The sub-agent updated the docs.",
      "kind": "agent", "status": "unverified", "risk": "low",
      "evidence": "",
      "why": "The only source for this is the sub-agent's own report. The verification for delegated work is the diff, and there is no diff in the log.",
      "proof_command": "git diff --stat HEAD~1 -- docs/",
      "expected": "Named doc files with non-zero line counts - or nothing, which would mean the report was wrong." }
  ],
  "coverage_check": [
    { "id": "contradicted:6f2a91c4", "addressed": true, "note": "C-003 - the failing test is the contradiction." },
    { "id": "satisfaction:3b81ee07", "addressed": true, "note": "Noted: the report opens with \"Done!\" before any evidence appears." },
    { "id": "regression-unproven:9c40ab12", "addressed": true, "note": "C-002 - downgraded to partially-verified for the missing red half." },
    { "id": "agent-trust:12d7c003", "addressed": true, "note": "C-005 - no diff in the log." },
    { "id": "future-tense:aa51b6de", "addressed": false, "note": "Set aside: \"deploying to staging next\" is a stated plan, not a completion claim, so it gets no ledger row." }
  ],
  "blocking": [
    "test_retry_backoff must pass in a full run started against the current working tree.",
    "The new regression test must be seen to fail with the fix reverted."
  ],
  "next_commands": [
    { "step": 1, "command": "pytest tests/test_uploader.py::test_retry_backoff -v",
      "proves": "C-003 - whether the one failing test is still failing at all",
      "expected": "1 passed, and the assert 3 == 5 line gone from the output." },
    { "step": 2, "command": "pytest -q",
      "proves": "C-003 - that the whole suite is green, not just the one test",
      "expected": "A summary line reading 0 failed." },
    { "step": 3, "command": "git stash && pytest tests/test_uploader.py::test_retry_backoff; git stash pop",
      "proves": "C-002 - the red half of the red-green cycle",
      "expected": "The test fails while the fix is stashed, then passes once it is restored." },
    { "step": 4, "command": "python -m mypy src/",
      "proves": "C-004 - that something actually compiles or type-checks",
      "expected": "The checker's own success line and exit code 0." },
    { "step": 5, "command": "git diff --stat HEAD~1 -- docs/",
      "proves": "C-005 - what the sub-agent changed, read independently of its report",
      "expected": "Doc files listed with line counts." }
  ],
  "summary": "One test is failing in the area this change touches, so the suite claim is not merely unproven, it is wrong as written. Re-run test_retry_backoff first; if it passes now, the rest of this ledger is three commands away from a conditional gate, and a red-green transcript for the new test would take it to pass."
}

The runnable verify.sh the app offers is derived in the browser from this object, not returned by the API: it walks next_commands (falling back to the proof_command of every open claim when that array is empty), prints each step with what it proves and what would count, sets fail=1 on any non-zero exit, and exits 1 at the end if anything failed. If you want that file server-side, build it from next_commands the same way — the API gives you the steps, the ordering and the expectations; the shell around them is yours.

The eleven claim kinds and what proves each

This table is the heart of the job, and it is why kind matters more than it looks: a claim is verified only when the log carries the artifact in the middle column. The right-hand column lists the substitutions people actually make — each one is a way to feel verified without having verified. Under strictness: "strict" a near-miss is downgraded to partially-verified rather than accepted.

kindWhat proves itNot sufficient
testsThe full test command's output showing zero failures.A previous run; a filtered subset; “should pass”.
lintThe linter's own output with a zero count.A partial path; the editor showing no squiggles.
buildThe build or typecheck finishing with exit code 0.The linter passing — a linter does not compile.
regressionA red-green cycle: the new test failing with the fix reverted, then passing with it restored.The test passing once — a test that never failed proves nothing.
bugfixThe original failing symptom exercised again and now passing.The code having changed — a changed line is not a tested line.
agentThe version-control diff showing what the delegated agent actually changed.The agent's own success report.
requirementsA line-by-line walk of the plan or acceptance criteria, each item evidenced.Tests passing — tests cover what was written, not what was asked for.
deployThe deploy's own success output plus a health check against the deployed target.The pipeline being triggered.
perfBefore and after numbers from the same benchmark.The change looking cheaper.
manualThe steps taken and what was observed at each.“I looked at it”.
genericWhatever command a skeptical reader would run to disprove it.A summary of what was changed.

Two rules override everything in this table. A failure signal in the log beats any claim in the report — failing tests, a type error, a non-zero exit, a stack trace, a lint problem count above zero: the status is contradicted, the risk is high, the gate is blocked, and the verdict names which line contradicts which sentence. And absence of evidence is unverified, never verified and never an accusation: the phrasing that matters is “this may well be true; nothing here shows it”. If the report contains no checkable claim at all, you get exactly one generic row with status not-applicable and a conditional gate.

The twelve prescan flag families

prescan_facts.flags is how you make the gate answer for things you already know about. Each flag id is <family>:<suffix>; the app's own scanner uses a short hash of the matched phrase as the suffix, but the suffix is opaque to the gate, so an API caller may use anything stable — a line number, a rule name, a counter. What matters is the family prefix, because it tells the gate what kind of suspicion it is being asked to clear. Ids in prescan_facts.resources are not reconciled in coverage_check; they seed the ledger instead, and the app's scanner emits them as claim:<hash> with a label like Claim/Tests pass: The full suite passes.

Family keyWhat it flags
no-evidenceA claim whose kind needs a specific artifact — a test summary, a build exit code, a diff — and the log has none. The iron law of the source skill, and the most common flag by far.
contradictedThe log carries a failure signal while the report claims the corresponding thing is fine. The one family that should stop a merge on its own.
hedgeHedging inside a claim: “should work”, “probably”, “seems to”, “looks right”, “I think”, “pretty sure”.
satisfactionCelebration before verification: “Great!”, “Perfect!”, “Done!”, “ship it!” ahead of the evidence.
stale-evidence“It passed earlier”, “already verified”, “unchanged since the last run” — a green run from before the last edit describes a codebase that no longer exists.
partial-verificationA spot check, a smoke test, a filtered run, -k, --only, .only( standing in for the full check.
extrapolationThe wrong tool cited as proof: the linter for a build claim, the type checker for a test claim, tests for a requirements claim.
agent-trustA delegated agent's self-report taken as proof, with no diff to read it against.
assumed-fix“Changed the code, so the bug is fixed” — the fix inferred from the edit rather than from the symptom being exercised.
regression-unprovenA regression test claimed with no red-green cycle anywhere in the log.
exceptionA rationalized exception to verification: “just this once”, “trust me”, “not worth running”, “takes too long”.
future-tenseVerification named but not yet run: “I'll run the tests next” inside a completion report is a plan, not a result.

A complete request body with a prescan attached:

{
  "claims": "Done! Fixed the flaky timeout in the uploader and added a regression test. The full suite passes and the build is clean.",
  "evidence": "$ pytest -q\nFAILED tests/test_uploader.py::test_retry_backoff - assert 3 == 5\n26 passed, 1 failed in 12.41s\n$ ruff check .\nAll checks passed!",
  "stakes": "pr",
  "strictness": "strict",
  "context": "Python service, pytest + ruff, merge queue. test_retry_backoff is the flake being fixed.",
  "prescan_facts": {
    "resources": [
      { "id": "claim:1a2b3c4d", "label": "Claim/Tests pass: The full suite passes." },
      { "id": "claim:5e6f7a8b", "label": "Claim/Bug fixed: Fixed the flaky timeout in the uploader." }
    ],
    "flags": [
      { "id": "contradicted:6f2a91c4", "label": "contradicted: the log shows failing tests while the report claims the suite passes" },
      { "id": "satisfaction:3b81ee07", "label": "satisfaction: \"Done!\"" },
      { "id": "regression-unproven:9c40ab12", "label": "regression-unproven: no red-green cycle appears in the log" }
    ]
  }
}

Treat the flags as a checklist the gate must clear, not as conclusions — it may overrule any of them, and when it does the reason lands in coverage_check[].note with addressed: false. The one guarantee is arithmetic: every id you send comes back exactly once. Assert on that in CI (len(coverage_check) == len(flags_sent)) and nothing you flagged can be silently dropped.

Step 5 — Stream the gate as it is written

POST /run-stream

/run-stream takes exactly the same body as /run but answers with server-sent events, so you can show progress instead of a spinner — useful here because a full ledger with an evidence quote, a proof command and an expectation per claim makes for a long reply. This app's own progress panel is this endpoint. Events are separated by a blank line; each has an event: line and a data: line carrying JSON.

EventPayloadMeaning
job{job_id, status}Sent once, when the job is accepted — show "starting".
delta{text}A chunk of the reply, in order. Append it; the accumulated length is your only progress signal (the total is not known in advance). The app advances its step list by watching for the "report_name", "gate", "claims", "coverage_check", "blocking", "next_commands" and "summary" keys as they arrive.
done{job_id, status, charged_credits, output}The final, authoritative result — read the gate from output.output rather than trusting concatenated deltas, and the settled price from charged_credits.
error{code, message}Replaces done when the run fails.
# -N disables buffering so events print as they arrive
curl -N -s -X POST "$API/run-stream" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: pd-$(date +%s)" \
  -d @input.json

# event: job
# data: {"job_id":"job_...","status":"running"}
#
# event: delta
# data: {"text":"{\"report_name\":\"uploader"}
# ...
# event: done
# data: {"job_id":"job_...","status":"succeeded","charged_credits":540,"output":{"output":"{...}"}}
import json, requests

result = None
with requests.post(
    API + "/run-stream",
    headers={"Authorization": f"Bearer {TOKEN}",
             "Idempotency-Key": "pd-001"},
    json=payload,
    stream=True,
) as r:
    r.raise_for_status()
    event = None
    for line in r.iter_lines(decode_unicode=True):
        if not line:
            continue
        if line.startswith("event:"):
            event = line[len("event:"):].strip()
        elif line.startswith("data:"):
            data = json.loads(line[len("data:"):].strip())
            if event == "delta":
                print(".", end="", flush=True)          # live progress
            elif event == "done":
                result = data
            elif event == "error":
                raise RuntimeError(data.get("message", "run failed"))

gate = json.loads(result["output"]["output"])            # authoritative
print("charged:", result["charged_credits"], "-", gate["report_name"], gate["gate"])
for c in gate["claims"]:
    print(f'  {c["id"]} [{c["status"]}] {c["claim"]}')
with open("gate.json", "w", encoding="utf-8") as fh:
    json.dump(gate, fh, indent=2)
const res = await fetch(API + "/run-stream", {
  method: "POST",
  headers: {
    Authorization: `Bearer ${TOKEN}`,
    "Content-Type": "application/json",
    "Idempotency-Key": crypto.randomUUID(),
  },
  body: JSON.stringify(payload),
});

const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "", done = null;

for (;;) {
  const chunk = await reader.read();
  if (chunk.done) break;
  buf += decoder.decode(chunk.value, { stream: true });
  const frames = buf.split("\n\n");
  buf = frames.pop();
  for (const frame of frames) {
    const name = /^event:\s*(.+)$/m.exec(frame)?.[1];
    const body = /^data:\s*(.+)$/m.exec(frame)?.[1];
    if (!name || !body) continue;
    const data = JSON.parse(body);
    if (name === "delta") console.write?.(".") ?? 0;   // live progress
    if (name === "done") done = data;
    if (name === "error") throw new Error(data.message ?? "run failed");
  }
}

const gate = JSON.parse(done.output.output);
console.log(`\n${done.charged_credits} credits - ${gate.report_name} [${gate.gate}]`);
for (const c of gate.claims) console.log(`  ${c.id} [${c.status}] ${c.claim}`);
writeFileSync("gate.json", JSON.stringify(gate, null, 2));
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", API+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", "pd-001")

res, err := http.DefaultClient.Do(req)
if err != nil {
	log.Fatal(err)
}
defer res.Body.Close()

var event string
var final map[string]any
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for sc.Scan() {
	line := sc.Text()
	switch {
	case strings.HasPrefix(line, "event:"):
		event = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
	case strings.HasPrefix(line, "data:"):
		var data map[string]any
		json.Unmarshal([]byte(strings.TrimPrefix(line, "data:")), &data)
		switch event {
		case "delta":
			fmt.Print(".") // live progress
		case "done":
			final = data
		case "error":
			log.Fatal(data["message"])
		}
	}
}
// final["output"].(map[string]any)["output"].(string) is the gate JSON —
// unmarshal it into the Gate struct from step 4, then write it to gate.json.
// Java 17+ — read the stream line by line instead of buffering the body.
var req = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
    .header("Authorization", "Bearer " + TOKEN)
    .header("Content-Type", "application/json")
    .header("Idempotency-Key", "pd-001")
    .POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
    .build();

var res = HTTP.send(req, HttpResponse.BodyHandlers.ofLines());
String event = null, done = null;
for (String line : (Iterable<String>) res.body()::iterator) {
    if (line.startsWith("event:")) {
        event = line.substring(6).trim();
    } else if (line.startsWith("data:")) {
        String data = line.substring(5).trim();
        if ("delta".equals(event)) System.out.print(".");   // live progress
        else if ("done".equals(event)) done = data;
        else if ("error".equals(event)) throw new RuntimeException(data);
    }
}
// parse `done`, then parse data.output.output again — it is a JSON string holding
// report_name, gate, verdict, claims[], coverage_check[], blocking[],
// next_commands[] and summary.
require "net/http"
require "json"

uri = URI(API + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = "pd-001"
req.body = payload.to_json

event = nil
done = nil
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|
        line = line.strip
        if line.start_with?("event:")
          event = line.delete_prefix("event:").strip
        elsif line.start_with?("data:")
          data = JSON.parse(line.delete_prefix("data:").strip)
          case event
          when "delta" then print "."           # live progress
          when "done"  then done = data
          when "error" then raise (data["message"] || "run failed")
          end
        end
      end
    end
  end
end

gate = JSON.parse(done["output"]["output"])
puts "\n#{done["charged_credits"]} credits - #{gate["report_name"]} [#{gate["gate"]}]"
gate["claims"].each { |c| puts "  #{c["id"]} [#{c["status"]}] #{c["claim"]}" }
File.write("gate.json", JSON.pretty_generate(gate))
$event = null;
$done  = null;

$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [
    CURLOPT_POST       => true,
    CURLOPT_HTTPHEADER => [
        "Authorization: Bearer $TOKEN",
        "Content-Type: application/json",
        "Idempotency-Key: pd-001",
    ],
    CURLOPT_POSTFIELDS => json_encode($payload),
    CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$event, &$done) {
        foreach (explode("\n", $chunk) as $line) {
            $line = trim($line);
            if (str_starts_with($line, "event:")) {
                $event = trim(substr($line, 6));
            } elseif (str_starts_with($line, "data:")) {
                $data = json_decode(trim(substr($line, 5)), true);
                if ($event === "delta") { echo "."; }        // live progress
                elseif ($event === "done") { $done = $data; }
                elseif ($event === "error") { throw new Exception($data["message"] ?? "run failed"); }
            }
        }
        return strlen($chunk);
    },
]);
curl_exec($ch);
curl_close($ch);

$gate = json_decode($done["output"]["output"], true);
echo "\n{$done['charged_credits']} credits - {$gate['report_name']} [{$gate['gate']}]\n";
foreach ($gate["claims"] as $c) {
    echo "  {$c['id']} [{$c['status']}] {$c['claim']}\n";
}
file_put_contents("gate.json", json_encode($gate, JSON_PRETTY_PRINT));
var req = new HttpRequestMessage(HttpMethod.Post, Api + "/run-stream") {
    Content = JsonContent.Create(payload),
};
req.Headers.Add("Idempotency-Key", "pd-001");

using var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());

string? evt = null, done = null;
while (await reader.ReadLineAsync() is { } line)
{
    if (line.StartsWith("event:")) evt = line[6..].Trim();
    else if (line.StartsWith("data:"))
    {
        var data = line[5..].Trim();
        if (evt == "delta") Console.Write(".");            // live progress
        else if (evt == "done") done = data;
        else if (evt == "error") throw new Exception(data);
    }
}

using var final = JsonDocument.Parse(done!);
var text = final.RootElement.GetProperty("output").GetProperty("output").GetString();
using var gateDoc = JsonDocument.Parse(text!);
var gate = gateDoc.RootElement;
Console.WriteLine($"{gate.GetProperty("report_name")} [{gate.GetProperty("gate")}]");
foreach (var c in gate.GetProperty("claims").EnumerateArray())
    Console.WriteLine($"  {c.GetProperty("id")} [{c.GetProperty("status")}] {c.GetProperty("claim")}");
await File.WriteAllTextAsync("gate.json", text!);

In a browser, the native EventSource only speaks GET, and this endpoint is a POST — read the fetch response body incrementally, as the JavaScript sample above does. On an idempotent replay the server may answer with a plain JSON envelope instead of an event stream; check the Content-Type before you start parsing frames. If a stream dies mid-flight, the accumulated text is often still a usable prefix: the app closes the open string and array ("}]}) and re-parses, keeping whatever claims arrived rather than throwing the run away.

Step 6 — The recipe: a CI gate on the agent's own report

The job this app was built for. A coding agent finishes, writes a completion report, and the pipeline is about to believe it. Instead: feed the report and the job's own log into Proof Desk, print the ledger into the build output, and exit non-zero when gate comes back blocked. The evidence is free — the pipeline already produced it — and it is fresh by construction, which is the one property the source skill cares about most.

Two details make it safe to run on every push. First, the Idempotency-Key is derived from a hash of the input rather than from a timestamp or a run id: a re-run of the same commit with the same log replays the first gate instead of paying for a second one, which is what you want when a flaky infrastructure step makes CI retry the whole job. Second, the gate is recomputed locally from the ledger with the two invariants from the schema section, so a badge printed in the build log can never disagree with the rows under it. Set stakes to match the branch — pr on a feature branch, deploy on the release pipeline — and the risk ratings follow.

#!/usr/bin/env bash
# ci-proof-gate.sh — run after the tests, with their output on disk.
set -euo pipefail

API="https://api.skillsafe.ai/v1/app-api"
TOKEN="${SKILLSAFE_TOKEN:?set SKILLSAFE_TOKEN in the CI secret store}"

# agent-report.md — what the agent (or the PR body) claims
# ci.log        — everything the pipeline actually printed, tail-kept
tail -c 80000 ci.log > evidence.log

jq -n --rawfile claims agent-report.md --rawfile evidence evidence.log \
  --arg stakes "${STAKES:-pr}" \
  '{claims: $claims, evidence: $evidence, stakes: $stakes,
    strictness: "strict",
    context: "CI gate on the merge queue. Evidence is this job'"'"'s own stdout.",
    prescan_facts: {resources: [], flags: []}}' > input.json

# input-derived key: a CI retry of the same commit replays, it does not re-bill
KEY="proof-desk:$(sha256sum input.json | cut -c1-40)"

JOB_ID=$(curl -sf -X POST "$API/run" \
  -H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d @input.json | jq -r '.data.job_id')

for _ in $(seq 1 120); do
  JOB=$(curl -sf "$API/jobs/$JOB_ID" -H "Authorization: Bearer $TOKEN")
  STATUS=$(echo "$JOB" | jq -r '.data.status')
  case "$STATUS" in succeeded|failed) break ;; esac
  sleep 2
done
[ "$STATUS" = "succeeded" ] || { echo "gate run $STATUS"; exit 2; }

echo "$JOB" | jq -r '.data.output.output' > gate.json

# re-apply the two client-side invariants so the badge matches the ledger
GATE=$(jq -r '
  ([.claims[] | select(.status == "contradicted")] | length) as $contra
  | ([.claims[] | select(.status == "unverified" or .status == "partially-verified")] | length) as $open
  | if $contra > 0 then "blocked"
    elif .gate == "blocked" and $open == 0 then "conditional"
    elif .gate == "pass" and $open > 0 then "conditional"
    else .gate end' gate.json)

jq -r --arg gate "$GATE" '
  "::group::Proof Desk — \(.report_name) [\($gate)]",
  .verdict, "",
  (.claims[] | "  \(.id) [\(.status) · \(.risk) risk] \(.kind) — \(.claim)\n      evidence: \(if .evidence == "" then "none in the log" else .evidence end)\n      \(if .proof_command == "" then "" else "$ " + .proof_command end)"),
  "",
  (.blocking[] | "  BLOCKING: \(.)"),
  (.next_commands[] | "  \(.step). \(.command)"),
  "::endgroup::",
  .summary' gate.json

[ "$GATE" = "blocked" ] && { echo "Gate blocked — the completion claim is not supportable."; exit 1; }
echo "Gate: $GATE"
#!/usr/bin/env python3
"""ci_proof_gate.py — run after the tests, with their output on disk."""
import hashlib, json, pathlib, sys, time

report = pathlib.Path("agent-report.md").read_text(encoding="utf-8")
log = pathlib.Path("ci.log").read_text(encoding="utf-8", errors="replace")[-80_000:]

payload = {
    "claims": report,
    "evidence": log,
    "stakes": "deploy" if BRANCH == "main" else "pr",
    "strictness": "strict",
    "context": "CI gate on the merge queue. Evidence is this job's own stdout.",
    "prescan_facts": {"resources": [], "flags": []},
}

# input-derived key: a CI retry of the same commit replays, it does not re-bill
key = "proof-desk:" + hashlib.sha256(
    json.dumps(payload, sort_keys=True).encode("utf-8")).hexdigest()[:40]

job_id = api("POST", "/run", payload, **{"Idempotency-Key": key})["job_id"]
while True:
    job = api("GET", f"/jobs/{job_id}")
    if job["status"] in ("succeeded", "failed"):
        break
    time.sleep(2)
if job["status"] != "succeeded":
    sys.exit(f"gate run {job['status']}: {job.get('error', '')}")

raw = job["output"]["output"] if isinstance(job["output"], dict) else job["output"]
gate = json.loads(raw)

def settled(g):
    """The two client-side invariants — keep the badge and the ledger in step."""
    contra = any(c["status"] == "contradicted" for c in g["claims"])
    open_ = any(c["status"] in ("unverified", "partially-verified") for c in g["claims"])
    if contra:
        return "blocked"
    if g["gate"] == "blocked" and not open_:
        return "conditional"
    if g["gate"] == "pass" and open_:
        return "conditional"
    return g["gate"]

verdict = settled(gate)
print(f'::group::Proof Desk — {gate["report_name"]} [{verdict}]')
print(gate["verdict"], "\n")
for c in gate["claims"]:
    print(f'  {c["id"]} [{c["status"]} · {c["risk"]} risk] {c["kind"]} — {c["claim"]}')
    print(f'      evidence: {c["evidence"] or "none in the log"}')
    if c["proof_command"]:
        print(f'      $ {c["proof_command"]}   # {c["expected"]}')
for b in gate["blocking"]:
    print("  BLOCKING:", b)
for s in gate["next_commands"]:
    print(f'  {s["step"]}. {s["command"]}')
print("::endgroup::")
print(gate["summary"])

pathlib.Path("gate.json").write_text(json.dumps(gate, indent=2), encoding="utf-8")
sys.exit(1 if verdict == "blocked" else 0)
#!/usr/bin/env node
// ci-proof-gate.mjs — run after the tests, with their output on disk.
import { createHash } from "node:crypto";
import { readFileSync, writeFileSync } from "node:fs";

const report = readFileSync("agent-report.md", "utf8");
const log = readFileSync("ci.log", "utf8").slice(-80_000);

const payload = {
  claims: report,
  evidence: log,
  stakes: branch === "main" ? "deploy" : "pr",
  strictness: "strict",
  context: "CI gate on the merge queue. Evidence is this job's own stdout.",
  prescan_facts: { resources: [], flags: [] },
};

// input-derived key: a CI retry of the same commit replays, it does not re-bill
const key = "proof-desk:" + createHash("sha256")
  .update(JSON.stringify(payload)).digest("hex").slice(0, 40);

const { job_id } = await api("POST", "/run", payload, { "Idempotency-Key": key });

let job;
do {
  await new Promise((r) => setTimeout(r, 2000));
  job = await api("GET", `/jobs/${job_id}`);
} while (job.status !== "succeeded" && job.status !== "failed");
if (job.status !== "succeeded") throw new Error(`gate run ${job.status}`);

const gate = JSON.parse(job.output?.output ?? job.output);

// the two client-side invariants — keep the badge and the ledger in step
const contra = gate.claims.some((c) => c.status === "contradicted");
const open = gate.claims.some((c) =>
  c.status === "unverified" || c.status === "partially-verified");
const verdict = contra ? "blocked"
  : gate.gate === "blocked" && !open ? "conditional"
  : gate.gate === "pass" && open ? "conditional"
  : gate.gate;

console.log(`::group::Proof Desk — ${gate.report_name} [${verdict}]`);
console.log(gate.verdict + "\n");
for (const c of gate.claims) {
  console.log(`  ${c.id} [${c.status} · ${c.risk} risk] ${c.kind} — ${c.claim}`);
  console.log(`      evidence: ${c.evidence || "none in the log"}`);
  if (c.proof_command) console.log(`      $ ${c.proof_command}   # ${c.expected}`);
}
for (const b of gate.blocking) console.log("  BLOCKING:", b);
for (const s of gate.next_commands) console.log(`  ${s.step}. ${s.command}`);
console.log("::endgroup::");
console.log(gate.summary);

writeFileSync("gate.json", JSON.stringify(gate, null, 2));
if (verdict === "blocked") {
  console.error("Gate blocked — the completion claim is not supportable.");
  process.exitCode = 1;
}
// ci-proof-gate.go — run after the tests, with their output on disk.
reportBytes, _ := os.ReadFile("agent-report.md")
logBytes, _ := os.ReadFile("ci.log")
if len(logBytes) > 80000 {
	logBytes = logBytes[len(logBytes)-80000:]
}

payload := map[string]any{
	"claims":        string(reportBytes),
	"evidence":      string(logBytes),
	"stakes":        stakesForBranch(branch), // "deploy" on main, "pr" otherwise
	"strictness":    "strict",
	"context":       "CI gate on the merge queue. Evidence is this job's own stdout.",
	"prescan_facts": map[string]any{"resources": []any{}, "flags": []any{}},
}

// input-derived key: a CI retry of the same commit replays, it does not re-bill
canonical, _ := json.Marshal(payload)
key := "proof-desk:" + fmt.Sprintf("%x", sha256.Sum256(canonical))[:40]

// call() from step 0, with the extra header set on the request:
//   req.Header.Set("Idempotency-Key", key)
var started struct{ JobID string `json:"job_id"` }
if err := callWithKey("POST", "/run", key, payload, &started); err != nil {
	log.Fatal(err)
}

var job struct {
	Status string          `json:"status"`
	Output json.RawMessage `json:"output"`
}
for {
	if err := call("GET", "/jobs/"+started.JobID, nil, &job); err != nil {
		log.Fatal(err)
	}
	if job.Status == "succeeded" || job.Status == "failed" {
		break
	}
	time.Sleep(2 * time.Second)
}
if job.Status != "succeeded" {
	log.Fatalf("gate run %s", job.Status)
}

var wrapper struct{ Output string `json:"output"` }
json.Unmarshal(job.Output, &wrapper)
var gate Gate // the struct from step 4
json.Unmarshal([]byte(wrapper.Output), &gate)

// the two client-side invariants — keep the badge and the ledger in step
contra, open := false, false
for _, c := range gate.Claims {
	if c.Status == "contradicted" {
		contra = true
	}
	if c.Status == "unverified" || c.Status == "partially-verified" {
		open = true
	}
}
verdict := gate.Gate
switch {
case contra:
	verdict = "blocked"
case gate.Gate == "blocked" && !open:
	verdict = "conditional"
case gate.Gate == "pass" && open:
	verdict = "conditional"
}

fmt.Printf("::group::Proof Desk — %s [%s]\n%s\n\n", gate.ReportName, verdict, gate.Verdict)
for _, c := range gate.Claims {
	fmt.Printf("  %s [%s / %s risk] %s — %s\n", c.ID, c.Status, c.Risk, c.Kind, c.Claim)
	if c.ProofCommand != "" {
		fmt.Printf("      $ %s   # %s\n", c.ProofCommand, c.Expected)
	}
}
for _, s := range gate.NextCommands {
	fmt.Printf("  %d. %s\n", s.Step, s.Command)
}
fmt.Println("::endgroup::")
os.WriteFile("gate.json", []byte(wrapper.Output), 0o644)
if verdict == "blocked" {
	os.Exit(1)
}
// CiProofGate.java — run after the tests, with their output on disk.
String report = Files.readString(Path.of("agent-report.md"));
String log = Files.readString(Path.of("ci.log"));
if (log.length() > 80_000) log = log.substring(log.length() - 80_000);

String jsonPayload = """
    {"claims": %s,
     "evidence": %s,
     "stakes": "pr",
     "strictness": "strict",
     "context": "CI gate on the merge queue. Evidence is this job's own stdout.",
     "prescan_facts": {"resources": [], "flags": []}}
    """.formatted(toJsonString(report), toJsonString(log));

// input-derived key: a CI retry of the same commit replays, it does not re-bill
var digest = MessageDigest.getInstance("SHA-256")
    .digest(jsonPayload.getBytes(StandardCharsets.UTF_8));
String key = "proof-desk:" + HexFormat.of().formatHex(digest).substring(0, 40);

// send POST /run with the header Idempotency-Key: key, poll GET /jobs/{id} every
// 2 s, then parse data.output.output as JSON. With the ledger in hand:
//
//   boolean contra = claims.stream().anyMatch(c -> c.status.equals("contradicted"));
//   boolean open   = claims.stream().anyMatch(c -> c.status.equals("unverified")
//                                              || c.status.equals("partially-verified"));
//   String verdict = contra ? "blocked"
//       : gate.equals("blocked") && !open ? "conditional"
//       : gate.equals("pass") && open ? "conditional"
//       : gate;
//
// Print report_name, verdict, every claim with its status, risk, evidence quote and
// proof_command, then blocking[] and next_commands[], and finally:
//
//   if (verdict.equals("blocked")) System.exit(1);
#!/usr/bin/env ruby
# ci_proof_gate.rb — run after the tests, with their output on disk.
require "digest"
require "json"

report = File.read("agent-report.md")
log = File.read("ci.log").slice(-80_000..) || File.read("ci.log")

payload = {
  claims: report,
  evidence: log,
  stakes: branch == "main" ? "deploy" : "pr",
  strictness: "strict",
  context: "CI gate on the merge queue. Evidence is this job's own stdout.",
  prescan_facts: { resources: [], flags: [] }
}

# input-derived key: a CI retry of the same commit replays, it does not re-bill
key = "proof-desk:" + Digest::SHA256.hexdigest(JSON.generate(payload))[0, 40]

started = api_with_key("POST", "/run", payload, key)
job = nil
loop do
  job = api("GET", "/jobs/#{started["job_id"]}")
  break if %w[succeeded failed].include?(job["status"])
  sleep 2
end
abort "gate run #{job["status"]}" unless job["status"] == "succeeded"

gate = JSON.parse(job.dig("output", "output") || job["output"])

# the two client-side invariants — keep the badge and the ledger in step
contra = gate["claims"].any? { |c| c["status"] == "contradicted" }
open   = gate["claims"].any? { |c| %w[unverified partially-verified].include?(c["status"]) }
verdict = if contra then "blocked"
          elsif gate["gate"] == "blocked" && !open then "conditional"
          elsif gate["gate"] == "pass" && open then "conditional"
          else gate["gate"] end

puts "::group::Proof Desk — #{gate["report_name"]} [#{verdict}]"
puts gate["verdict"], ""
gate["claims"].each do |c|
  puts "  #{c["id"]} [#{c["status"]} · #{c["risk"]} risk] #{c["kind"]} — #{c["claim"]}"
  puts "      evidence: #{c["evidence"].empty? ? "none in the log" : c["evidence"]}"
  puts "      $ #{c["proof_command"]}   # #{c["expected"]}" unless c["proof_command"].empty?
end
gate["blocking"].each { |b| puts "  BLOCKING: #{b}" }
gate["next_commands"].each { |s| puts "  #{s["step"]}. #{s["command"]}" }
puts "::endgroup::", gate["summary"]

File.write("gate.json", JSON.pretty_generate(gate))
exit 1 if verdict == "blocked"
#!/usr/bin/env php
<?php
// ci-proof-gate.php — run after the tests, with their output on disk.
$report = file_get_contents("agent-report.md");
$log    = substr(file_get_contents("ci.log"), -80000);

$payload = [
    "claims"        => $report,
    "evidence"      => $log,
    "stakes"        => $branch === "main" ? "deploy" : "pr",
    "strictness"    => "strict",
    "context"       => "CI gate on the merge queue. Evidence is this job's own stdout.",
    "prescan_facts" => ["resources" => [], "flags" => []],
];

// input-derived key: a CI retry of the same commit replays, it does not re-bill
$key = "proof-desk:" . substr(hash("sha256", json_encode($payload)), 0, 40);

$started = apiWithKey("POST", "/run", $payload, $key);
do {
    sleep(2);
    $job = api("GET", "/jobs/" . $started["job_id"]);
} while (!in_array($job["status"], ["succeeded", "failed"]));
if ($job["status"] !== "succeeded") {
    fwrite(STDERR, "gate run {$job['status']}\n");
    exit(2);
}

$gate = json_decode($job["output"]["output"] ?? $job["output"], true);

// the two client-side invariants — keep the badge and the ledger in step
$contra = false; $open = false;
foreach ($gate["claims"] as $c) {
    if ($c["status"] === "contradicted") { $contra = true; }
    if (in_array($c["status"], ["unverified", "partially-verified"])) { $open = true; }
}
$verdict = $contra ? "blocked"
    : ($gate["gate"] === "blocked" && !$open ? "conditional"
    : ($gate["gate"] === "pass" && $open ? "conditional" : $gate["gate"]));

echo "::group::Proof Desk — {$gate['report_name']} [{$verdict}]\n{$gate['verdict']}\n\n";
foreach ($gate["claims"] as $c) {
    echo "  {$c['id']} [{$c['status']} · {$c['risk']} risk] {$c['kind']} — {$c['claim']}\n";
    echo "      evidence: " . ($c["evidence"] ?: "none in the log") . "\n";
    if ($c["proof_command"]) { echo "      $ {$c['proof_command']}   # {$c['expected']}\n"; }
}
foreach ($gate["blocking"] as $b) { echo "  BLOCKING: $b\n"; }
foreach ($gate["next_commands"] as $s) { echo "  {$s['step']}. {$s['command']}\n"; }
echo "::endgroup::\n{$gate['summary']}\n";

file_put_contents("gate.json", json_encode($gate, JSON_PRETTY_PRINT));
exit($verdict === "blocked" ? 1 : 0);
// CiProofGate.cs — run after the tests, with their output on disk.
using System.Security.Cryptography;
using System.Text;

var report = await File.ReadAllTextAsync("agent-report.md");
var log = await File.ReadAllTextAsync("ci.log");
if (log.Length > 80_000) log = log[^80_000..];

var payload = new {
    claims = report,
    evidence = log,
    stakes = branch == "main" ? "deploy" : "pr",
    strictness = "strict",
    context = "CI gate on the merge queue. Evidence is this job's own stdout.",
    prescan_facts = new { resources = Array.Empty<object>(), flags = Array.Empty<object>() },
};

// input-derived key: a CI retry of the same commit replays, it does not re-bill
var canonical = JsonSerializer.Serialize(payload);
var key = "proof-desk:" + Convert.ToHexString(
    SHA256.HashData(Encoding.UTF8.GetBytes(canonical)))[..40].ToLowerInvariant();

var started = await SkillSafe.ApiAsync(HttpMethod.Post, "/run", payload, key);
JsonElement job;
while (true)
{
    job = await SkillSafe.ApiAsync(HttpMethod.Get,
        $"/jobs/{started.GetProperty("job_id").GetString()}");
    var status = job.GetProperty("status").GetString();
    if (status is "succeeded" or "failed") break;
    await Task.Delay(2000);
}
if (job.GetProperty("status").GetString() != "succeeded") return 2;

var rawText = job.GetProperty("output").GetProperty("output").GetString()!;
using var doc = JsonDocument.Parse(rawText);
var gate = doc.RootElement;
var claims = gate.GetProperty("claims").EnumerateArray().ToList();

// the two client-side invariants — keep the badge and the ledger in step
bool contra = claims.Any(c => c.GetProperty("status").GetString() == "contradicted");
bool open = claims.Any(c => c.GetProperty("status").GetString() is "unverified" or "partially-verified");
var declared = gate.GetProperty("gate").GetString();
var verdict = contra ? "blocked"
    : declared == "blocked" && !open ? "conditional"
    : declared == "pass" && open ? "conditional"
    : declared;

Console.WriteLine($"::group::Proof Desk — {gate.GetProperty("report_name")} [{verdict}]");
Console.WriteLine(gate.GetProperty("verdict") + "\n");
foreach (var c in claims)
{
    Console.WriteLine($"  {c.GetProperty("id")} [{c.GetProperty("status")} / " +
                      $"{c.GetProperty("risk")} risk] {c.GetProperty("kind")} — {c.GetProperty("claim")}");
    Console.WriteLine($"      evidence: {c.GetProperty("evidence")}");
}
foreach (var s in gate.GetProperty("next_commands").EnumerateArray())
    Console.WriteLine($"  {s.GetProperty("step")}. {s.GetProperty("command")}");
Console.WriteLine("::endgroup::");

await File.WriteAllTextAsync("gate.json", rawText);
return verdict == "blocked" ? 1 : 0;

Two things to decide before you turn this on for a whole team. Where the evidence comes from: piping the job's own stdout is the honest version, because it is fresh by construction and nobody can paste a green run from yesterday — but it also means an empty log when the pipeline gates before the tests, and an empty log makes every claim unverified by design. What to do with conditional: failing on blocked only, as above, is the version people leave switched on; failing on conditional too is defensible on a release pipeline (stakes: "deploy") and unbearable on a feature branch. Post summary and the next_commands list as a PR comment either way — the commands are the actionable half, and this is AI-generated judgement about text, not a substitute for running them.