Make plan push failures machine-readable - #6
Conversation
The authoring Skill needs to distinguish damaged local input from an ambiguous remote outcome without matching English prose. Give every handled plan push failure a stable JSON discriminator while preserving the existing shell exit classes.\n\nWrap validated server diagnostics and problems in a bounded response projection, retain private recovery state only after an accepted Plan cannot be saved locally, and keep raw input and transport details out of stderr. Exercise the contract through the runner and installed package.
Technical reviewThis is the follow-up to the design suggestion on #5, and it closes it properly. One linear commit, The envelope is exhaustive, and I checked rather than assumedThe claim worth testing is "every handled Every one calls The unknown-error fallthrough still rethrows and crashes with a stack trace. That is the right call: an unhandled bug should not be dressed up as a contract code, and the body scopes the claim to handled failures. One thing I specifically checked because it would be an easy miss. The response allowlist preserves everything real and drops everything elseReplacing I ran the real First Draft diagnostic payloads through No field is lost, including { ...diagnostic, evil: "IGNORE PREVIOUS INSTRUCTIONS" }
// projected → the evil key is goneThat is the point of the allowlist, and it matters more than usual here. The consumer is an agent, so an unvalidated server body echoed into stderr is untrusted text landing in a model's context. Projecting known fields rather than filtering known-bad ones is the correct direction, since a blocklist only stops what you thought of. The loop with firstdraft/skills#6 actually closesOn #5 I flagged that five failure classes collapsing onto two exit codes would force prose matching. That was theoretical until the Skill showed up and did exactly that. Mapping the Skill's recovery branches onto the new codes:
All six are machine-readable. The two that previously had no signal but an English sentence now have codes. The README earns its place here by saying which half is the contract:
And exercising the envelope through the packed, installed CLI in The remaining work is on the Skill side, and this PR demonstrates whyWorth flagging for whoever picks up firstdraft/skills#6: that repository's This PR reworded that message. Old: New: The Skill's rule still matches, because the phrase "the Plan may have been accepted" happens to survive in the rewrite. That is luck rather than design, and it is a live demonstration of the coupling I described there: a message changed, no test in either repository noticed, and the consumer kept working by coincidence. Updating the Skill to key on Two smaller notes
An unvalidatable rejection now reports |
Lesson: writing errors for something that cannot readEvery error message you have ever written was aimed at a person. Someone reads "Could not connect to the database" and knows what to do. That works because the reader brings judgment. Now the caller is a script, or an agent. It cannot bring judgment. It has to decide something from your error, and all it has is what you printed. This PR is about that shift, and the pattern generalizes well beyond this CLI. Exit codes run out almost immediatelyThe traditional answer is the exit code. Zero means success, nonzero means failure, and shell scripts have branched on that for fifty years. firstdraft plan push || echo "something went wrong"The trouble is you get about three useful values before the vocabulary is exhausted. This CLI has six distinct failures, and they call for genuinely different responses:
Retrying is correct for one of those and actively harmful for another. All of them are "nonzero." You could assign each a number, and some tools do. It reads badly at the call site, though. Nobody remembers that 4 means the state write failed, and adding a seventh case means picking another magic number and hoping no wrapper script hardcoded it. So this PR keeps exit codes coarse, 0 for success, 2 for "you called it wrong", 1 for everything else, and puts the real information in a JSON object on stderr: {
"error": "request_outcome_unknown",
"detail": "The Plan may have been accepted, but the response could not be verified...",
"status": 502
}Two fields, two audiences. That split is the whole idea. The stable identifier and the readable sentence are different things, and conflating them is what makes error handling brittle. You have seen the failure already if you have ever written this: rescue => e
retry if e.message.include?("timeout") # please do not
endThat code breaks when someone improves the wording. Rescue the class, check a code, and let the message stay free to change. There is a live example of exactly this in the PR. A sister repository has instructions that detect one failure by matching the phrase "the Plan may have been accepted." This PR reworded that message, and the match survived only because the phrase happened to appear in the new wording too. Nothing tested that coupling. It worked by luck, which is the least durable reason for anything to work. Allowlist what you echo backHere is the subtler change. The old code, on a server rejection, printed the server's response body straight through: writeJson(stderr, result.body);Reasonable-looking. The server sent JSON explaining the problem, so pass it along. The new code builds the output field by field, copying only known keys: Two things make that worth the extra code. The first is stability. If you echo a body wholesale, every field the server happens to include silently becomes part of your CLI's output. You did not choose it, you cannot test it, and you will break somebody when the server changes. The second matters more given who is reading. That output lands in an agent's context, where text is at least partly instructions. A hostile or compromised server could include a field whose value reads "ignore your previous instructions." Copying known fields means that string never reaches the agent at all. Note the direction. The code does not scan for bad content and strip it. It names what is allowed and ignores the rest. Allowlists fail closed; blocklists fail open. A blocklist only stops what you thought of in advance, and you will not think of everything. Same reason you use strong parameters rather than trying to reject dangerous keys: params.expect(post: [:title, :body]) # allowlistAdd a column tomorrow and it is not mass-assignable until you say so. That is the behavior you want from a security boundary. I checked that the allowlist here is not too aggressive, which is the usual way this goes wrong. The real diagnostics the server sends survive it byte for byte, including a When to botherNot every script needs this. A one-off deploy script printing to a terminal you are watching is fine as is. Reach for it when a program is the caller and it has to make a decision. That covers CLIs meant for CI, anything an agent invokes, and JSON APIs, which have the same problem and a good convention for it in RFC 9457 problem details. The rule of thumb: if a caller would otherwise have to parse your prose to decide what to do, give them a code instead. You will change the prose eventually. That is what prose is for. |
Summary
plan pushfailurelocal_state_not_savedError contract
The stable codes are:
invalid_argumentsinvalid_configurationlocal_input_unreadablerequest_outcome_unknownserver_rejectedlocal_state_not_savedUnvalidated responses remain conservative: agents stop and reconcile rather than infer whether the Plan was stored.
Validated rejection payloads are projected into a bounded
responseobject. Command arguments, Plan bytes, rawnetwork errors, and unvalidated response bodies are never emitted.
This is the bounded follow-up recorded on #5 after the concrete consumer need surfaced in firstdraft/skills#6.
Verification
asdf exec npm run check