Shipmind Labs

Why a job-posting lint reads the application form, not just the ad

· 9 min read

A statute that prohibits a hiring requirement is easy to honour in prose and easy to breach in a form. Ontario's job-posting duties under the Employment Standards Act, 2000, Part III.1 include a prohibition on requiring Canadian experience in a publicly advertised posting — and in practice the ad copy reads clean while the requirement sits in a dropdown option or a field label on the application form the ad links to. A lint that reads only the visible text misses the usual spot.

We hit this while building jobpostrules, a lint we run over a rendered posting before it is published: pay-transparency and disclosure duties by jurisdiction as of the posting date, prohibited application questions, and markup that matches the visible page. Rules are data with primary-source citations. It is a lint, not legal advice. The Canadian-experience rule was the first one where the interesting decision was not what the rule says but what surface the lint is allowed to read.

The requirement is rarely a tidy sentence#

The textbook breach is a sentence in the body copy:

5 years of Canadian experience required.

That case exists, and it is the one everybody writes a test for. It is also the one a human reviewer catches, because it is sitting in the paragraph they were already reading.

The cases that survive review look like this instead:

  • a form field labelled Years of experience in Canada, type number;
  • a select whose options are I have Canadian experience and None;
  • a field whose name is the only place the phrase appears at all, because the visible label was softened during a copy pass and the field name never changed.

In none of those does a sentence frame anything as a requirement. The ad copy may even be careful about it. But the question is being asked of every applicant, and it is being asked in the one part of the posting that the people reviewing the posting treat as plumbing rather than text.

So the reading surface for this rule is the posting plus the form. In our model those are two fields on the same object:

typescript
export interface FormField {
  name?: string;
  label?: string;
  type?: string;
  options?: string[];
}

export interface ApplicationForm {
  fields?: FormField[];
}

export interface Posting {
  visibleText: string;
  datePosted?: string;
  existingVacancy?: boolean;
  payRange?: PayRange;
  applicationForm?: ApplicationForm;
}

One lexicon, two surfaces#

The detector is deliberately small. It is a list of phrases and a substring test over normalized text:

typescript
import { normalizeText } from "../model.ts";

export const CANADIAN_EXPERIENCE_PHRASES: readonly string[] = [
  "Canadian experience",
  "Canadian work experience",
  "Canadian working experience",
  "Canadian industry experience",
  "Canadian market experience",
  "Canadian employment experience",
  "experience in Canada",
  "experience within Canada",
  "experience working in Canada",
  "Canada-based experience",
  "Canadian-based experience",
];

export function matchCanadianExperience(text: string): string[] {
  const haystack = normalizeText(text);
  return CANADIAN_EXPERIENCE_PHRASES.filter((phrase) => haystack.includes(normalizeText(phrase)));
}

normalizeText is the one shared primitive that makes this work on real input:

typescript
export function normalizeText(value: string): string {
  return value.replace(/\s+/g, " ").trim().toLowerCase();
}

Collapsing whitespace, trimming and lowercasing is not a nicety here. Visible text extracted from rendered HTML breaks phrases across lines and indentation at whatever width the markup happened to use, and casing is whatever the copywriter felt. A posting that reads Acme asks for CANADIAN\n EXPERIENCE only. has to match, and we keep a test on exactly that so nobody optimizes the normalization away:

typescript
test("matching is case-insensitive on normalized whitespace", () => {
  const report = reportFor({
    ...POSTING,
    visibleText: "Acme asks for CANADIAN\n   EXPERIENCE only.",
  });
  assert.deepEqual(ruleIds(report), [CANADIAN_EXPERIENCE.id]);
});

The detector returns every phrase it found, not a boolean. That matters for the message: a finding that quotes what it matched is a finding somebody can act on without re-reading the whole ad.

Pointing at the field#

The rule applies the same lexicon twice — once to the posting text, once per form field — and each hit becomes its own finding with its own path:

typescript
if (active.has(CANADIAN_EXPERIENCE.id)) {
  const inText = matchCanadianExperience(posting.visibleText);
  if (inText.length > 0) {
    findings.push({
      ruleId: CANADIAN_EXPERIENCE.id,
      severity: severityOf(CANADIAN_EXPERIENCE),
      message: `The visible text carries a requirement related to Canadian experience (${quotePhrases(inText)}); a publicly advertised job posting carries none.`,
      at: "/visibleText",
    });
  }
  for (const [index, field] of (posting.applicationForm?.fields ?? []).entries()) {
    const matched = matchCanadianExperience(fieldText(field));
    if (matched.length > 0) {
      findings.push({
        ruleId: CANADIAN_EXPERIENCE.id,
        severity: severityOf(CANADIAN_EXPERIENCE),
        message: `An application form field asks for Canadian experience (${quotePhrases(matched)}); neither the posting nor an associated application form carries such a requirement.`,
        at: `/applicationForm/fields/${index}`,
      });
    }
  }
}

