# Changelog of Greenhouse, Lever & Ashby Jobs Scraper + Changes (`domestic_buffalograss/company-hiring-tracker`) Actor

- **URL**: https://apify.com/domestic_buffalograss/company-hiring-tracker/changelog.md
- **Full Actor documentation**: https://apify.com/domestic_buffalograss/company-hiring-tracker.md

## Changelog

### 0.2.7 — 2026-10-13

Findings of the operator's audit of 2026-10-12. Prices and event names are unchanged.

- **Spreadsheet-safe text (new input `spreadsheetSafeText`, off by default).** Job titles, departments, teams and locations are written by each employer, and a value such as `=HYPERLINK("https://…","Apply")` was written to the dataset as it was; opened from a CSV/XLSX export it ran as a formula. With the option on, every text value in company and job rows that starts with `=`, `+`, `-`, `@`, a tab or a line break gets a leading apostrophe (`src/spreadsheet.js`, the same rule as the operator's Google Ads Transparency Scraper). Off by default because it changes those values for JSON consumers. Tests: `test/spreadsheet_safe.test.js` (removing it from either dataset write, or ignoring the input, fails the tests).
- **No start fee.** README, START_HERE and STORE_LISTING listed an Actor start fee of $0.00005, but the Pay-per-event setup has no `apify-actor-start` event: nothing is charged when a run starts. `ACTOR_START_PRICE` is 0, the README row is replaced by "There is no Actor start fee", and the two failure cases now say that nothing is charged. The cost examples are unchanged after rounding.

### 0.2.6 — 2026-10-12

Publishing (added the same day, no version change): `.actor/output_schema.json` and `"output"` in `.actor/actor.json`. Apify Store refused to publish without an Actor output schema. It points to the default dataset items and the `OUTPUT` record. Build 0.2.2; the code is unchanged.

An owner decision (a limit on job rows on the first run) and fixes from the final review of 0.2.5. Every change has a regression test in `test/regressions_v026.test.js` (the reviewer's repro scripts x_detect, x_defs scenarios 1 / 2 / 6, x_basedetail and x_restart R1 / R2 turned into assertions; the billing fuzz x_fuzz.mjs is `test/fuzz_harness.js`, run on fixed seeds by 3 tests: with crashes nothing is ever charged more than it was delivered, without crashes there is no violation at all). 26 of its 27 tests fail on 0.2.5; the other one keeps a restart from counting an anomaly or a 404 twice when it comes after the commit and before OUTPUT (the 0.2.3 test #8 now covers a finished run, which does nothing at all). Prices, event names and dependencies are unchanged.

Owner decision

- **At most 50 job rows per company on the first run** (new input `baselineJobRowsPerCompany`, "First-run job rows per company", default 50, 0–1,000; 0 = no job rows on the first run). Applies to `outputJobs: "new"` on a baseline (the first run of a board, or a new baseline after the filters changed); `outputJobs: "all"` still outputs every open job. The rows are the most recently posted jobs (jobs without a date last, ties in feed order). The jobs over the limit are not output and not charged; the company row counts them in the new field `baselineJobRowsOmitted` (0 when every job was sent, empty on other runs) and its `statusDetail` says "N of M open jobs sent as job rows". The baseline itself still saves every open job (so none of them is reported as new later), and the rows within the limit still follow the budget rules. The default 3 boards (160 / 309 / 165 open jobs on 2026-10-11): 3 checks + 150 job rows = $0.006 + $0.150 ≈ $0.16 at FREE prices (was ≈ $0.64); README, input description, Store listing and START_HERE §3 say so.

Final review of 0.2.5

- Medium 1 (detect.js): a careers page that timed out (or answered 5xx / 429) while the other page tried answered 404 was taken as "no board" and cached for 7½ days. "No board" now needs at least one careers page that answered 2xx, or no page with a missing answer: when no page tried answered 2xx and at least one gave no answer, a 5xx or a 429, the result is `fetch_failed` (free, not cached) and the next run looks again. A host that does not exist (NXDOMAIN) or is blocked, and pages that answer 404 / another 4xx, still give a cached "none".
- Medium 2 (report.js): `prevOpenEstimate` counted a job missing once that had already offset a new job twice (the new job is listed): a turnover of 10 on 50 jobs gave 60 instead of 50, so real growth afterwards could miss `hiring_up`. It is now listed + missing once without the replaced ones (x_defs 1, 2 and 6 pinned: 50 / 50 / 55).
- Medium 3 (run.js): when first-run job rows were cut by the budget, the company row still said "baseline saved". Job rows are now planned with the company checks and changes, before any company row is output: a row whose job rows do not all fit says `baseline waits: N of M job rows sent; the rest next run` (on a change: `changed since last run; N of M job rows sent, the rest next run`), the status message adds "N job rows wait for budget", `OUTPUT.safety.jobRowsWaiting` counts them, and the rows output never exceed the plan.
- Medium 4 (run.js, from 0.2.4): a restart after the ledger note and before the company rows were pushed did not output the row again but charged `company-changed`, and the board was committed, so the change was never shown. A restart now does not charge `company-changed` for a board whose company row the earlier attempt noted but whose change it did not get to attempt, does not send its job rows and does not commit it; the next run shows the change and charges it once (`OUTPUT.safety.restartChangesHeld`).
- Low 5 (run.js, from 0.2.4): a restart after the run had cleared its per-run notes (during the digest, or after the run finished) output the boards it had not committed (waiting for budget) a second time and charged `company-checked` again. The notes are now cleared last, after the digest and `OUTPUT`; the digest is noted before it is sent (`digestRunId`), so a restart does not send or charge it again; and a run that output anything a restart could repeat (free rows, boards not saved, a digest) writes a small record `f-{stateKey}` (run id, time, status message) right before the cleanup, so a restart of a finished run outputs, charges, sends and writes nothing (`restartOfFinishedRun: true` in what it returns; the first attempt's `OUTPUT` is kept). README "never outputs a second row" now also covers these cases.
- Low (docs): "growth by function" / "growth per function" in actor.json, the Store listing and README said more than the field does: now "new jobs by function" (`topGrowingFunction` is the function with the most new jobs; `…Delta` columns are listed differences). "a careers page + up to 6 board-name guesses" is now "up to 2 careers pages + up to 6 board-name guesses" (the careers URL given and `/careers`, or `/careers` and `/jobs`). README "Scope and limits" names the limit of id-based diffs: jobs given new ids for one run show +10 and then −10 (total 0).
- Low (OUTPUT): `OUTPUT.detection.started` / `deferred` also counted the domains of a chunk dropped at the time limit (and of companies after the cut). They now count the companies the run output.

Known limitations

- The record `f-{stateKey}` stays until a later run of the same watchlist replaces it (one small record per watchlist; it only matters to a restart of that same run). A restart that comes before it is written (during the digest, or before `OUTPUT`) is covered by the per-run notes, which are still in the ledger then.
- A held change (Medium 4) is shown again by the next run even when the earlier attempt's row did reach the dataset: the change can appear twice, it is charged once.
- The job-row plan uses `billing.fits()`, which is a lower bound of the budget left (accurate to the cheapest price): at the edge one row fewer than the budget would allow may be sent (it is sent by the next run).

Tests and docs

- Tests of 0.2.1 / 0.2.3 / 0.2.5 that pinned the old behaviour were updated to the new one with their intent kept: W6 (restart right after the company row: the change is now charged by the next run, once), #8 (a restart of a finished run now does nothing at all), 中5 ("one 503 and one 404" is now `failed`), the exact version and the first-run wording.
- `tools/mutation_check.mjs`: 6 mutations re-anchored to the changed code, 23 added for 0.2.6, and 1 removed that became an equivalent mutant ("a 404 counted twice by a restart": the free-row note written before the push and the counter commit already stops it); 245 in all.

### 0.2.5 — 2026-10-12

Owner decisions (prices, title, job rows on the first run, a limit on free detections) and fixes from the fifth independent review of 0.2.4. Every change has a regression test in `test/regressions_v025.test.js` (the reviewer's repro scripts r1_definitions scenarios A–N, r2 / r2b / r2c country, r3 key-value traffic, r4 budget, r5 detection cache and own_mutations turned into assertions); 35 of its 38 tests fail on 0.2.4; the other 3 pin behaviour that 0.2.4 already had right but that no test noticed when it was broken (the digest's budget check by event name, the key-value traffic of 100 boards, `billing.fits()` as a lower bound of the budget left); the r4 budget invariants fail on 0.2.4 only because they also check the new fields hidden on withheld rows.

Owner decisions

- **Prices changed** (FREE / BRONZE / SILVER / GOLD+, now also in `src/billing.js` `EVENT_PRICES`, which a test compares with README, docs/STORE_LISTING.md, START_HERE.md and .actor/actor.json): `company-checked` $0.002 / $0.002 / $0.0017 / $0.0015 (the BRONZE discount is gone; was $0.002 / $0.0015 BRONZE+ / $0.0012 GOLD+), `job-row` $0.001 / $0.001 / $0.0008 / $0.0007 (was $0.0005 / $0.0003 / $0.0003). `company-changed` ($0.003 / $0.002 / $0.002 / $0.002), `digest-sent` ($0.01) and `apify-actor-start` (platform default) unchanged. Event names unchanged. README cost examples recomputed: ~$0.40 (30 companies weekly), ~$3.59 (300 weekly), ~$169 (2,000 daily), plus the first run of 30 companies with 50 jobs each, ~$1.56.
- **Title changed** to `Greenhouse, Lever & Ashby Jobs Scraper + Changes` (actor.json title, README heading, Store listing). The short description (actor.json `description` = Store listing) says first that it gets the jobs and what changed, gives the price as a formula ("$2 per 1,000 companies checked + $3 per 1,000 changed companies + $1 per 1,000 job rows") and ends with "Not affiliated with Greenhouse, Lever or Ashby."; the Store search terms greenhouse / lever / ashby jobs scraper, ats jobs api and hiring changes are in the listing.
- **Job rows on the first run**: with the default `outputJobs: "new"` a baseline (first run of a board, or a new baseline after the filters changed) outputs one row per open job (`change: "baseline"`) and bills `job-row` for each, under the same budget rules as new-job rows (rows that do not fit wait, the board's baseline is not saved until they are paid, the next run sends the rest and bills each row once). The default 3 boards: 3 company rows + one row per open job (about 630 on 2026-10-11, ≈ $0.64 at FREE prices; the test pins 3 + 9 rows = $0.015 on fake boards of 4 / 3 / 2 jobs). README, input description and START_HERE §3 say so.
- **At most 1,000 free detections per run**: domains with no usable cached detection (careers page + up to 6 board-name guesses) are detected in input order, at most `maxDetectionsPerRun` per run (new Advanced input, default 1,000, 1–5,000). The cache is read for the whole chunk first and the detections are handed out in input order, so the order never depends on timing; the entry read is handed to the detection (one read per domain, as before). The rest get the new free status `detection_deferred` (no request; `statusDetail` says why) and later runs detect them; the run still gets to the end of its list, so the resume point is untouched. `OUTPUT.detection` = `{started, deferred, limitPerRun}`; the status message says how many wait.

Fifth review — hiring signals (report.js, digest.js)

- Medium 1: real growth in the same run as a temporary miss stayed growth 0 for ever (the new jobs were offset by the missing ones, and those came back as "returned"). A job missing once that offset a new job and comes back with the same id now takes its offset back: that run's `growth` is +1 per such job (new field `returnedReplacedCount`), and the run is a change (`changed: true`, billed once; the summary says "Hiring growth +N"). Scenarios B (+10 then +10 in total) and E (6 back, 4 closed: +6) are pinned; a flapping board without new jobs stays growth 0 and no change.
- Medium 2: `hiring_up` / `hiring_down` were measured against the listed count of the previous run, which a temporary miss had lowered (A: 3 new against 30 still listed = `hiring_up`; C: 9 of 50 closed against 41 = `hiring_down`). The base is now the jobs open at the previous check as far as anyone can tell: listed then plus missing once then (new field `prevOpenEstimate`), also for the 5-job floor. `prevTotal`, `delta` and `deltaPct` stay listed counts. README no longer says "previous listed count". Two 0.2.4 boundary tests were moved to the new base (10 of 50 is still exactly 20%).
- Medium 3: `topGrowingFunction` and the summary's per-function moves were listed differences, so a board recovering from a miss read "Engineering +20" while the growth was 3 Sales jobs (N). `topGrowingFunction` is now the function with the most new jobs; the summary names the functions of the new jobs ("New: Sales 3.") and of the confirmed closes ("Closed: Sales 10.", and "10 closed (Sales 10), already counted as replaced …"), so the run that confirms a decline shows which function it hit (D). The flat `…Delta` columns and `countsBy` stay listed differences (documented). The Companies view (actor.json overview) shows `growth` and `missingOnceCount` (and labels `delta` "Listed change").
- Low 7: the digest's "Sharp declines (20%+)" used its own rule (growth / previous listed count); it now lists exactly the `hiring_down` rows. Each Growing / Sharp declines line shows the parts that add up to `growth` ("-20 (0 new, 20 closed)" instead of "-20 (0 new, 30 closed)" when 10 of the closes had already offset new jobs; "+10 (0 new, 10 back that had been counted as replaced)"). New field `closedOffsetCount` (closes that had already offset a new job). The webhook JSON carries `prevOpenEstimate`, `replacedCount` and `returnedReplacedCount`.
- Withheld changes (spending limit) also hide the new fields.

Fifth review — countries, detection, restarts, docs

- Medium 4: a code after "Remote" was read as an ISO code even when it is also a US state / Canadian province: "Remote, CA" -> Canada, "Remote, CO" -> Colombia, "Remote, IN" -> India, "Remote (DE)" -> Germany, while "Remote, WA" / "Remote, TX" were US. Policy (one rule for all of them): after "Remote" (and for a code with nothing before it) a code counts only when it has one meaning. "Remote, WA" / "TX" / "NY" stay US (state codes only), "Remote, ON" / "BC" Canada, "Remote (FR)" / "Remote - UK" France / GB; "Remote, CA", "Remote, CO", "Remote, GA", "Remote, PA", "Remote, MA", "Remote (DE)", "Remote (NL)" and "Hybrid, CO" become `null`. Country names are unaffected ("Remote - Colorado" US, "Remote - Germany" DE).
- Medium 5: a careers page that timed out or could not be connected to was taken as "no board" and cached for 7½ days. When every careers page tried gives no answer, a 5xx or a 429, "no board" is not concluded: the result is `fetch_failed` (free, not cached; a `needs_review` from the guesses is returned but not cached either) and the next run looks again. A page that answers (404 included) and a host that does not exist (NXDOMAIN) or resolves to a blocked address still mean "no page" (cached as before): `http.getText` now returns status 0 for those, null only for no answer.
- Low 9: the input description of "Only send when something changed" says what the code does: the digest is also sent when a company only got its first job in a new country or a new department.
- Low 11: a restart of the same run (migration) output a second row for a company whose result changed between the attempts (read the first time and failed the second, or the other way). Company rows the earlier attempt noted in the ledger (`checkedRunId` per board, `freeOut` per input, as of the start of the restart) are not output, charged or committed again; the next run reports the board (`OUTPUT.safety.restartedRowsSkipped` counts them).
- Low 13: the User-Agent links the Actor's Store page when the platform tells it: `ACTOR_FULL_NAME` ("owner/actor-name", checked against a strict pattern) -> `https://apify.com/owner/actor-name` (the Store's address format). `ACTOR_PAGE_URL` still overrides it; locally (no `ACTOR_FULL_NAME`) there is no link. That the variable is set on Apify is known from the SDK's constants only (not seen on the platform yet).
- Low 10 (test holes): the digest's decline threshold and the event name of the digest's budget check now have tests that fail when they are broken, and mutations.

Recorded, not changed (fifth review: specification decisions / known constraints)

- Spending limit (r4): when a change fits but not all of its job rows, the change is shown and charged and the rows that fit are sent, but the board is not committed; the next run shows the same change again (not charged again), re-sends the rows already paid (free) and charges the rest. Nothing is billed twice and nothing is given away, but the same change appears in two runs.
- Key-value traffic (r3): every run writes each board's snapshot (one record per board) and reads it twice (the diff and the revision check before the commit), also when nothing changed — about 1 write and 2 reads per board per run plus a few per chunk (100 unchanged boards: ≤ 115 writes, ≤ 220 reads, pinned by a test).

Known limitations

- The first run now costs one job row per open job by default (the default 3 boards ≈ $0.64 at FREE prices); a trial with `outputJobs: "none"` costs $0.006.
- Restarts of the same run start the detection count again (the domains the first attempt detected are cached and do not count). The re-check of a board that suddenly looks gone (ATS switch) is not counted against the limit.
- Which pending jobs a replacement offsets is still decided by order (see 0.2.4): when some come back and others close, the "back that had been replaced" mark can sit on a different job than the one really replaced (the totals stay right).
- A job that was confirmed closed and comes back with the same id is still "returned", not growth (the close was counted as -1 when it was confirmed).
- Tiered prices and the SDK: which tier's prices `getPricingInfo()` returns is still not verified on Apify (START_HERE step 10).

Tests and docs

- Tests of 0.2.2–0.2.4 that pinned the old prices, title, exact version, `Remote (DE)` = DE, the old hiring_down base, the summary's listed per-function moves and "no job rows on a baseline" were updated to the new behaviour (their intent kept; several runs that are about company checks or digests now set `outputJobs: "none"`).
- README, input descriptions, Store listing and START_HERE follow (status `detection_deferred`, the new fields, the first-run cost, the Remote rule, the careers-page rule).
- `package.json` / `package-lock.json` root: 0.2.5; dependencies unchanged.

Tests: 220 (were 182). `npm run mutation`: 223/223 caught (the 183 of 0.2.4, 9 of them re-anchored to the rewritten code, + 40 new). `npm run check`: 34 files.

### 0.2.4 — 2026-10-12

Fixes from the fourth independent review of 0.2.3. Every fix has a regression test in `test/regressions_v024.test.js` (the reviewer's repro scripts h2b, h2, country_multi_country_list, h1, h5, h3, h4 and own_mutations turned into assertions); 20 of its 25 tests fail on 0.2.3 (among them the version check, and #5 only because of the new `growth` / `changed` / `replacedCount` fields: it is the pin for the reviewer's three "withheld" mutations); the other 5 pin behaviour that 0.2.3 already had right but that no test noticed when it was broken (the Lever sample, another list's resume point, an old run's company-checked note, the 5,000 cap) or that the new per-chunk resume point could break (the restart point of a migrated run that had continued from a resume point). Prices, event names and the title are unchanged (not decided yet).

Hiring signals (report.js) — the new definition

- `openTotal` is what the board lists now (the same as `listedTotal`). Jobs missing for the first time are no longer counted as open: they are shown in `missingOnceCount`. `prevTotal`, `delta`, `deltaPct`, the per-function `…Delta` columns, `countsBy` and `trend12` compare listed counts. Before, a job missing once stayed in `openTotal` while its replacement was already new, so a board with a constant count and some turnover showed `openTotal` above the real count, a positive `delta` and `hiring_up`, and the next run a negative `delta` (reviewer: 50 listed -> 60 open, +10, `hiring_up`, then -10).
- Replacements: a job that disappears in the same run as new jobs appear offsets one of them at once (`replacedCount`). Hiring growth is new jobs minus replaced jobs minus confirmed closes that no new job offset (new field `growth`; returned jobs, reposts and filter moves do not count). `hiring_up` (growth >= 3 and >= 10% of the previous listed count, from 5 listed jobs) and `hiring_down` (growth <= -3 and >= 20%) use it, as do the digest's "Growing" / "Sharp declines" and `OUTPUT.topGrowing`.
- Changed (company-changed): a company is changed (new field `changed`) when it has new jobs, or confirmed closes that no earlier change already covered. One turnover is one change, billed once, on the run that sees the new jobs; the closes confirmed on the next run are counted in `closedJobsCount` (each close once) but are not a second change and not a `hiring_down`. A disappearance that no new job offsets is a change (and can be a `hiring_down`) when it is confirmed, as before. One incomplete answer is still no closure, no tag and no charge. The same rules apply after a long pause (28+ days): the reviewer's 60-day turnover is 50 open, delta 0, one change. The "replaced" mark is stored with the job in the snapshot (`pending` entries get a 9th element); pending jobs of 0.2.3 snapshots have no mark and count as not offset (their close is a change, as in 0.2.3).
- Summary sentences follow the same counts ("50 open (0 vs last run): 10 new, 0 closed. 10 not listed this run (10 replaced by new roles): closed if still missing next run." / "No new roles: 50 open. 10 closed, already counted as replaced in the last change.").

Countries (country.js)

- A comma list of countries in one place was read as its last entry: "Remote - US, Canada" -> CA, "Remote (DE, AT, CH)" -> CH, "USA, Canada, Mexico" -> MX, "France, Spain" -> ES, "Remote: UK, Ireland" -> IE (a false "first job in a new country"). Two or more country names / ISO codes in one place that cannot be the same country now give `null`. A code right after a known city is that city's code, not a list entry ("Berlin, DE", "Dublin, CA"), and a code that can also be a US state / Canadian province agrees with US / CA ("Smalltown, IN, US", "Toronto, ON, CA", "Mumbai, MH, IN"). Everything 0.2.3 fixed still holds (checked against the reviewer's 200-location probe: only the intended cases changed).
- Australia: "Perth, WA" was US and "Darwin, NT" CA. Australian cities (Perth, Darwin, Canberra, Hobart, Gold Coast, Geelong, Cairns, Townsville) and states were added: NSW, VIC, QLD, TAS, ACT and the state names are Australian; WA, NT and SA are Australian only after an Australian city, otherwise they keep their US / Canadian / ISO reading ("Vancouver, WA" US, "Riyadh, SA" SA).

Billing and state (run.js, http.js)

- Digest at the spending limit: with a budget that covered only the company checks, the digest was delivered and `digest-sent` was not charged. The run now checks that one `digest-sent` fits before sending; if not, the digest is not sent and `OUTPUT.digest.skippedReason` says why.
- Migration restarts: free rows (`unsupported_url`, `board_not_found`, `needs_review`, `duplicate`, …) of chunks that were already output were pushed again by the restarted run. They are noted in the ledger (`freeOut`, part of the chunk's single ledger write, right before the push) and skipped by a restart of the same run; the note is cleared at the end of the run.
- Resume point: it was saved only at the end of a run, so a run the platform ended anyway (a slow ATS past the time limit) left no position (and no OUTPUT; its lock expires after the TTL). The position is now written after every chunk (one write; OUTPUT is still written at the end); a restart of the same run starts where that run itself started (`startIndex`), so its OUTPUT still covers the whole list; a run of another list that gets to its end puts the other list's position back. HTTP requests never wait past `OUTPUT_MARGIN_MS` before the time limit: run.js gives the client the deadline (`http.setDeadline`), every request timeout is cut to the time left (also after waiting for its host's turn), no retry pause runs past it, and no request starts after it (the board is `fetch_failed`, free).

Tests and docs

- Test holes closed (each with a test that fails when it is broken, and a mutation): a verified 25-posting Lever sample used as the whole board; `withholdChange` leaking `newDepartments` / `newCountries`, the per-function deltas or `countsBy` prev / delta; another list's run deleting this list's resume point; an old run's company-checked note kept by a later run; the 5,000-company cap; the `hiring_up` / `hiring_down` thresholds (both boundaries).
- README: `openTotal` / `listedTotal` / `missingOnceCount`, the new `growth`, `changed` and `replacedCount` fields, the replacement rule, what "Company changed" bills, the digest at the spending limit, the per-chunk resume point and the HTTP deadline, country lists and Australian states. The "failed run" paragraph now says what it does: `lock_lost` / `lock_unconfirmed` can happen after earlier groups were output and charged (those charges stay, are recorded and never billed again; the next run shows an unsaved group's changes again without charging them again). Store notes, input descriptions and START_HERE follow (START_HERE: after setting tiered prices, check pricingInfo and `unpricedEvents` in the preflight log and `OUTPUT.monetization`).
- Tests of 0.2.2 / 0.2.3 that asserted the old counts (`openTotal` with jobs missing once, a replaced job's close as a second change, the digest line format, the digest delivered at the spending limit, the exact version) were updated to the new behaviour.
- `package-lock.json`: the root `version` fields (left at 0.2.1) now say 0.2.4; dependencies unchanged.

Known limitations (owner decisions pending, not changed)

- Prices, event names and the title.
- Large inputs of domains without a supported ATS are free (`board_not_found`) but still cost the careers-page request and up to 6 board-name guesses per domain (cached for 7½ days).
- Tiered prices and the SDK: which tier's prices `getPricingInfo()` returns, and how `calculateMaxEventChargeCountWithinLimit` behaves with tiers, is not verified on Apify (START_HERE step 10 says what to check; this version logs the preflight result, not the pricingInfo object itself).
- Which pending jobs a replacement offsets is decided by order (the first jobs missing in the run), not by matching titles: when some of them come back and others are closed, the "already covered" mark can sit on a different job than the one really replaced (counts stay right; at worst one close is or is not a change).
- One resume point per watchlist (see 0.2.3): a run of list B that crashes before its end leaves B's position instead of A's.

Tests: 182 (were 157). `npm run mutation`: 183/183 caught (the 144 of 0.2.3, 7 of them re-anchored to the rewritten code, + 39 new). `npm run check`: 33 files.

### 0.2.3 — 2026-10-12

Fixes from the third independent review of 0.2.2 (two regressions that 0.2.2 introduced, plus smaller findings). Every fix has a regression test in `test/regressions_v023.test.js` (the reviewer's repro scripts turned into assertions); 16 of its 21 tests fail on 0.2.2; the other 5 pin behaviour that 0.2.2 already had right but that no test noticed when it was broken (resuming with another list and the 30-day age of a resume point, HTTP 410 and staleBoard after 3 misses, the 14-day repost window, a detection that could not finish is not cached) or that the new budget plan made unreachable in normal runs (the backstop that never commits an unpaid change). Prices, event names and the title are unchanged (not decided yet).

Regressions of 0.2.2

- Countries: a location that lists several places was read from its last code only: "Austin, TX; San Francisco, CA" was CA (Canada), "Seattle, WA; Boston, MA" MA (Morocco), "New York, NY | San Francisco, CA" CA, "Chicago, IL, Denver, CO" CO (Colombia), "Springfield, Greene County, MO" MO (Macau) — each one a false "first job in a new country". Places separated by `;`, `|`, `/`, " or ", and "City, ST" repeated in a comma list (a known city right after a code or a state / country name starts the next place) are now read one by one: all in the same country -> that country, otherwise `null` (no `newCountries` news from a guess). "City, ST, CC" takes the last code as the ISO code only when it cannot also be a US state / Canadian province ("Leeds, England, GB"); otherwise the other parts must tell ("Toronto, ON, CA", "Munich, Bavaria, DE", "Peoria, IL, Smalltown, CO" -> US), else `null`. What 0.2.2 fixed still holds ("Berlin, DE", "Amsterdam, NL", "Toronto, ON, CA", "Tel Aviv, IL"). The header comment of `src/country.js` matches the code ("Wilmington, DE" is US: Wilmington is a known US city).
- Resume point: a run that continued from a saved position and then stopped at the spending limit left the position in place, so for up to 30 days every run started there again and the top of the list was never checked. Now a run that gets to the end of the list clears the position whatever stopped it, and a run stopped by the time limit, the spending limit or an abort saves the first company it did not output (`OUTPUT.nextIndex`); the next run with the same companies continues there. A run that processed every company is no longer labelled `charge_limit_reached` (unpaid changes are counted in `safety.notFullyPaid`).

Billing and state

- Spending limit: with a budget that covers only "Company checked", the changes of a company were delivered every run without "Company changed" being charged. Each chunk is now planned before anything is output: company-checked for every company read, then company-changed in input order (`billing.fits`, a lower bound of the budget left). A change that does not fit is not shown: the row keeps the current counts, the change fields are empty, `changeWithheld: true`, no job rows are sent for it, and the board is not committed, so the next run with room reports it and charges it once (`safety.changesWithheld`).
- Lock checks before output and before the commit: one transient key-value store error used to end the run as FAILED (after rows were output and charged, when it hit the check before the commit). The check is now retried (3 attempts), like the heartbeat; only a lock taken over by another run (`lock_lost`) or one that still cannot be read (`lock_unconfirmed`, the run ends as failed, nothing more is output or saved, the charges already made are in the ledger and never billed again) stops the run.
- Time limit inside a chunk: no further company of a chunk is read in the last 3 minutes, and a chunk whose reading ran into the last minute is dropped before any output or charge (the next run reads it).
- Snapshot size: measured as the SDK saves it (JSON indented by 2, about 1.3x the compact size), so a board just under the limit no longer fails its commit.
- Restarts of the same run (migration) no longer advance `anomalyStreak` / `notFoundStreak` twice: the counter update carries the run ID, and the restart skips that board (its row was already output).

Hiring signals

- A drop accepted at once after a 28+ day pause, followed by the board recovering at the next check (with the same job ids), counted the returning jobs as new (and a second change). A job closed at the previous check is now remembered for the next check however long after it comes: the same id back is `returnedCount`. Reposts (same title + location, another id) keep the 14-day calendar window.

Tests and docs

- Test holes closed: resuming with a different company list, the 30-day age of a resume point, HTTP 410 as "not found", `staleBoard` after 3 misses — each now has a test that fails when it is broken (and a mutation). Re-running the reviewer's 24 mutations on 0.2.3 found two more: the restart note of a finished run is now pinned (test + mutation); "a failed detection is cached" is an equivalent mutant (the `failed` verdict never goes through `save()`), pinned by a behaviour test only.
- README, Store notes, input descriptions and START_HERE describe the behaviour above; job functions are documented as read from English titles. START_HERE no longer names "v0.2.0" for the default-dataset-item refusal.
- Two 0.2.2 tests in `test/run.test.js` were updated to the new behaviour (a change that does not fit is not shown; a budget stop saves its position).

Known limitations (owner decisions pending, not changed)

- Prices, event names and the title.
- Large inputs of domains without a supported ATS are free (`board_not_found`) but still cost the careers-page request and up to 6 board-name guesses per domain (cached for 7½ days). A cap or a paid detection event would be a pricing decision.
- Job functions are classified from English titles and departments; Japanese and other titles mostly end up as `Other` (documented in the README).
- One resume point per watchlist: two different company lists on the same watchlist overwrite each other's position when both stop early (a position of another list is ignored, never applied to the wrong list).
- A company whose change was withheld at the spending limit is revisited on the next pass through the list (the next run continues after it).
- `billing.fits` uses a lower bound of the budget left (accurate to the cheapest price, $0.0005): at worst a change that would just fit is withheld until the next run.

Tests: 157 (were 136). `npm run mutation`: 144/144 caught (the 116 of 0.2.2, 3 of them re-anchored to the rewritten code, + 28 new). `npm run check`: 32 files.

### 0.2.2 — 2026-10-12

Fixes from the v0.2.1 verification and two follow-up reviews. Every fix has a regression test in `test/regressions_v022.test.js` (the reviewers' repro scripts turned into assertions): 40 of its 46 tests fail on 0.2.1; the other 6 pin behaviour that 0.2.1 already had right (5 places that no test noticed when they were broken, see `npm run mutation`, and the guard that an already empty board is not re-checked every run). Prices, event names and the title are unchanged (not decided yet).

Hiring signals

- Anomaly: only a drop in the job count is an anomaly now (a board of 20+ jobs that lists none, or fewer than 20% of its previous count). Before, more than 80% of the job ids being replaced was enough, so a board with high turnover or one checked after a long pause stayed `anomaly` as long as its count moved. A drop is accepted as real after 3 anomaly runs in a row (the counts may differ; before: the same count 3 times), or at once when the previous check is 28+ days old and the board is not empty. The misleading "may have switched ATS" advice on anomalies is gone.
- Two misses to close: a job missing for the first time is kept as open (`missingOnceCount`, still in `openTotal`) and closed only if it is still missing on the next run. One incomplete ATS answer no longer reports closures followed by "new" jobs and a `hiring_up`. Jobs that come back with the same id are `returnedCount` (also within 14 days after a real close), never new and never growth. Small boards that return `[]` once are held the same way. `hiring_up` / `hiring_down` are computed from new minus closed jobs.
- ATS switch: when a board found through a cached detection looks gone (404, empty, or an anomaly), the careers page is checked again (bypassing the cache). If it links another board the row is `ats_switched` (free, not a drop) and the next run tracks the new board. An empty old board is no longer billed every week. A board that was already empty last time is not re-checked (no extra requests every run).
- Filters: a job still on the board that leaves or enters the filter scope (e.g. a renamed department under `departmentIncludes`) is counted as `movedOutOfFilterCount` / `movedIntoFilterCount`, not closed / new.
- Duplicate job ids in a feed count once (one job row, one `job-row` charge); `schemaWarnings` reports how many.
- Countries: "City, CC" is read as the ISO code ("Berlin, DE" was US, "Amsterdam, NL" was CA, "Leeds, GB" was null); "City, ST, CC" takes the last part; a two-letter code is a US state / Canadian province only after a US / Canadian place, "Remote (DE)" and a bare "DE" are countries, "Tbilisi, Georgia" is GE; codes that could mean two countries without a hint ("Dublin, CA") stay null. Full ISO-3166 alpha-2 list; country fields are validated against it.
- Job functions: "partner" alone is no longer a sales word. Talent Acquisition / Recruiting / HR / Finance / Legal / People Partner are G\&A, Customer Success Partner is Customer Success; channel / sales / alliance / technology / reseller partners and Partner Manager stay Sales. Accounts payable / receivable are G\&A.
- `titleIncludes`: word boundaries are judged with Unicode letters ("Café", "C++", ".NET" work); Japanese, Chinese, Korean and Thai terms match inside words (`エンジニア` finds `シニアエンジニア`).
- Greenhouse "No Department" is no department (null), not a new department.
- Lever `workplaceType: "unspecified"` no longer means on-site: the location text decides.

Input

- Country filter values `UK`, `USA` and English names are converted to ISO codes; values that cannot be read are reported in the log (before, a filter could silently disappear and track every country).
- Overrides match the same domain or board however `company` is written (`https://www.acme.com/careers`, `acme.com`, `www.acme.com/`); an override that matches no input line is reported.
- Identical input lines and unknown `groupBy` values are reported; `groupBy` is a fixed choice in the input form.
- Greenhouse `embed/job_app?for=` URLs are recognised. Greenhouse EU boards (`*.eu.greenhouse.io`) are reported as "not supported yet" with that reason (input URL: `unsupported_url`; linked from a careers page: `board_not_found`). No EU API is called: there is no verified EU job board endpoint to read.
- Domains and careers URLs on ports other than 80/443 are refused.
- A careers subdomain (`careers.acme.com`) is checked against its registrable domain (`acme.com`): its own page may redirect to acme.com and acme.com job links confirm a guessed board. Best effort without the Public Suffix List (common multi-label and hosting suffixes are listed; see `registrableDomain` in `src/adapters/common.js`).

Billing and state

- Charge ledger: a record is no longer deleted because its board is not in the current input. Another list on the same watchlist used to delete it, and the next run of the first list billed the same change again. Records are dropped when their board's state moved to another revision, or after 30 days.
- A run in which no company could be read sends no digest (before, `notifyOnlyWhenChanged: false` sent one and billed `digest-sent` on a failed run).
- A second run on a busy watchlist waits up to 4 minutes for the first (polling every 15 s, nothing charged) instead of failing at once.
- A board whose saved state would exceed one key-value store record is `too_large` (free, counts shown) instead of failing the commit of the whole run after it was delivered and charged; a snapshot write that fails for one board no longer stops the others (`safety.commitFailures`; the board is reported again next run and not billed again). Snapshots no longer store the job ids twice (`rawIds` replaced by `rawCount`; ids outside the filters only while filters are set).
- Time limit: the run stops between chunks before the platform's timeout (at least 3 minutes, or twice the slowest chunk, before it), saves where it stopped (`r-{stateKey}`) and ends as succeeded; the next run with the same companies continues from there.
- Fewer key-value store writes and charge calls: each step of a chunk is one write / one call for the whole chunk (restart notes, change attempts, job-row notes, row attempts, the final "paid" state with the cleanup). A run of unchanged companies writes about one record per company (its snapshot) plus 4; each changed chunk adds 3. Charges are one call per event per chunk. The lock is still checked before output and before the commit, but its expiry is written only when a quarter of the TTL has passed (the heartbeat still extends it).
- Lever slug probe: reads a sample of 25 postings first (`limit=25`); a sample that never names the domain is `needs_review` without downloading the board (often ~6 MB). The `anomaly` path still downloads the full board (the count is what decides).
- Heartbeat: only a lock taken over by another run stops the run; a transient store error is retried at the next tick. The run waits for a heartbeat in flight before releasing the lock, and an extend that returns after the release no longer writes the lock back ("ghost lock").
- Retry-After longer than 30 seconds is not waited for: the board fails for this run (free) instead of being retried early.
- A careers page larger than 2 MB is read up to 2 MB instead of being treated as missing (which cached `board_not_found` for a week).
- Detection cache: 7.5 days instead of 7 for guessed boards, "not found" and "needs review", so a weekly schedule re-checks every second run instead of depending on the minute the run started.
- Local runs get a fresh run ID (a second local run was taken for a restart of the first and skipped every board).

Output and docs

- Failure rows carry `company` (domain or input line); the Companies view shows `input` and `statusDetail`.
- New row fields: `listedTotal`, `missingOnceCount`, `returnedCount`, `movedIntoFilterCount`, `movedOutOfFilterCount`, `prevCheckedAt`, `daysSincePrev`, `atsSwitchedTo`; anomaly rows have `droppedShare` (was `disappearedShare`). New statuses `ats_switched`, `too_large`. OUTPUT: `continuedFromIndex`, `nextIndex`, `safety.commitFailures`.
- The status message is updated after each chunk while the run works.
- Digest: declines and the "need attention" note come first (the 3,500-character cap cuts the least important sections), changed companies without net growth are listed under "Other changes" (no more "N changed" followed by "No notable changes"), Slack formatting characters in names are replaced by look-alikes, `G&A` is escaped.
- Netguard: only cloud metadata names are refused by name (`metadata.io` is a normal company domain again); Teredo (2001::/32) is refused.
- The version comes from package.json (`src/version.js`); the User-Agent no longer carries a placeholder URL.
- README, Store notes and input descriptions describe the behaviour above (parallel runs need different watchlist names; countries filter leaves out jobs without a recognisable country; first digest after the second run; first-run time estimate for domains).

Not changed (low priority) and why

- First run sends no digest under "only when changed": documented instead of sending a free "baseline saved" message (a message nobody pays for, sent on every new watchlist, was not wanted without the owner's decision).
- Registrable domain: no Public Suffix List (no new dependency). Unlisted shared-hosting suffixes resolve to the platform's domain for subdomain inputs; plain company domains are unaffected.
- The anomaly path still downloads the full board (Lever ~6 MB): the decision needs the full count.

Tests: 136 (were 90). `npm run mutation`: 116 mutations (the 55 of 0.2.1, 13 of them re-anchored to the rewritten code, + 61 new).

### 0.2.1 — 2026-10-11

Fixes from the acceptance run of 0.2.0 (2 real defects; the other 2 failures were in the acceptance tests and are fixed there). Each fix has a regression test that fails on 0.2.0 (`test/regressions_v021.test.js`).

- Lock: ownership is now re-checked before each chunk is output or billed, not only before the commit. Two runs that both passed the read-back (the KVS has no compare-and-swap; or a paused run whose lock expired and was taken over) used to bill the same change twice before the commit-time check stopped one of them. The run that finds another run's token now stops with `lock_lost` before outputting or charging anything.
- Restarts of the same run (Apify migration, crash): the company row and the job rows of the board that was in flight are no longer pushed a second time. The ledger notes "this run delivers the company row" right before pushing it (per board, replacing the per-chunk note) and "this run delivers these job rows" right before pushing them; the restart skips those rows and does not bill company-checked again. At-most-once like the charges: a crash between the note and the push loses that row for this run instead of duplicating it, and job rows pushed just before a crash but not yet billed are not billed. Notes of one run never affect another run. New OUTPUT field `safety.restartedRowsSkipped`.
- `npm run mutation`: the inner test runs no longer inherit `NODE_TEST_CONTEXT` / `NODE_OPTIONS` (started from inside another `node --test`, every mutant looked "missed"), and a clean run without TAP output is reported as an environment error. 55 mutations (4 new: lock re-check before billing, restart row skipping x3).
- Tests: 90 (were 86).

### 0.2.0 — 2026-10-11

Fixes from two independent reviews of 0.1.0 (Codex: BLOCK, agent: WARNING). Every fix has a regression test that failed on 0.1.0 (`test/regressions.test.js`); `npm run mutation` breaks 51 safety rules one at a time and the suite catches all of them.

Billing and state safety

- Watchlist lock (`l-{stateKey}`): one run per watchlist; TTL 10 min with heartbeat, read-back after writing the token, ownership re-checked before every commit, released on exit and on crash, taken over after expiry; a restarted run (same run ID) takes its own lock back. A second run stops at once with nothing processed or charged (run ends as failed, `stoppedReason: watchlist_busy`).
- Commits check the snapshot revision: a run never overwrites a newer state with an older observation.
- Charge ledger redesigned: each charge is recorded as `attempt` right before the billing call and `paid` right after. A crash before the attempt (e.g. while pushing rows) no longer marks an unpaid change as paid; a crash inside the billing call is never billed twice (reported as `safety.uncertainChargesNotRebilled`).
- Restarts of the same run (Apify migration): boards already committed are skipped (no duplicate rows, no second charge); company-checked is noted per chunk so it is not billed twice; `migrating` / `aborting` events stop between boards after saving what was paid.
- A priced `apify-default-dataset-item` stops the run before anything is processed (that event bills every row, also failure rows, and lets the SDK drop rows).
- A run where no company could be read (all `fetch_failed`) ends as failed; every run sets a status message with the counts.

Correctness

- Same-name check by host only: Greenhouse job links on the domain (or a subdomain), Lever/Ashby postings writing the domain as a web or e-mail address in at least 30% of postings. Prefix name matches, substrings (`notacme.com`) and capitalised words no longer confirm a board; name-only hits are `needs_review`. Slug-probe results are cached 7 days (re-verified), 0.1 cache entries are ignored.
- Careers pages that link 3+ different boards (or two equally) are `needs_review`; boards on a page the careers URL redirected to off-site are ignored.
- Incomplete responses are `fetch_failed` (snapshot kept, free): Greenhouse departments call failing, or more than 10% of postings without id/title. Previously all jobs could be reported closed and billed.
- A real drop is no longer stuck as `anomaly`: the same job count 3 runs in a row is accepted (`dropConfirmed`, tags `hiring_down` + `drop_confirmed`, billed as a change, listed under sharp declines). Changing counts stay held back and set `staleBoard`.
- Reposts across runs: closed jobs are remembered for 14 calendar days (UTC) in the snapshot, so a role closed in one run and re-posted in a later run is `reposted`, not new.
- Job functions: the role part of a title decides (before ",", " - ", "(", ":"), and within it the head noun (last match; longer phrase on ties). Fixes "Account Executive, Cloud", "Infrastructure Sales Representative", "Hardware Design Engineer", "UI Engineer", "Software Engineer, Customer Support Tools" and similar.
- Snapshot keeps full titles (no false function deltas for long titles); future publish dates are not "recent"; values currently on the board are never evicted from the first-seen history (`seenHistoryCapped` when older values were dropped).
- A job moving to a new country/department is reported in the summary and counts as news for `onlyWhenChanged` digests.

Security

- Outbound guard (`src/netguard.js`) for careers pages and webhooks: the address is checked after DNS (all answers) and the socket connects only to the checked address; blocks private, loopback, link-local, CGNAT, metadata, documentation, multicast ranges, IPv4-mapped/NAT64/6to4 IPv6 and internal host names. Every redirect hop is re-checked; webhook POSTs never follow redirects. Localhost works only when tests inject it.
- Webhook URLs with a user name or password are refused; send errors are fixed URL-free messages (no secret in logs or OUTPUT).
- Slack text escapes `&`, `<`, `>` (no `<!channel>` injection), names are shortened and the message is capped at 3,500 characters. Override labels are capped at 200 characters.
- HTTP transport settles on every path (aborted or truncated compressed responses, size limit, overall deadline) and counts bytes of failed responses; Retry-After dates are honoured.

Other

- Watchlist names map to storage keys with a hash of the exact name (no collisions between e.g. non-ASCII names).
- `.actor/actor.json`: `defaultRunOptions` memory 1,024 MB, timeout 7,200 s.
- README / Store text: job rows are on by default (new jobs), the Companies view does not filter rows (use `rowType`), `recentJobs` holds up to 10, price table with the Actor start fee and stated assumptions (Free-plan prices), run outcome, lock, limits (Lever EU guessing, Greenhouse boards without own-domain links).
- `npm run mutation` copies everything the tests read (it failed before mutating in 0.1.0) and now runs 51 mutations.
- Tests: 86 (were 46), still no network (DNS is blocked in the test helpers except loopback).

### 0.1.0 — 2026-10-11

First version (MVP from PRD_採用動向モニター.md §7, required items + weekly digest).

- Input: domains, careers page URLs or Greenhouse / Lever (incl. EU) / Ashby board URLs; `overrides`; `groupBy`; `filters`; `stateKey`; `outputJobs` (none / new / all); Slack Incoming Webhook and generic webhook (secret inputs).
- ATS detection: URL patterns → careers page (`/careers`, `/jobs`, or the given path; embedded boards and redirects) → slug probe with a same-name check (Greenhouse company name / job links; Lever and Ashby posting text read in memory). Unverified hits are `needs_review`. Results cached per domain (found 90 days, misses 7 days; a 404 clears the cache). The probe response is reused, so detection costs no extra fetch.
- Adapters: Greenhouse (jobs `content=false` + departments, leaf department), Lever (descriptions parsed and dropped; no gzip on Lever's side), Ashby (unlisted jobs skipped). gzip/br accepted; wire and decoded bytes measured.
- Normalization: 10 job functions (word-boundary rules, falls back to department then team; previous snapshots are re-classified with current rules so classifier updates never create fake deltas), country (ISO-2) from fields or location text, department falls back to team.
- Company row: totals and deltas, per-function open/delta columns, GTM count, engineering share, first-seen departments / locations / countries, 7/30/90-day posting counts and 90+ day long-open count from the first run, signal tags, rule-based summary, `countsBy`, 12-run trend, new / closed / recent job lists. Optional job rows.
- State: named KVS `company-hiring-tracker-state`, one record per board and watchlist. Order: dataset write → charge → snapshot commit, with a charge marker so a crash before commit never bills the same change twice. Snapshots only advance for boards that were delivered and fully paid.
- Anomaly guard: previous ≥20 jobs and now 0 or >80% gone → `anomaly`, snapshot kept, not charged; 3 in a row → `staleBoard`. Reposts within 14 days excluded from new/closed.
- Billing (PPE): `company-checked`, `company-changed`, `job-row`, `digest-sent`, charged only after output; never charges past the run's spending limit (stops cleanly, unreached companies untouched); preflight warns when Console lacks a price for any event.
- Failures: 404 → `board_not_found`; 429/5xx/network → 3 retries with backoff (Retry-After honored) → `fetch_failed`; invalid input → `unsupported_url`; other companies continue. Digest failures are recorded, not charged, and never fail the run.
- Tests: 46 (node --test, no network; includes a guard that package-lock resolves only from the public npm registry). Mutation check: 10/10 deliberate breakages caught.
