Skip to content

Job search filters — what a set criterion promises (WING-372)

Plain-language companion: what "Remote only" actually promises you.

The rule

A filter is a promise about every row in the filtered list.

When the user sets RemoteOnly, SalaryFloor, or EmploymentType, every row in JobSearchResponse.Results carries observed evidence that it satisfies that criterion. Rows a source could not speak to are not quietly mixed in — they are returned separately in Unverifiable, and the UI renders that collapsed, opt-in, and never interleaved.

What went wrong before

JobSearchCriteria.RemoteOnly was honored in exactly one place: the decorator wrapping Greenhouse, Ashby and Lever. AdzunaSource and UsaJobsSource implement ISearchableJobSource directly and never referenced it. JobSearchService merged every source's output into one flat list, so ticking "Remote only" returned filtered rows and unfiltered rows presented as one uniform result set, with nothing to tell them apart. SalaryFloor was applied by nobody at all.

Same failure class as WING-315's "never present an unfiltered list as filtered" and WING-108's "best-effort metadata, never a silent filter".

How it works now

1. Sources publish evidence; they do not decide

Each adapter stamps JobPosting.SearchEvidence with what it actually OBSERVED, at a provenance that records how strongly. Sources no longer choose whether to honor a criterion.

Provenance Meaning Can express a NEGATIVE?
SourceStructured A dedicated typed field the source published (USAJOBS UserArea.Details.RemoteIndicator, PositionRemuneration) Yes
ServerQuery The source's own server-side query did the narrowing AND the response carries the field it filtered on Yes
LocationText Whole-word match in the posting's Location No
TitleText Anchored match in the posting's Title No
None Nothing observed No — this is absence of evidence, never evidence of absence

Description is not an evidence source. It is prose, and "no remote work available" contains the word "remote"; a filter reading it would confidently include the exact jobs the user is excluding.

2. One evaluator decides, with no exemptions

JobSearchCriteriaEvaluator is the only place a criterion is decided, and JobSearchService runs it over every row from every source. There is deliberately no "this source already filtered server-side" flag. Server-side pushdown is still wanted for recall and rate budget — it just has to leave a receipt on the row (ServerQuery provenance), and those rows then pass on merit like any other. A trust flag is exactly the silent-skip shape that shipped the original defect.

3. Four outcomes, not two

Outcome Meaning Where the row goes
Verified Evidence satisfies the criterion Results
Contradicted The source's own typed data says it fails Dropped — we know it fails
Unverifiable No admissible evidence either way Unverifiable, with a reason code
NotRequested The user did not set this criterion n/a

Collapsing Contradicted and Unverifiable is what makes a filter start quietly discarding jobs it knows nothing about.

Reason codes are machine-readable so the UI writes its own copy per cause. The pay cases are deliberately split fine, because a single catch-all described a range in cases where no range was ever published:

Code Shape
remote-status-not-published source says nothing about remote
pay-not-published no pay figures at all
pay-period-not-normalizable published per hour/day; annualizing needs an invented assumption
pay-minimum-not-published only a ceiling ("up to $120k") — the floor's comparand is unknown
pay-starts-below-floor-no-ceiling open-ended "from $80k" below the floor; may still pay above it
pay-range-straddles-floor a real range starting below and ending at or above the floor
pay-range-inconsistent minimum above maximum — comparing against it means nothing
schedule-not-published no work schedule published
sources-disagree equal-strength sources published different values

A salary floor of zero or less is treated as unset at the controller: every posting clears it, and a $0 floor would mark unpaid roles "confirmed" against a pay requirement.

Equal-strength disagreement is a conflict, not a race

Stronger evidence legitimately overrides weaker — that is what the ranking is for. Two observations of the same strength saying different things is not something to resolve. Merge flags it Conflicted and the evaluator returns sources-disagree, because resolving it by arrival order would make a row's inclusion depend on service registration order.

4. Disclosure is quantitative

Participation reports, per source: fetched, verified, unverifiable, contradicted, and whether the source failed (a source being down must never read as a source having nothing). The UI states counts — "Adzuna: 214 roles — this source does not say whether these roles are remote" — because a number the user can act on beats a caveat they scroll past.

Per-source capability, and why

