Recovering a deploy that may already be serving
How a deployment client journals an attempt before submitting, retries under one operation ID, and after a crash accepts only status bound to that operation.

On this page
A deployment client spends most of its life waiting on something it can’t see. It sends source to the platform, the platform admits it, builds it and brings it up, and everything the client knows about that work arrives over one connection. If the connection drops after the request has left, or the process is killed while it waits, the client no longer knows whether the platform never saw the request, queued a build, refused it, or has already put the application into service. The two obvious recoveries are both dangerous. Submitting again can start a second build of something already on its way, and checking whether the application answers can return a perfectly healthy response from whatever was running before you started.
In August 2026 I designed and built the durable path through ferrite deploy, the client for Ferrite, my application platform. The platform’s half of the contract is a deployment API that takes an operation ID from the client and reports on that operation afterwards. The client’s half is making sure that question can still be asked after a crash, by a fresh process that remembers nothing and may be a script or a coding agent as easily as a person.
The journal
The deployment request is not submitted until there is a durable record of the attempt. Before it submits, the client computes a digest of the request, mints a random ID for the attempt and its operation, and creates a journal record holding that digest, the endpoint, the account and a digest of its identity, the application, a fingerprint of the workspace and an initial state of pending_submit. The write follows the usual durability ordering of temporary file, fsync, rename and then an fsync of the parent directory, and the create call returns success only after the synced record exists.
That ordering gives the client the guarantee everything else depends on. A process that dies before the record exists has submitted no deployment, and a process that dies after it leaves something a fresh process can read, so a process crash cannot leave a submitted deployment without a journaled identity. The attempt files themselves are authoritative and scanned directly, which means losing a rebuildable index can’t hide an attempt still in flight. They hold no source and no credentials, only what it takes to identify the attempt and follow it, and only their owner can read them.
When the outcome is unknown
If the transport fails during submission, the attempt moves to submit_unknown and reports DEPLOY_SUBMIT_UNKNOWN. A response that is malformed or doesn’t match the request is marked invalid, and only a response that checks out moves the attempt to queued with a build ID. The CLI reserves exit code 3 for reconciliation or resume required, so a script or an agent driving it can tell a deploy that failed from one nobody can yet speak for.
A submission may be replayed only with the same payload and the same operation ID. That works like an idempotency key, and it lets the platform treat the retry as the same operation and not a second one. The journal holds a digest and no source, so when no build ID was ever recorded, a resuming client has to rebuild the request from the workspace and compare digests, and if the workspace has changed it refuses, with the reason: the workspace changed after the journaled submission. Sending edited source under the old ID would make one operation ID mean two things. An end-to-end test against a stub of the platform drops the acknowledgment after submission on purpose. A fresh process resumes and reaches ready, and the stub has seen the operation twice and created one build.
What counts as ready
Neither the application’s URL nor the client’s own memory can tell a failed operation from one already serving. The platform’s account of this exact operation can, so that is the only answer the client accepts. Every status reply is checked against the journal: the operation ID, the owning account, the build ID and the application all have to match, and the state has to be one the client knows. Ready has a precise meaning. The operation has to say deployed and ready, name a generation, a host and a release ID, and report desired, running and observed values, with desired above zero, running at or above desired, observed equal to the generation and no last error. An answer short of that is rejected as an invalid binding, however cheerful the rest of it reads. An unrelated application answering at the same address satisfies none of this, and neither does the previous generation of this one.
Before it asks, a resuming client checks that it is still talking about the same thing, comparing the endpoint, account ID, account identity digest and project fingerprint with the journal. Where something doesn’t line up, it declines to guess:
| What a resuming client finds | What it does |
|---|---|
| A journal that is missing or corrupt | Exits 2 before making any account call |
| A different account from the one journaled | Exits 2, saying it doesn’t match, with no status call |
| No build ID yet, and a changed workspace | Refuses to resubmit |
| No build ID yet, and a matching request digest | Replays the same payload under the same operation ID |
| A ready attempt that has since been superseded | Reports DEPLOY_READY_NO_LONGER_CURRENT, exits 3 and leaves the journal unchanged |
The last row matters because ready isn’t a permanent truth. A later look at a ready attempt compares every field with the platform again, and if a newer deployment has replaced it the client says so and leaves the record alone, so the journal goes on saying what happened to that attempt while the command tells you it is no longer what’s serving. The same restraint applies across the record: fields that identify the attempt are preserved once set, and neither the acknowledgment nor the charge can move backward.
Why it failed
A fresh process should also be able to say why an attempt failed, not just that it did. The platform computes why a build was refused and returns the reasons with the operation’s status, and an earlier version of the CLI discarded them because they weren’t shaped like codes. Now the poll loop holds them at the moment it declares the attempt terminal and applies three shaping rules: control bytes are removed, so a reason can’t rewrite the terminal or smuggle a second line past the journal’s validation; a transport wrapper is turned inside out, so the cause in <wrapper>; last server error: <cause> comes first; and long output is cut down to the first error and the last lines. What gets printed and what gets journaled are the same, so the reason is still there for whoever resumes the attempt later.
Beyond the client
Reaching ready settles whether the platform is serving what it was asked to serve. Whether the application works is a further question with the same shape.
Coding agents make the distinction sharper, because an agent reports what it believes happened, and a build accepted by the platform is an easy thing to believe in. The skill packaged for agents tells them never to claim deploy success from build acceptance alone, and the evaluation harness I built around real coding clients, with adapters for Codex, Claude and Pi, grades source artifacts, admission, deployment, probes, browser behavior, task completion and effort separately. A pass requires a deployed, running application, real behavior in a browser and cleanup afterwards, and that separation is what exposes the false-positive success reports a synthetic tool-call score would miss.
At each layer the question has the same form: what exactly did I ask for, and is this answer about that thing? A client that keeps a durable record of the first part, and accepts only answers to the second, can lose its connection at the worst possible moment and still tell you the truth afterwards, including the truth that it doesn’t know yet.
Chris Chabot, November 2026