DEV Community

Daniel Pertu
Daniel Pertu

Posted on

A form hash would refuse every reply, so we diff the field set instead

Notifio watches rental search pages and emails you when a new listing appears. With the auto-reply upgrade it can also answer the listing for you, by replaying a flow you demonstrated once on that site yourself.

Replay has one hard precondition. The form in front of it has to be the form it was recorded against. Portals redesign, A/B test, add a field, rename a label, and a recording made in August is a set of instructions for a page that may no longer exist. Typing a recorded flow into a changed form is the one failure that cannot be undone, because the thing on the other end is a real message to a real landlord in the user's name.

So the engine has to answer a question before every reply: is this still the same form? The obvious implementation of that question is a hash, and the hash is wrong.

Why equality fails

Hash the page and you have nothing. Every listing page differs: price, photos, agent name, the "12 people are viewing this" counter. Every single reply would abort.

Hash the form instead and it gets much better, and it is still too strict. Here is what actually happens to a contact form over a few months:

  • a label changes from "Message" to "Your message"
  • an optional "How did you hear about us" dropdown appears
  • the fields render in a different order on mobile
  • a new required consent checkbox appears
  • the message field is gone, because the flow now starts with a phone number step

The first three are irrelevant. The last two are serious. A hash cannot tell them apart, because a hash answers one question and it is not the one anybody has. The question is not "did this form change", it is "did it change in a way that makes my recording wrong".

The snapshot keeps the fields, not only the digest

So the stored snapshot carries both. The hash is there, because an exact match is a cheap fast path and a useful selection key. The field list is there because that is the thing you can actually diff.

export function buildFormSnapshot(fields: FormFieldSchema[]): FormSnapshot {
  const reduced = fields.map((f) => ({
    name: f.name, type: f.type, label: f.label, required: f.required,
  }));

  // Sorted so field re-ordering alone does not read as a redesign.
  const canonical = reduced
    .map((f) => `${fieldKey(f)}|${f.required ? 'req' : 'opt'}`)
    .sort()
    .join('\n');

  return {
    hash: crypto.createHash('sha1').update(canonical).digest('hex').slice(0, 16),
    fields: reduced,
  };
}
Enter fullscreen mode Exit fullscreen mode

Two decisions are buried in there. The list is sorted before hashing, so a re-ordered form is identical rather than different. And the identity of a field is not its CSS position:

function fieldKey(field: { name?: string; type?: string; label?: string }): string {
  // Prefer `name`: it is what the server actually reads, so it changes least.
  const identity = field.name?.trim() || field.label?.trim().toLowerCase() || '';
  return `${field.type ?? 'text'}|${identity}`;
}
Enter fullscreen mode Exit fullscreen mode

name first, because name is the one attribute the site's own backend depends on. Designers change labels. Frameworks change class names and ids. Nobody renames a form field the server reads without a reason.

Three buckets, and only two of them stop anything

The diff sorts every difference into one of three outcomes:

a required field appeared that we have no step for   -> unsafe, stop
a field the recipe fills has disappeared            -> unsafe, stop
anything else (labels, optional fields, order)       -> cosmetic, continue
Enter fullscreen mode Exit fullscreen mode

That is the whole policy, and it is deliberately asymmetric. "Unsafe" is defined narrowly, as exactly two conditions, because a guard that stops too often is a feature that silently does nothing, and a feature that silently does nothing is worse than one that is switched off: at least the switch tells you.

Note which fields count. Not every field that vanished, only the ones the recording actually writes to:

const filledKeys = new Set(
  recipe.steps
    .filter((s) => s.field && s.binding && s.binding.kind !== 'skip')
    .map((s) => fieldKey(s.field!))
    .filter((key) => recordedByKey.has(key))
);
Enter fullscreen mode Exit fullscreen mode

A field we never touched disappearing is not our problem. A field we fill disappearing means the message we send would be incomplete, and an incomplete enquiry to a landlord is a wasted enquiry.

The multi-step trap

That last .filter() is the part that took a real bug to find. Plenty of contact flows are two or three steps: your message, then your details, then a confirmation. At preflight time, when the drift check runs, the later fields are not in the DOM at all. They are not missing. They have not been rendered yet.

A naive drift check reports all of them as missing and refuses every reply on every multi-step site. So drift is scoped to the fields that were in the snapshotted form, and the later steps are verified a different way: each value is read back after it is typed, during execution, where the field provably exists.

Two checks, two places, because they are answering the question at two different moments.

Choosing between several recipes

Once drift is a diff rather than an equality test, something else becomes possible. A site with two contact-form layouts can keep two recordings, and the engine picks the one that still fits:

const exact = order.find((r) => r.form.hash === live.hash);
if (exact) return { recipe: exact, drift: diffForm(exact, live) };

for (const recipe of order) {
  const drift = diffForm(recipe, live);
  if (drift.ok) return { recipe, drift };
}

// Nothing matches. Return the best candidate anyway so the caller can report
// a specific reason ("site changed") rather than a vague failure.
const fallback = order[0];
return { recipe: fallback, drift: diffForm(fallback, live) };
Enter fullscreen mode Exit fullscreen mode

The fallback is the detail I would defend hardest. When nothing matches, the function still returns something, not null. Not so it can be replayed, it will not be, but so the refusal can name itself. The user gets "new required field: Phone number" on the site row instead of "reply failed", and that difference is the difference between re-recording in thirty seconds and filing a support email.

Recipes that fail are marked broken, but broken recipes stay in the ordering at the bottom. If a portal reverts a redesign, which they do, the old recording quietly starts working again without anyone being told it ever stopped.

Where this surfaces

A refusal is a reply status, not an error dialog. Notifio keeps a ledger of every reply attempt, and "the site changed" is one of twenty-one outcomes in it, eight of which mean never touch this listing again. I wrote about that set in 21 reply statuses, and the 8 that mean never touch this listing again. This post is the layer underneath one of those statuses: the thing that decides skipped_recipe_broken rather than acting on a guess.

Per-site honesty about where auto-reply currently works is on each portal's own page, for example Kamernet and Pararius. The recording flow itself is documented on the help page, and the app is on the download page.

Top comments (0)