Source Remote Pay Schedule
USAJOBS SourceStructured — UserArea.Details.RemoteIndicator SourceStructured — PositionRemuneration, annual (PA) only SourceStructured — PositionSchedule[].Code
Adzuna text only none — see below none — contract_type/contract_time are absent from real results
Greenhouse / Ashby / Lever text only none none

Adzuna publishes no usable pay

Adzuna returns salary_min/salary_max next to salary_is_predicted. In the verbatim producer capture (tests/Fixtures/sources/adzuna-search-verbatim.json) every row has "salary_is_predicted": "1" with salary_min == salary_max — that is Adzuna's own model estimate, not the employer's posted range. Filtering a user's pay floor on an estimate is the fabricated-number failure this ticket exists to remove, so Adzuna contributes no salary evidence.

The same capture is why no salary_min or full_time server-side parameter is sent either: pushing the filter down would filter on those same predictions, and contract_type/contract_time are absent from real results, so nothing in the response could verify the narrowing afterwards.

USAJOBS pay is annual-only on purpose

PositionRemuneration carries RateIntervalCode. Only PA (Per Year) is mapped to figures. Anything else (PH per hour, PD per day) is stamped as observed but unfigured, which the evaluator reports as pay-period-not-normalizable. Annualizing an hourly rate needs an invented hours-per-year assumption, and a confident false match against a salary floor is worse than no filter at all.

Evidence merging at dedupe

The same job can arrive from two sources — one with a typed remote flag, one with nothing. JobSearchService.Dedupe unions the evidence (strongest provenance per criterion) so the surviving row is at least as well-evidenced as any row it replaced. Without this, whichever duplicate happened to be dropped would decide whether the job passed the filter.

Deliberate limits

  • Remote, Oregon is a real town. A bare Remote segment followed by a US state reads as a place, so those rows yield no evidence and are disclosed rather than guessed at either way.
  • Cross-source pagination is incoherent and tracked separately as WING-373. What ships here is the guard: sources are asked for a candidate window at page 1 and the user's offset is applied exactly once, centrally, after evaluation. Passing the user's page to both the source and the merged list applied the same offset twice — page 2 skipped the very rows it had just fetched and came back empty. The window over-fetches (×4, capped at 200) when a criterion is set, because withholding removes rows after the fact and a single page's worth would guarantee a short page.
  • Withheld rows are disclosed, not delivered. Each group returns a bounded sample with the TRUE count beside it. Returning every withheld posting unpaged turned a full-board fetch into a multi-megabyte response. pageSize is clamped at the controller because it sizes a real upstream fetch, not just a local slice.
  • Counts must reconcile — within a source. Participation is measured per source on that source's own rows, before dedupe, so Fetched == Verified + Unverifiable + Contradicted. VerifiedTotal and Shown are separate fields: paging truncates the list, and one number claiming to be both is how a count starts contradicting the list beside it.
  • Per-source counts do NOT sum to VerifiedTotal, on purpose. Two sources can each hold half a job's evidence — one knows the pay, the other knows it is remote. Per source neither can confirm it; merged, it is confirmed. Both statements are true. Making them add up would require crediting one source with another's evidence, which is the misattribution this accounting exists to remove. Present participation as per-source capability, never as a breakdown of the result list.
  • Reachable depth is bounded by the candidate window, and a page past it returns empty with a reason that says so. Attributing that emptiness to the sources ("everything was withheld") would fabricate an explanation for a limit of our own making. Real cross-source paging is WING-373.
  • Recall drops when a filter is set. That is the accepted trade. Never instrument this work on "filter compliance rate" — that metric is trivially maximized by returning nothing. Instrument on verified rows delivered plus unverifiable rows disclosed.

Adding a source

  1. Map what the producer actually publishes — verified against a verbatim capture, never a hand-written fixture (see producer-contracts.md; the USAJOBS capture caught four live drifts).
  2. Stamp SearchEvidence only for values the source really supplied. Never stamp Unknown.
  3. Never write an inferred value onto JobPosting.WorkModel / EmploymentType — evidence lives beside those fields, never over them, so a title match can never become indistinguishable from a source's own flag.
  4. Do not add a filter branch to the adapter. The evaluator handles it.