· 4 min read

Friction where damage starts

Developer tools prevent predictable errors when the safe path stays easy, damaging actions demand explicit intent, and escape hatches still expose the model programmers need to learn.

A round red fire alarm call point whose button sits behind a flip-up cover labeled push to open.
Drb400atx, Public domain

I once helped maintain an internal deploy command called ship. Its nicest feature was that you rarely had to name an account: the command reused whichever cloud profile happened to be active in your shell, so staging and production looked almost identical on the command line. One afternoon I watched a teammate request a production plan while intending to inspect staging.

The plan was read-only, so nothing changed, but I had a diagnosis ready anyway: more confirmation prompts. I added them to deploys, rollbacks, log tails, and a few inspection commands that could not modify anything. Within a week, people typed y before the prompt finished drawing — why was a log tail asking whether I was sure? Friction only helps when it sits where the damage starts and demands the fact that would prevent the failure, not another keystroke of agreement.

The pattern generalizes. When the shortest command chooses an environment implicitly, wrong-environment incidents follow, and a client that retries every request automatically will eventually deliver the same purchase twice. The shortest path through a tool predicts a surprising amount of its bug queue.

Alma Whitten and J. D. Tygar watched the ceremony side of this fail with PGP 5.0. In their 1999 usability study, the interface looked respectable by ordinary consumer-software standards, and given ninety minutes to sign and encrypt one message, most participants still failed. Their criteria for security software include one I keep returning to: people must remain willing to use the protection. A mechanism that prevents mistakes by exhausting everyone gets bypassed, automated away, or answered without attention.

Make the retry prove itself#

HTTP clients show what the better kind of resistance looks like. The HTTP semantics standard defines an operation as idempotent when repeating it has the same intended effect as sending it once, allows automatic retries for those operations after a communication failure, and warns clients away from retrying anything else unless they can prove the first attempt never applied.

A client library can put friction directly on that uncertainty. Ordinary POST requests go through untouched, and the demand appears only when a caller asks for automatic retry:

payments.tsts
type PostOptions =  | { retry?: false; idempotencyKey?: string }  | {      retry: { attempts: 2; when: "transport-failure" };      idempotencyKey: string;    };const options = {  retry: { attempts: 2, when: "transport-failure" },  idempotencyKey: order.id,} satisfies PostOptions;await http.post("/charges", charge, options);

The type leaves the protocol visible. A developer sees that the write is a POST, sees why the retry needs a key, and can drop to a lower-level client when an endpoint has different semantics. This is resistance that changes the request: the caller hands over the one fact that makes repetition safe.

The fuse board in the photograph above is the physical version. Each fuse passes ordinary current until an overload opens its own circuit, while the neighboring circuits stay live. The resistance sits exactly where excess current would turn a local fault into wider damage.

The wrapper that ate the vendor#

Our ship command failed the other half of the test. It started as a thin way to pass an account and revision to the provider, then acquired authentication, retries, manifest generation, rollout polling, and its own error vocabulary. A provider response containing a request ID and a failed health check emerged as rollout failed.

During one stalled release, I spent most of the incident bypassing our own wrapper so I could recover the upstream request ID. Two teammates had never run the provider command directly, because the internal guide treated that path as an implementation detail. Routine deploys had become effortless, and nobody had a vocabulary left for a deploy that stopped being routine.

Lisanne Bainbridge described this trap in 1983. Her paper “Ironies of automation” argues that automation can expand the human operator's problems when people retain responsibility mainly for abnormal conditions. The machine keeps the situations that provide practice, then hands the rare and confusing cases to someone whose feedback loop went quiet long ago.

I cannot separate the wrapper's effect from turnover and ordinary forgetting, but our incident notes kept ending with the same instruction: bypass ship to see the real error. That was enough evidence to stop adding features to it.

We also took the prompts off the read path entirely. The rebuilt command treats its three paths differently:

Read pathProduction writeBreak-glass
DemandsNothing — log tails, status checks, and plan generation run unpromptedThe environment, named in the commandAn explicit flag that bypasses the policy checks
PrintsThe exact provider operation, before it runsThe upstream command
RecordsProvider object names, request IDs, raw plans, and the equivalent upstream commandWho used it
Each path through the rebuilt ship command. The read column's empty cells are deliberate.

Last month I tried to delete the wrapper's custom rollout parser. An old CI job still depended on an undocumented phase field, so part of the parser went back in. The field is now called vendorRolloutPhase, and the raw provider status sits beside it. I still cannot remove that parser without breaking somebody's build.