Readback Verification: When 'Typed: ✅' Means the Wrong Field Has Your Email
An automated form filler reported success on every field while silently writing each value one field late. The tool's own success signal confirmed a write happened, not that the right value landed in the right place.
View companion repoThe near-miss on a real employer form
I was filling a Cloudflare Principal Software Engineer application on Greenhouse, the first live form in a 200-application batch, when I decided to read the values back before clicking Submit. That single check is the only reason Cloudflare did not receive a scrambled application under my real name.
The automation tool had just reported success four times in a row. Each type call targeted the correct CSS selector. Each response came back as Typed: into #first_name, then #last_name, then #email, then #phone. No error. No warning. Every signal in the system said the form was ready. Then I asked the browser what was actually in the fields.
{"first":"","last":"","email":"Krzemienski","phone":"krzemienski@gmail.com","country":"","loc":"9146495534"}
Email held my last name. Phone held my email address. Location held my phone number. First name and last name were empty. Had I trusted the tool and clicked Submit, Greenhouse would have locked that state permanently. Greenhouse applications cannot be edited after submission. The authorization I had been given to click Submit would have turned into a liability the moment the click fired on unverified state.
What the tool said it did
The fill sequence was the obvious one. Six identity fields on the Cloudflare Greenhouse form (boards.greenhouse.io/cloudflare/jobs/8038898): first name, last name, email, phone, country, location. I drove them through superpowers-chrome against the live page, selector by selector:
{"action": "type", "selector": "#first_name", "payload": "Nick"}
{"action": "type", "selector": "#last_name", "payload": "Krzemienski"}
{"action": "type", "selector": "#email", "payload": "krzemienski@gmail.com"}
{"action": "type", "selector": "#phone", "payload": "9146495534"}
Every call returned the same shape of success:
Typed: into #first_name
Typed: into #last_name
Typed: into #email
Typed: into #phone
I had already inventoried the DOM before typing. The field map was unambiguous:
{"tag":"INPUT","type":"text","id":"first_name","label":"First Name*"}
{"tag":"INPUT","type":"text","id":"last_name","label":"Last Name*"}
{"tag":"INPUT","type":"text","id":"email","label":"Email*"}
{"tag":"INPUT","type":"tel","id":"phone","label":"Phone*"}
{"tag":"INPUT","type":"text","id":"candidate-location","label":"Location (City)*"}
Selectors matched labels. Payloads matched intent. The tool confirmed each write. Under every conventional definition of "the step worked," the step worked. That is exactly why the failure mode is dangerous: nothing failed. The failure was that success meant the wrong thing.
What the DOM actually held
After the four type calls, I ran one eval against the live page:
{"action": "eval", "payload": "JSON.stringify({first:document.querySelector('#first_name')?.value,
last:document.querySelector('#last_name')?.value,
email:document.querySelector('#email')?.value,
phone:document.querySelector('#phone')?.value,
country:document.querySelector('#country')?.value,
loc:document.querySelector('#candidate-location')?.value})"}
The result, verbatim:
{"first":"","last":"","email":"Krzemienski",
"phone":"krzemienski@gmail.com",
"country":"","loc":"9146495534"}
Map that against intent:
| Field | Intended | Actual after "Typed: ✅" |
|---|---|---|
#first_name | Nick | (empty) |
#last_name | Krzemienski | (empty) |
#email | krzemienski@gmail.com | Krzemienski |
#phone | 9146495534 | krzemienski@gmail.com |
#candidate-location | (not yet typed) | 9146495534 |
Every value had landed one field late. The selector for a given call focused one field, and the keystrokes arrived in the next one. My session note at the time was blunt: "Values landed one field off: email holds 'Krzemienski', phone holds the email, location holds the phone. The selector focuses one field but typing goes to the next. Good thing I checked; blind-submitting that would have sent Cloudflare a garbage application."
I never fully isolated the root cause. The candidates were a stale DOM reference after a prior focus, or an event-ordering race between sequential type calls on a React-controlled Greenhouse form that also mounts an intl-tel-input widget beside the phone field. Either way, the mechanism mattered less than the observation: the tool's success flag answered "did I perform a write," and the only question that mattered was "is the right value in the right place."
The fix is not a smarter type call
I cleared the scrambled state and wrote through the native value setter with input and change events, then read the result back from the same setter path:
var set=function(sel,val){
var el=document.querySelector(sel);
if(!el) return sel+':MISSING';
var p=Object.getOwnPropertyDescriptor(
window.HTMLInputElement.prototype,'value').set;
p.call(el,val);
el.dispatchEvent(new Event('input',{bubbles:true}));
el.dispatchEvent(new Event('change',{bubbles:true}));
return el.value;
};
var r={};
r.first=set('#first_name','Nick');
r.last=set('#last_name','Krzemienski');
r.email=set('#email','krzemienski@gmail.com');
r.phone=set('#phone','914-649-5534');
JSON.stringify(r)
Result:
{"first":"Nick","last":"Krzemienski",
"email":"krzemienski@gmail.com","phone":"914-649-5534"}
That is the pattern. Write, then read the committed state, then compare to intent. The setter path happened to work for plain text inputs on this form. It is not the lesson. The lesson is that the second half of the pair (the readback) is non-negotiable. A write path that only reports its own action is unverified by construction, no matter how carefully you pick the write primitive.
I later folded the same discipline into campaign/ats_harness.md as a standing Greenhouse rule: after every fill step, verify the container or input holds the expected value before the next step, and never treat a per-action success flag as proof of final state. The harness was written because the near-miss was real, not because a style guide suggested it.
Why silent success is worse than a loud error
A type call that throws is honest. You see the error, you stop, you fix the selector, you continue. A type call that returns Typed: into #email while writing into #phone is worse in every dimension that matters for automation at volume:
- There is no error to debug. Log scraping, retry logic, and alerting all look at failure signals. A green step produces none of them.
- Downstream steps compound the damage. Once the email field holds a last name, every later validation that checks "is email non-empty" still passes. The form looks complete. It is complete and wrong.
- The blast radius scales with trust. I had authorization to submit. The batch was 200 applications across 4 ATS platforms and 41 orgs. Trusting one green fill path at that scale means dozens of corrupted applications to real employers, under a real legal name, on forms that do not support post-submit edits.
- The operator is trained to stop looking. After the fourth consecutive
Typed: ✅, the natural move is to proceed. Readback is the discipline that interrupts that training.
I said this to myself in the session, almost word for word: the click is not the risky part. Submitting unverified state is. Authorization to act does not substitute for evidence that the action produced the intended world state.
The same session produced two sibling defects in the same class. file_upload reported success while input.files.length read zero, because Greenhouse swaps the file input node after a successful attach; the résumé was actually on the form, and the obvious check produced a false negative. Separately, two overlapping listboxes lived on the page (the phone widget's country list and the form's react-select menus), so a global [role=option] query returned the wrong option set. Both are the same shape as the field shift: the tool (or the obvious DOM probe) answered a question adjacent to the one you care about, and the adjacent answer looked decisive.
What generalizes past Greenhouse
Any automation that mutates external state and then trusts its own return value has this hole. Browser form filling is just the version I hit with a legal name attached. The same pattern shows up in:
- CLI tools that print "wrote N bytes" without re-reading the file. A write to the wrong path, a truncated write under a full disk, a write that lost a race to another process: all of them can still report bytes written.
- API clients that treat HTTP 200 as "the resource is now what I sent." Partial updates, silent field coercion, and server-side defaults all produce 200 responses whose body (if you bother to read it) disagrees with the request.
- Agent tool layers that surface
success: truefrom a browser bridge, a CDP wrapper, or a Playwright action. That flag means the bridge completed a protocol step. It does not mean the page's React state, the form's validation model, or the server's stored record match intent. - Config deploys that report "applied" without querying the live value. Feature flags, DNS records, and IAM bindings are full of write-confirm paths that never read back.
The unifying rule is narrow enough to implement and strong enough to catch the class: after every write that matters, read the authoritative state from the system that will actually consume it, and compare that state to intent before the next irreversible step. For a Greenhouse form, the authoritative state is document.querySelector(...).value (or the react-select single-value node) on the live page, not the tool's stdout. For a config deploy, it is the live API GET, not the apply command's exit code. For a file write, it is the bytes on disk at the path the reader will open.
Two constraints make the rule hold up. First, read from the consumer's view of the world, not the writer's. The type tool's "I typed into #email" is the writer's view. The email input's .value is the consumer's view. Only the second one decides what Submit will send. Second, treat mismatch as a hard stop, not a log line. I stopped the Cloudflare fill the moment the readback disagreed. I did not "note it and continue." Continuing on a known field shift is how a near-miss becomes a submission.
The durable lesson
A write-confirmation is not a state-verification. Any automation that trusts its own success return without reading back the resulting state is unverified by construction.
I almost learned that the expensive way: one Submit click away from a permanent, uneditable, name-bearing garbage application at a company I actually wanted to work for, on the first form of a two-hundred-application run. The cost of the readback was one eval. The cost of skipping it would have been irreversible. That ratio is why the check is now mandatory in the harness, and why I no longer treat Typed: ✅ as evidence of anything except that a write was attempted.
Continue the series
- 48SeriesRejected Is Not Dead: The Liveness Probe That Killed Healthy TunnelsMy self-healing SSH daemon treated an auth rejection as proof the tunnel was gone. A rejection is proof the far end is alive and answering.
- 50SeriesThe Negative Control: Why a Test Suite of Only Positive Matches Cannot Catch Over-MatchingMy invoice verifier passed 22 of 22 checks on two real PDFs, then passed a stale-bank check on a Chase-era invoice that still carried the old account numbers. Every assertion was a positive match. None of them asked the pattern to fail. <!-- cite: verifier output, session c7e3c270-7aeb-4369-9f2e-9e14496219bf -->
- 47SeriesThe Capability LinkedIn Never Shipped: Designing a Pipeline Around One Irreducible PasteLinkedIn's API can fire feed posts on its own. It cannot create a Pulse article. I stopped trying to route around that gap and built the human paste into the pipeline as a first-class, gated state instead.
- 46SeriesObservability You Can See But Not Keep: A Live Stream Is Not a RecordA dashboard that renders a run in real time convinces you observability is solved. The real test is whether you can answer a question about a run that finished yesterday — and on my agent platform, the honest answer was zero rows.