Matching
The reason you are looking
Aaron at Job Umbrella · September 25, 2026 · 18 min read
A job board asks what you are looking for and where. It never asks why. Those two questions look like the whole of a search, and until this week this site treated them that way too: the profile named some titles and a city, and every listing was scored against them.
Here are four people whose searches produce very nearly the same keywords. One wants the job they already have, at a company that will still exist in two years. One wants the next rung, and would rather wait three months than take another sideways move. One is leaving their field for good, so twelve years of work is now context rather than credentials. One has been out of work for two years caring for a parent, and the last thing they need is a ranking that reads the gap as a defect.
The four searches ask for the same words and want different jobs. No query can tell them apart, because the difference was never in the query.
Two axes, and only one of them filters
The profile now carries an “I am looking to” block: two optional choices, one for what (Land a job, Step up, Change direction, Return to work) and one for where (Near me, Fully remote, a new city). Between them they set about seventeen levers in the matcher — what a title hit is worth, what a keyword hit is worth, how hard a junior title or a staffing agency sinks, what a posting’s age costs it, the score a listing needs to be kept at all, and which city the search actually runs in.
Two rules held over the whole of it. First, an unset axis is the old behaviour exactly: the base weights reproduce the constants the scorer used to hard-code, so a profile that never touches this ranks bit-identically to last week. That was checked as 43 old-versus-new comparisons rather than asserted. Second, only Fully remote filters. Every other choice sinks. A listing that fits your keywords but not your intent ranks lower, it does not vanish — the same rule the listing flags obey, where an unknown always favours the listing. Remote is the one deliberate exception, because somebody who asked for remote work reads an on-site job at rank 40 as a bug rather than a suggestion.
And Matches says what it ranked for, in a line above the list: Step up, moving to Austin, TX. A ranking that changes silently is a ranking nobody can argue with.
An aspirational title is not a match
Change direction was the choice that needed real thought. The matcher rewards a listing whose title matches one of your suggested titles, which is sound while those titles are where you have been. For somebody changing field they are where they are going, and the bonus starts paying out for jobs they cannot yet get. So under Change direction the title bonus is halved and every keyword hit is doubled: a listing that names the transferable skills should beat one that only matches the target title.
A reviewer found the doubling had a back door. Keyword suggestions are seeded from the suggested titles, so “product” and “manager” were sitting in the keyword list as well, and doubling every keyword hit doubled those too. The title-only listing came back out on top by another route. A keyword that is only a word of a suggested title, and not one of your skills, is title signal rather than skill signal: it now follows the title bonus down instead of the keyword weight up.
Return to work takes the day the gap began. Older postings barely sink, and the gap is stated once, from the date, as a fact — never as a weakness, and never left for the model to characterise. The profile review learned to tell a gap from a thin role: a role that ended before the gap is not stale, and the gap itself is not a missing role. The same review pass caught the flaw that only shows up in a whole feature: Generate titles appended the new field’s titles after the old ones, and the background sweep searches only the first three, so a person who told the site they were leaving accounting kept being searched for accounting jobs.
The pay you asked for
A listing showed its pay verbatim and earned a bonus for stating it. Nothing said whether it was the pay you wanted, and “$40,000 to $160,000” earned the same bonus as a real range. There is now a target pay on the profile, and every stated pay is judged against it. No market data and no model: the two inputs are the posting’s own text and a number you typed. Both sides are annualised — 2,080 hours, 260 days, 52 weeks, 12 months — and the card says Below, Meets, Above or Overlaps, with both figures in the tooltip. A range whose top is more than two and a half times its bottom is too wide to mean anything: judged as nothing, no bonus, and never a penalty.
The interesting part is the fifth answer, which is the label it refuses to give. Three reviews found four defects and every one was the same kind — a confident wrong label. “SEK 600000-700000” read as having no currency and was judged in dollars. “$85k - $95k AUD” was judged as US dollars, because the code after the figures lost to the symbol in front of them. “$10,000 - $12,000 per month” was read as a yearly figure, because the unit lived in the description and never reached the pay text.
So a currency the module can see is stated but cannot name now gets no label at all. “Biweekly” is declined rather than guessed, and two claims that disagree (“C$90,000 USD”) get nothing. Somebody applies, or does not apply, on the strength of that one chip, which makes a wrong label strictly worse than no label.
Where the job was actually posted
Two weeks ago I wrote that somewhere between one in seven and one in three listings is already dead, and every fix in that post measured decay after the fact. The structural answer is to stop reading copies. A company’s own applicant-tracking board is where a role is born, and when a job comes off that board it is gone — no aggregator lag, no re-post, no stale duplicate. So company boards are a job source of their own now, and they sit first in the registry: where an aggregator carries the same posting, the deduplication keeps the company’s own apply URL and posting date.
Behind that source there is a directory rather than a list I keep by hand. The Forbes Global 2000 and the top 2,000 Nasdaq companies merge into one distinct list of 3,000; each company’s board slug is guessed and the three vendors are asked; one daily pull reads every live board into a table, and search answers out of the table with no vendor call at all. The number worth publishing is the disappointing one: about 200 to 260 of those 3,000 companies have a board that can be read, measured on two samples before any of this was built. Most large employers are on Workday, SuccessFactors, Taleo or iCIMS, none of which expose a board. A directory of 3,000 companies is a directory of roughly 250 boards, and saying so now is cheaper than discovering it in month three.
Two rules matter more than the pipeline. Nothing goes live on a guess: only a Greenhouse board whose own reported name matches the company goes live by itself, and every other hit waits in a review queue where the decision is made on where the board’s apply links actually go. The name is what a board says about itself; the apply domain is what it has to mean. That check knows two things — the three vendors’ domains and the company’s own name — and anything else is reported as “not recognised”, never as “wrong”, because a reviewer must not be told a problem was found when nothing was found. And a table is never proof of absence: rows are capped per board and per query, so a job missing from it may simply be past a cut. A listing is judged closed only by re-reading its own board, live.
One caveat, and it is the load-bearing kind: none of this is switched on in production. The pull is a script rather than a scheduled job, production names no boards, and the rollout order is deliberate — the table may only become the source after a first pull has actually succeeded. What ships today is the machinery, proven on a scratch database and on one hand-run pull of 74 boards and 12,231 postings on the development environment.
A field the parser cannot find is a field nobody sees
A resume is read by an applicant tracking system before any person sees it. Both exporters here are built to survive that parse, and the only proof is to run one, so there is now a button that does: it renders the export in memory exactly as the download does, hands the bytes to the same parse worker the profile importer uses, and compares what comes back against the document — name, email, phone, every role’s title, company and dates, every degree, school and skill. Nothing is stored, no model runs, and it shows you the parser’s own text behind a disclosure.
Then three reviewers ran it on ordinary resumes and it found the bug it was built to find, in our own exporter. Half of twenty generated resumes reported a skill missing from the PDF: the PDF library hyphenates a long word at the end of a line, so a skill went into the file as Cyber- and security, and a parser reads two halves and matches neither. The .docx of the same document was clean every time, which is exactly how this sort of defect survives — the format nobody checks is the format that breaks. Every text element now carries a callback that returns the word whole, set per element rather than through the library’s global registration, which would have changed the cover letter depending on which export ran first in the process.
Fixes still need the refuters
A fourth security review ran over the commits since the last release: three medium findings, eight low, all closed here. The pattern from last week repeated, and I now expect it — every one of the three fixes was broken by the reviewer set on it before it held.
The worst finding is worth stating plainly, because it is about other people’s data. The pass that retires a closed listing could be pointed at listings that were not yours. A profile whose title nobody posts under produced an empty result the feed marked complete, and the code read that emptiness as evidence against every listing it touched, for every person holding it. A snapshot now counts as evidence only when the listing’s own source put rows in it, when every significant word of the query is in the listing’s title, and when the place could have covered it — and a listing is retired only for the people whose own searches argued its absence. The reviewer’s thirty-line attack script leaves the victim untouched in every shape it takes.
A fifth review has already run, over the additions in this release itself. Server-side request forgery, cross-tenant access, script injection and runaway patterns were all cleared.
One more thing
This post sits on a blog that is itself part of this release: ten earlier posts, one for every Friday since the first commit, written in a single week from the release ledger and the commit history. The index says so plainly rather than leaving you to work it out, along with the other unusual thing about this project — the code is written by AI agents, under my direction, and reviewed by other agents whose instructions are to break it rather than approve it.
This is the first post written on the day of its own release. From here they all are.
Release notes
September 25, 202662 commits · 6 migrationsShipped — why you are searching
- An “I am looking to” block on the profile: Land a job, Step up, Change direction or Return to work, and Near me, Fully remote or a new city. Either axis left unset scores exactly as it did before.
- Only Fully remote filters. Every other choice sinks a listing rather than hiding it.
- Matches names what it ranked for, including the city once a move has one.
- Change direction halves the suggested-title bonus and doubles the skill keywords, and a keyword that is only a word of a suggested title follows the title bonus down.
- Return to work raises the recency floor, and the review, resume and cover letter state the gap once as a fact.
- A target pay on the profile. Every stated pay is annualised and labelled Below, Meets, Above or Overlaps, with both figures in the tooltip.
- A range more than two and a half times its own bottom is too wide to count: no bonus, and never a penalty.
- A currency the module cannot name gets no label at all. SEK, MXN, R$, “per month” and “biweekly” were all judged as US dollars a year before this.
Shipped — company boards
- Greenhouse, Lever and Ashby boards are a job source, first in the registry: where an aggregator carries the same posting, the company's own apply URL and posting date win.
- A directory of 3,000 companies from the Forbes Global 2000 and the top 2,000 Nasdaq listings, with each board slug found automatically.
- A daily pull stores every live board's postings in a table, and search and verify then answer with no vendor call and no quota charge.
- Only a Greenhouse board whose own name matches the company goes live by itself. Every other hit waits for review.
- Board review decides on where the apply links actually go, and reports an unplaceable domain as “not recognised” rather than as wrong.
- Boards not worth a daily read are scored out by rules alone: empty, stale, evergreen, outside the US, or a score under 35.
- Measured before building it: about 200 to 260 readable boards from 3,000 companies, because most large employers use Workday.
- Verified on a scratch database and one hand-run pull on the development environment: 74 boards, 12,231 postings. Not switched on in production.
Shipped — the Board and the job page
- Archive an application. It leaves the board, the stat row, the reply rate, the digest and every sweep, keeps its stage, documents, contacts and history, and restores exactly where it was.
- The reply rate no longer counts stage moves made by jobs the denominator no longer holds, which is how a rate goes over 100%. A second cause of the same thing — a job dragged straight into Interview, counted as a reply though it was never applied to — was found in testing and is fixed below.
- A posting taken down after you applied is found by the hourly sweep and written as one follow-up task saying when it went and how long after you applied.
- Silence after an interview: a thank-you note on day 1, a follow-up on day 5 and day 10, only ever the latest step due.
- A Contacts card on the job page, so the contact table a follow-up already reads by name finally has a screen.
- An interview sheet per job: the questions the posting is likely to ask, what you can say to each, and the resume lines each answer was built from. Tiered behavioural, senior or practical from the posting itself.
- The sheet downloads as .docx or PDF and prints as the sheet alone, not as the whole page around it.
Shipped — resumes and listings
- “Check how a parser reads it” renders the export, parses the bytes with the same worker the importer uses, and lists any field that did not come back.
- The PDF exporter no longer hyphenates a word at a line end. Half of twenty test resumes had been splitting a skill in the file and losing it to the parser.
- The customizer writes the summary for the job from points selected in code, instead of lightly editing the profile's own.
- A “Possible scam” badge over seven marks, each quoted verbatim from the listing, and a “Looks like a scam” dismissal that sinks the company and not the role.
- The scam checks were re-tightened against three reviewers' honest postings: 156 checks, every must-not-flag case clean.
- A listing its own source has stopped returning twice is retired for every holder, behind guards that made three reviewer-reproduced false retirements impossible.
Platform and money
- Google AdSense is the serving network and Adsterra is one variable away. Ads are still off in production: no placement is configured and nothing renders.
- The consent manager is gated on ads being served rather than on which network serves them, so an Adsterra-only configuration can no longer put an ad on a page with no consent message. A unit waits for a consent answer and loads nothing without one.
- The advertising script now loads only on the three pages that carry a placement, instead of on every page, and the privacy policy and help page say so.
- The sign-up allowlist gates the Google path as well as the password path, from one list in one module.
- A Google sign-up is counted, from the server, once per account, for the first time.
- This blog: ten backdated posts, the bio, and the promise that nobody can pay to reach the top of your list.
Shipped — a functional pass over the whole release
- Before any of this shipped, a functional pass ran over the release: 3,001 checks across ten areas, every finding then put to three reviewers set on refuting it. 53 survived. All 34 high and medium findings are fixed here.
- A currency that is a symbol or a name — “US$”, “£”, “Dollars” — was stored without complaint and then threw inside the formatter during the render, replacing the Board and the Applications list with an error page and hiding the row at fault.
- A salary past the integer column, a year outside four digits, a NUL byte and the orphaned half of a character each answered an empty 500 the form could say nothing about. They name the field now.
- The CSV import wrote a row at a time with no transaction, so one bad cell ended it halfway through and re-importing the corrected file duplicated everything that had already landed. It is one statement now, and it reads the archived column it has always written.
- A stage is read by the word rather than the letters in it, so a column called “Never applied” no longer stamps an applied date and “No offer” no longer counts as the employer answering — including in the figures published across accounts.
- A job description could put skills, a whole employer and a certification the candidate never had onto a tailored resume, and from there into the PDF they send to employers. Those three lists come from the profile now, not from the reply.
- A save carrying a title and no document wrote a blank document over the whole record, on resumes and cover letters alike. An absent field means unchanged.
- Every AI failure arrived as the hosting platform’s own HTML error page, so the message explaining a rejected key was never readable by the person who needed it.
- A Google sign-up was told on every page, forever, to verify an address Google had already proven. Sign-in sessions now last seven days rather than thirty.
Security
- Fourth review since the last release: three medium findings and eight low, all closed here.
- The retirement pass could be pointed at other people's listings through an empty result the feed had marked complete.
- Bullet selection normalised every bullet once per job term: 1.3 to 1.5 seconds of blocked event loop became 75 to 264 milliseconds.
- The PDF export had no size gate and no rate limit. An unbreakable 80-character word cost 16 seconds; the worst shape under the new cap is 1.2 seconds.
- DNS is pinned on the link check and the job-page read, which each resolved a host to prove it was public and then let fetch() resolve it again.
- Every one of the three medium fixes was broken by the reviewer set on it before it held.
Still open
- The daily company-board pull is not a scheduled job yet, and production names no boards, so nothing in that directory changes a search today.
Everything here is free to use
AI resumes and cover letters, a fit check against any posting, search across 12 job boards plus government sites, and a Board that tracks every application. No employer can pay to reach the top of your list.