Letting a model gate a deploy
A model is good at reading a diff and saying “this is a version bump” or “this touches the migration”. That judgement is worth having in a deploy pipeline. What it is not worth is handing the model the authority to ship.
Two shapes give you the judgement without the authority, and both work with what’s already here.
Shape one: the model routes, it never approves
Section titled “Shape one: the model routes, it never approves”The model’s verdict picks a path, and one of the paths is a human. The
most it can ever do is decide something didn’t need a person — it cannot
decide something ships unattended that a human would have caught,
because “ships unattended” is only ever one branch of a switch you
wrote.
{ "version": "cryosleep-graph/v1", "nodes": [ { "id": "review", "type": "anthropic.messages", "retry": { "attempts": 3, "backoff": "10s" }, "config": { "credential": "claude", "with": { "model": "claude-sonnet-4-6" }, "input": { "prompt": "You are reviewing a deploy. Reply with exactly one word — low or high — for the risk of shipping this without a human reading it. Anything touching migrations, auth, or payments is high.\n\n{{ input.changelog }}" } } },
{ "id": "triage", "type": "switch", "config": { "cases": [ { "name": "auto", "when": "nodes.review.output.body.content[0].text == 'low'" } ], "default": "needs_human" } },
{ "id": "gate", "type": "approval", "if": "nodes.triage.output.branch == 'needs_human'", "config": { "prompt": { "$template": "The model flagged this deploy as needing a look:\n\n{{ nodes.review.output.body.content[0].text }}\n\nApprove?" } } },
{ "id": "deploy", "type": "shell", "if": "nodes.triage.output.branch == 'auto' || nodes.gate.status == 'success'", "config": { "run": "./deploy.sh production" } } ], "edges": [ { "from": "review", "to": "triage" }, { "from": "triage", "to": "gate" }, { "from": "triage", "to": "deploy" }, { "from": "gate", "to": "deploy" } ]}The deploy condition is the whole design in one line: it runs when the
model said auto, or when a human cleared the gate. A skipped node
still satisfies its dependents, so deploy is reachable down either
path, and the two are mutually exclusive by construction.
Run it with a low-risk change and the gate is skipped, deploy is
success, and nobody was paged. Run it with a high-risk one and the run
parks at the gate until a person answers.
Note the default on the switch is needs_human, not auto. When the
model returns something unexpected — an explanation instead of a word, an
empty reply after a bad day at the provider — the run goes to a human.
Make the safe branch the default and a surprising answer costs you a
notification, not an unreviewed deploy.
That default only protects you if the auto case is exact. == is
doing real work here; a substring test is not the same thing. Ask a
model for one word and it will sometimes write a sentence, and
text.contains('low') ships every sentence with the letters l-o-w
anywhere in it — including “this is NOT low risk, it touches auth” and
“high, this allows unauthenticated access”. Both auto-deploy. Match the
whole reply, and let anything else fall to the default you chose.
Answering the gate
Section titled “Answering the gate”A gate is answered the same way whether it’s an approval node in a
graph or an approval: job in a YAML pipeline. From a terminal:
cryo approve <run-id>The job name is optional — a run parks on one thing at a time, so the gate it’s sitting at is the one that gets answered. Any fields the gate declares are asked for in turn, with their type, options and default:
Deploy 9f2c1a to production?
ticket (text): OPS-412 env (select) [staging/production]: production note (textarea, optional):approved gate on dev:default:run-4BjXjnc59zq1cdFDIn a script, pass them instead. Values are converted to the type the gate declared, so a shell’s strings arrive as the right JSON:
cryo approve <run-id> gate --field ticket=OPS-412 --field env=productionIf you’re already watching the run with cryo follow, a on the
parked gate opens the same form there.
The submission is checked against the declared fields — a missing
required one comes back approval field "ticket" is required, with the
full schema, rather than releasing the run. What passes becomes the
node’s output, so a later node reads the decision at
nodes.gate.output.ticket.
The same thing over HTTP, which is what the web UI’s form posts:
POST /api/v1/orgs/{org}/projects/{project}/runs/{id}/approvals/gate{ "decision": "approve" }Underneath, a gate parks on a signal named after the node, so
cryo signal <run-id> gate --payload '{"decision":"approve"}' also
releases it. That path skips the field check, which makes it the right
tool for a gate with no declared fields and the wrong one for a gate
that has them.
Shape two: the model advises, a human decides
Section titled “Shape two: the model advises, a human decides”Simpler, and often enough: always ask a person, but give them the model’s reading of the change in the prompt they’re answering.
{ "id": "gate", "type": "approval", "config": { "prompt": { "$template": "Deploy {{ input.sha }} to production?\n\n---\n**Model's review**\n\n{{ nodes.review.output.body.content[0].text }}" }, "fields": [ { "name": "ticket", "type": "text", "required": true }, { "name": "note", "type": "textarea", "required": false } ] } }No routing, no branch to get wrong. The approver reads a summary they didn’t have to write, and the decision is entirely theirs. When you are starting out, do this one — you find out whether the model’s judgement is any good by watching how often you disagree with it, and you can promote to shape one once you trust it on a class of change.
What about letting the model approve directly?
Section titled “What about letting the model approve directly?”You can build it. The approvals and signals routes are ordinary HTTP, so a node holding a credential with a personal access token could call either one.
Consider what that actually is. The routes take user authentication, not a job’s — the engine deliberately doesn’t give a running job the authority to clear its own gates. Handing a model a PAT doesn’t grant it authority the system decided it should have; it borrows a person’s, and every approval in the audit trail then says that person approved it.
If you find yourself wanting this, the honest version is usually shape
one with a wider auto branch: write down, as an expression you can read
and a reviewer can argue with, the class of change that doesn’t need a
person. That is the same decision, made once, in the open, by you.
Tying it to a real pipeline
Section titled “Tying it to a real pipeline”The pieces upstream of the model call are the ones you already have:
- Only the branches you care about get here at all — branch routing keeps a docs push from ever reaching a deploy graph.
- The changelog the model reads is a job’s output, so a CI pipeline can
hand off with a
workflow:job naming this graph. See composition. - If the deploy itself must not run twice for one commit, claim the sha before deploying — see claiming a key.
- Serialising production deploys across runs is
concurrency:, not anything the model decides.
See also
Section titled “See also”- Calling a model — credentials, the reply paths, and the shapes that don’t involve a gate.
- YAML reference — the
approval:job kind for the pipeline-shaped half.