Confirm a candidate in your own app
A bounded M2 walkthrough — from a screened lever candidate through your application CI, the rollout gate, refusal and rollback — with honest states for everything not yet built.
The Flywheel has found a repeated correction in your traffic and screened a
candidate that might fix it. Before that candidate is allowed anywhere near your
live requests, it has to clear evidence you can inspect: your own application
tests, in your own CI, then a randomized live contrast against a frozen
baseline. This M2 quickstart walks that path using only shipped surfaces — the
/v1/flywheel/candidates/:ref/rollout gate, the signed decision callback, and the
Flywheel screen — and it tells you plainly where the current build stops short
of a button, so you always know which state you are looking at and why.
It is bounded on purpose. The runnable example below calls the rollout gate, which reads your workspace state and reports readiness. It makes zero paid model calls, mutates nothing, and returns in one request.
What you get — and what you do not yet
M2 gives you, without changing how a single live request is served:
- a rollout gate — one server-side function that reads the four facts your CI cannot see (permissions, consent, budget, qualification) and reports whether a controlled rollout may start, refusing with a named reason for each blocker;
- a staged candidate view on the Flywheel screen — offline screening, your application CI, and live confirmation shown as three distinct blocks, never merged into one verdict, plus a single derived progression headline;
- a signed, replay-safe decision record — a human accept/reject/revert, written as an audited receipt and never as a learning label;
- the adoption lease and rollback lifecycle — an adopted lever expires by default and reverts to the frozen baseline unless it is renewed.
What M2 does not yet give you:
The gate reports readiness; it never presses. Starting a controlled rollout,
opening live confirmation and adopting are a human action the current build does
not yet expose as a one-click button on the candidate card — the rollout response
is typed so pressed is always false, and pressing is owned by a later packet
(FW10). Adoption is not a toggle either: it is a lease that requires a live
confirmation result and a scarce confirmation slot. Where this guide reaches a step
the UI does not yet drive, it says what you will see and why, rather than
showing a fabricated screenshot. The discovery screen that proposes a candidate for
you (DS3) is also still under construction; today a screened candidate reaches you
through the FW8 screening pipeline and appears on the Flywheel screen.
The three evidence kinds are never blended
This is the load-bearing honesty rule of the whole page, and the code enforces it
mechanically (@ultrarouter/core/flywheel/staged-evidence.ts):
- Offline screening is our replay on a disjoint split. A screening pass is not a live result.
- Application CI is your test/replay harness, in your CI. A passed check is not a live confirmation — and a direct model-call comparison can never claim application-level coverage.
- Live confirmation is a randomized contrast against a frozen baseline. It is the only evidence that can confirm improvement.
The three are derived from disjoint run stages, so an application check can never be presented as a live confirmation, and a screened candidate can never be presented as confirmed. "Passing CI, a merged PR or a model-generated correction is not a business-outcome label" is not a slogan here — it is where the type boundaries are.
Prerequisites
- The stack running. Locally,
pnpm dev:e2eboots the gateway onhttp://localhost:8080and the dashboard onhttp://localhost:3000. These endpoints are part of the learning preview, which is not on the invite-only hosted gateway (https://api.ultrarouter.io) yet — run the local stack or point at your own deployment. - A key and base URL. Set them once:
export UR_BASE_URL="http://localhost:8080"
export UR_API_KEY="ur_sk_live_…" # ur_sk_live_* or ur_sk_test_*- The
outcomes:writescope on that key — the rollout gate and the decision callback are Flywheel writes. New keys are granted it by default; confirm yours withGET /v1/key(step 1). - A screened candidate. You reach this guide once the Flywheel has screened a
learned_contextcandidate for one of your workflows. Until you have your own, every request below still returns a well-formed, honest response — the gate reports blockers for a candidate reference it has never seen rather than erroring. - The Flywheel screen enabled. It is behind the
dashboard.flywheelfeature flag (an unknown flag reads off, and/flywheelreturns 404 until it is on). It is seeded on in dev/e2e. - Time and cost. About five to ten minutes. The gate call and the introspection call replay no prompts and make no model calls — they cost nothing.
1. Confirm your setup
GET /v1/key describes the key itself — its scopes and caps — so a 200 proves your
base URL and key work end to end. Check that scopes includes outcomes:write.
curl -sS --fail "$UR_BASE_URL/v1/key" \
-H "Authorization: Bearer $UR_API_KEY"
echo
echo "ok: key introspection reachable — confirm 'scopes' lists outcomes:write above"2. Bind the candidate into your application CI
Application CI is the ultrarouter GitHub Action / CLI that
detects a configured prompt, model, tool/schema, retrieval or Context change, binds
it to your workflow IDs and your own application test command, and runs your
baseline and candidate revisions through your own test/replay harness, in
your own CI. The candidate arrives as an immutable reference
(@ultrarouter/core#candidateReference), so the Flywheel row, the pull request and
the harness configuration all point at the same object.
Three things about this step are guarantees, not conveniences:
- Your CI produces the headline, out of band. The gateway records the bound evidence-object hash; the pass/fail lives on your pull request. The headline is one of exactly three: Ready for controlled rollout, Needs evidence, or Regression found. A flaky test, a missing secret, a missing evaluator or an absent replay capability produces a named non-success — never success by omission.
- Untrusted code never sees platform secrets. Fork pull requests receive no secrets and no privileged execution of their code. Repository write is a separate grant from reading checks — you can run the whole workflow with only a downloadable patch and grant write later.
- Nothing production mutates during CI. A gateway-owned module change rides as an immutable candidate reference in the harness config; a code-owned change is a file diff you review before anything is written.
This step runs in your repository's CI, so there is no ultrarouter endpoint to
curl here. See the Flywheel API reference for the exact
evidence-object shape the gate reads back in step 3.
3. Check the rollout gate (the runnable bounded example)
Once your CI has a headline, ask the server whether a controlled rollout may start.
The gate is the one server-side implementation
(@ultrarouter/core#evaluateRolloutPreconditions): your CI supplies the facts only
it observes (the bound identity + the headline), and the server adds the four
facts your CI cannot see. It always returns 200 with pressed: false and the
full list of blockers — you should see everything you must fix, not just the
first.
curl -sS --fail -X POST \
"$UR_BASE_URL/v1/flywheel/candidates/urcand1_docdemo/rollout" \
-H "Authorization: Bearer $UR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"schema": "ur.flywheel.rollout.request.v1",
"candidate_ref": "urcand1_docdemo",
"candidate_state": "screened",
"observed_identity": {
"repository": "acme/app", "baseSha": "b", "headSha": "h",
"lockDigest": "l", "harnessHash": "hh", "configHash": "ch",
"datasetSplit": "holdout-a", "workflowId": "wf", "objective": "acc",
"evaluatorVersion": "v3", "evidenceEpoch": "e1"
},
"application_headline": "ready_for_rollout",
"requires_repository_write": false,
"change_shape": "gateway_owned"
}'
echo
echo "ok: the gate returned pressed:false with a named blocker list"Expected state. A 200 whose body is { "unlocked", "pressed": false, "blockers": [...], "evidence_hash", "candidate_ref" }. pressed is the literal
false — the gate can describe an unlocked rollout but has no representation for
a started one. unlocked is true only when blockers is empty. On a fresh
workspace you will typically see consent_revoked and budget_exceeded until you
grant corrections consent and a confirmation slot is free — each blocker names its
own family and reason.
The evidence_hash is the immutable evidence object the dashboard row and the pull
request both link to — recomputable from the bound identity, so "the dashboard and
the PR point at the same object" is a recomputation, not a promise.
4. What the gate refuses, and why
Every refusal is named and distinct, because your next action differs for each.
There is no generic "blocked", and there is no input whose absence is skipped — a
fact the server could not read is its own *_unknown refusal, never an implicit
pass.
Blocker reason | Family | Your next action |
|---|---|---|
rollout_not_permitted | permissions | Use a role that may start a rollout. |
repository_write_not_granted | permissions | Grant scoped repo write (a distinct grant from starting a rollout), or choose a patch-only change shape. |
consent_revoked | consent | Re-grant the corrections consent the candidate was derived under. |
budget_exceeded | budget | A confirmation slot is a scarce, approved resource — free one or raise the approved cap. |
candidate_not_qualified | qualification | Only a screened candidate may start a rollout; an adopted one is refused loudly (adopted_is_not_ci). |
application_check_not_passed | qualification | Your CI headline is not Ready for controlled rollout (including an absent check). Re-run CI. |
stale_head | frozen_baseline | Your application head commit moved after evidence was bound — re-run CI against the new head. |
app_config_changed | frozen_baseline | A bound config/lock/harness input changed — re-bind. |
epoch_changed | frozen_baseline | The measurement epoch changed; evidence from two epochs is never combined. |
*_unknown / authorization_unavailable | (its family) | The server could not read that fact — it refuses rather than assume. Retry once the state is readable. |
Try it: change application_headline to regression_found in step 3 and the gate
adds application_check_not_passed; set requires_repository_write to true with a
code_owned change shape and it adds repository_write_not_granted. The gate still
returns 200, still pressed: false — it is telling you what to fix.
5. Record the human decision
When a person approves or refuses a candidate, that decision is written as a signed,
replay-safe callback and stored as an audited receipt with the actor and a
mandatory reason. It is deliberately never a learning label: a human's opinion
of a candidate is not an independently verified outcome, so its learning_use is
hard-wired to audit.
# Illustrative — a real call is SIGNED (X-UR-AppCI-Signature / X-UR-AppCI-Timestamp,
# the FLW-26 MAC recipe keyed by your API key). Without a valid signature the server
# FAILS CLOSED with 403 "missing_signature"; unauthenticated is 401. The signed path
# is exercised in the gateway e2e (E2E-FLW-ROLLOUT / TST-FLW-CONFIRMATION).
curl -sS -X POST \
"$UR_BASE_URL/v1/flywheel/candidates/urcand1_docdemo/decisions" \
-H "Authorization: Bearer $UR_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"schema": "ur.appci.callback.v1", "kind": "decision",
"callback_id": "urcb1_…", "workspace_id": "ws_…",
"candidate_ref": "urcand1_docdemo", "evidence_hash": "…",
"decision": "accept", "actor_id": "usr_…", "reason": "cleared our CI",
"surface": "pull_request", "decided_at": "2026-09-15T00:00:00.000Z"
}'Expected state. With a valid signature: { "status": "accepted", "callback_id", "learning_use": "audit" }. A re-sent identical callback resolves to "duplicate" — a
visible no-op, never a second recording — because the callback_id is derived from
the callback's own identity. On the dashboard, the same decision is the candidate
card's Approve rollout / Refuse form: it, too, requires a reason and records an
audited receipt, and it, too, never presses the rollout itself.
6. Live confirmation and adoption — what you will see
This is the step the UI does not yet drive with a button, so here is the honest description of the state, not a screenshot.
When a rollout is started (FW10), a confirmation opens against a frozen baseline
serving profile — the exact effective-config version at open time. The candidate is
served to a treated arm; a fixed lever_holdout slice is served the frozen baseline
so the contrast is randomized and live. The lift ledger renders only as an interval
with its sample size, gated by an A/A false-positive check; superiority (the lower
interval bound above zero) is required before any improvement is claimed, and the
preregistered cost constraint must pass. No lever ever reads holdout traffic.
Adoption is a lease, not a switch. An adopted lever carries an expiry and reverts to the frozen baseline unless someone renews it — "if nobody renews, the lever reverts to the frozen baseline at expiry" is the designed default, so nothing runs unwatched forever. On the Flywheel screen the adopted lever appears under Adoptions (leases) with its granted/expiry dates and Renew lease / Roll back to baseline actions.
On the candidate card itself you will see the three evidence blocks from §"The three evidence kinds" side by side — each with its own authority label and verdict — a single progression headline (Collecting → Pattern found → Ready to test → Passed application checks → In live comparison → Confirmed / No improvement / Inconclusive → Adopted / Rolled back), the live lift interval when one exists, and the rollout gate rendered as a report (unlocked, or blocked with each named reason). The Supporting cases link opens the candidate's own run trace — the five-section what-happened / what-was-measured / result / inputs-and-outputs / lineage view, with bodies shown as content-hash pointers and gated by consent and retention.
7. Rollback and the epoch law
Rollback is available two ways, both shipped:
- The lease rollback — Roll back to baseline on an active lease, with a mandatory reason and an audited receipt, returns the call type to the frozen baseline.
- Automatic invalidation — an application deployment or rollback that changes the
bound baseline invalidates an open confirmation
(
@ultrarouter/core#confirmationInvalidation) rather than silently crediting an unrelated change as lift. A changed head isstale_head, a changed config isapp_config_changed, a changed epoch isepoch_changed, and a changed candidate iscandidate_changed. Evidence from two measurement epochs is never pooled — the code throws rather than combine them.
So a merged PR is never, by itself, "measured improvement", and a deploy under a running confirmation never quietly becomes the result.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
401 on the rollout or decision route | The request is unauthenticated. Send Authorization: Bearer $UR_API_KEY. |
403 on POST …/rollout | Your key is missing outcomes:write. Check GET /v1/key; mint or roll a key that includes it. |
403 missing_signature on …/decisions | The decision callback is signed and therefore api-key only, with a valid X-UR-AppCI-Signature. A session cookie has no signing secret. |
Gate returns consent_revoked | The workspace has not granted corrections_consent. Grant it on the Flywheel screen (the candidate was derived under it). |
Gate returns budget_exceeded on every candidate | No confirmation slot is free — a slot is a scarce, approved resource. Free one or raise the approved cap. |
Gate returns application_check_not_passed | Your CI headline is not Ready for controlled rollout, or no check has run. Re-run application CI and bind fresh evidence. |
404 opening /flywheel | The dashboard.flywheel flag is off for your workspace. Enable it, then reload. |
| No Adopt / Start rollout button on the card | Expected — pressing is not yet a UI button (the gate reports; pressed is always false). Adoption surfaces as a lease once a live confirmation completes. |
What this proves — and what it does not
Proves. You can carry a screened candidate into your own application CI, ask
the server whether a controlled rollout may start, and get back a pressed: false
report whose every refusal is named and whose missing evidence fails closed — with the
offline / application-CI / live evidence kinds kept type-distinct at every step, a human
decision recorded as an audit-only receipt, and adoption modeled as a revocable lease
with rollback. All of it makes zero paid model calls.
Does not prove. It does not prove the candidate is better. Only a live confirmation with a superior interval can do that, and this build does not yet expose the press that opens confirmation or the one-click adopt — so no lift figure, and no business-outcome label, is claimed here. A passing CI check, a merged PR and a model-generated correction are, by construction, none of those things. When FW10 wires the press, the same candidate on the same workspace will move through In live comparison to one of Confirmed / No improvement / Inconclusive, with the interval, sample and inspectable failures behind it.
Next steps
- Declare what "it worked" means for your outputs first — see Declare what "it worked" means.
- Plan a straight model swap instead — see Plan a model migration.
- Register the provider keys a live confirmation dispatches through — see providers & BYOK.
Import history
Bring past traffic and resolutions into the ledger without the proxy — usage-only vs intact-trace classes, the capability report, per-record idempotency, repairable per-row receipts, and binding history to a confirmed call type so its verdicts can count toward readiness. Nothing is guessed; nothing is trained.
Connect a check (oracles)
Register the check that knows the right answer — a platform rule, an HTTPS endpoint we call, or a worker that claims jobs — and let it earn the right to certify fixes. API reference level.