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, Oregonis a real town. A bareRemotesegment 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.
pageSizeis 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.VerifiedTotalandShownare 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¶
- 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). - Stamp
SearchEvidenceonly for values the source really supplied. Never stampUnknown. - 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. - Do not add a filter branch to the adapter. The evaluator handles it.