Two details carry most of the value.

fieldText joins a field's name, label and options with a pipe separator. One haystack per field means a dropdown option trips the rule as readily as a label, with no second code path to keep in sync — and the separator means a phrase can never be assembled across two parts that do not contain it.

The at path is per field, not per posting. A finding at /visibleText sends someone to the copy; a finding at /applicationForm/fields/1 sends them to the field. The test asserts the path, because the path is the fix instruction:

typescript
test("a form field asking for Canadian experience fails at that field", () => {
  const labelled = reportFor({
    ...POSTING,
    applicationForm: {
      fields: [
        { name: "legalName", label: "Legal name", type: "text" },
        { name: "yearsHere", label: "Years of experience in Canada", type: "number" },
      ],
    },
  });
  assert.deepEqual(ruleIds(labelled), [CANADIAN_EXPERIENCE.id]);
  assert.equal(labelled.findings[0]?.at, "/applicationForm/fields/1");
  assert.match(labelled.findings[0]?.message ?? "", /"experience in Canada"/);
});

Findings come back through sortFindings, which orders by severity, then rule id, then path — so two runs over the same posting print identically and a diff of two reports is readable.

What we deliberately do not do#

The lint does not try to decide whether the surrounding sentence frames the experience as required. It reads the phrase where the phrase appears. A posting that says no Canadian experience is required fails this rule and has to be reworded.

That looks wrong the first time you see it, and we kept it on purpose, because the two kinds of mistake do not cost the same thing.

Severity here is not a hand-set field. The rule is a CitedRule with status: "in-force", and status maps to severity:

typescript
const STATUS_SEVERITY: Readonly<Record<RuleStatus, Severity>> = {
  "in-force": "error",
  advisory: "warning",
  unverified: "warning",
  pending: "info",
};

So every hit is an error and blocks publication. A miss on an in-force prohibition is a published ad carrying a question the statute says it cannot carry. A false positive is a quoted phrase in a report and a sentence rewritten to say prior experience in Canada is not necessary some other way — minutes of work, by the person who was already editing the copy.

A framing classifier inverts that trade. To spare the handful of benign sentences, it has to make a judgement about negation, scope and modality on marketing prose written by someone who was not thinking about parsers — and every time it gets that judgement wrong in the generous direction, a real case ships. We would be trading a cheap, visible, self-correcting failure for a silent one. For an advisory rule that might be worth it. For an in-force one it is not.

The same reasoning is why the lexicon is a list rather than a pattern: a phrase that is in it is auditable, and a phrase that is missing is a one-line change plus a test. The alternative, a clever regex, fails in ways nobody can enumerate.

What it costs to run#

Three things, and they are all small by design.

Lexicon upkeep. New wordings show up; adding one is an entry in the array. A test keeps the list honest — no empty, duplicated or overlapping phrase, and every phrase in the list matches itself:

typescript
test("the lexicon carries no empty, duplicated or overlapping phrase", () => {
  const seen = new Set<string>();
  for (const phrase of CANADIAN_EXPERIENCE_PHRASES) {
    const normalized = normalizeText(phrase);
    assert.notEqual(normalized, "");
    assert.equal(seen.has(normalized), false, phrase);
    seen.add(normalized);
  }
  for (const phrase of CANADIAN_EXPERIENCE_PHRASES) {
    assert.deepEqual(matchCanadianExperience(phrase), [phrase], phrase);
  }
});

Reworded copy. Substring matching on normalized text means the occasional benign sentence gets flagged. The finding quotes the match and names the field, so the cost is bounded at one edit.

Knowing when it does not apply. The rule is scoped, and the scope is recorded rather than implied. checkOntario skips it with a stated reason in notApplied for an internal posting, a general recruitment campaign, a known headcount under ONTARIO_MIN_HEADCOUNT, or a posting date before ONTARIO_EFFECTIVE_FROM — and there is a test that a report built for a headcount of 24 carries no findings and names the rules it did not apply. A skipped rule that leaves a reason behind is reviewable; a rule that quietly evaluates to nothing is indistinguishable from a rule that passed.

Close#

The compliance question was never hard: do not ask for it. The engineering question was which bytes count as the posting. Once the application form is inside that boundary, the rest follows — one lexicon, two surfaces, a path per finding, and a lint that would rather send back a sentence to rewrite than agree with a classifier about what the sentence meant.

Was this useful?

Building something similar?

or email hello@shipmindlabs.com