Ctrl K
ComunidadGuías…

BACKLOG.md

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# OSApplyTrack Backlog

This file mirrors the open
[GitHub Issues](https://github.com/CryptoJones/OSApplyTrack/issues) for the project.
Check an item only when its matching issue is closed. Deferred ideas in `README.md`
or `SPRINTS.md` are not committed backlog until they have a corresponding issue.

## Security and stability

- [x] [#228 — [SEC] Magic-link sign-in is broken on the live instance: POST /api/auth/request returns 500 for every valid email](https://github.com/CryptoJones/OSApplyTrack/issues/228) (fixed in #245: fail loudly at boot on a misconfigured SMTP sender instead of a per-request 500)
- [x] [#229 — [SEC] Session cookie issued without Secure flag: app layer does not see the request as HTTPS behind the TLS proxy](https://github.com/CryptoJones/OSApplyTrack/issues/229) (fixed in #247: surface a dropped X-Forwarded-Proto so Secure cookies + HSTS aren't silently off)
- [x] [#230 — [SEC] Duplicate and conflicting security headers emitted by both edge and app](https://github.com/CryptoJones/OSApplyTrack/issues/230) (the app is the single source of truth for the header set — README now tells operators not to re-add them at the reverse proxy; the live pluto edge stops re-adding the duplicates)
- [x] [#231 — [SEC] DNS-rebinding TOCTOU in the IMAP mailbox host guard](https://github.com/CryptoJones/OSApplyTrack/issues/231) (fixed in 1.39.4: the mailbox resolves the host once and dials a socket pinned to that resolution's public addresses, handed to MailKit already connected — no second resolution at connect time, the same no-TOCTOU pattern as the scrape path in #51)
- [x] [#232 — [SEC] Minor information disclosure: build version, DB status, and nginx version on unauthenticated surfaces](https://github.com/CryptoJones/OSApplyTrack/issues/232) (decision recorded: `/health` build version and `/health/ready` DB status stay — both serve a purpose and are documented as accepted disclosures in code; the nginx version banner is turned off at the edge with `server_tokens off;`, documented in the README)
- [x] [#51 — Close the Python poller link-check DNS rebinding gap](https://github.com/CryptoJones/OSApplyTrack/issues/51)
- [x] [#49 — Restrict forwarded-header trust to configured proxies](https://github.com/CryptoJones/OSApplyTrack/issues/49)
- [x] [#50 — Add global JSON body caps and per-field/cardinality limits](https://github.com/CryptoJones/OSApplyTrack/issues/50)
- [x] [#52 — Serialize overlapping tenant poll runs](https://github.com/CryptoJones/OSApplyTrack/issues/52)
- [x] [#266 — Scrape: the fetch has no whole-fetch budget, so a slow board or a redirect chain stacks past the per-hop timeout](https://github.com/CryptoJones/OSApplyTrack/issues/266) (fixed in [#268](https://github.com/CryptoJones/OSApplyTrack/pull/268): every hop and the body read draw on one 15s clock, and `HttpClient.Timeout` is Infinite so that clock is the only ceiling — at 10s it fired first on a single slow hop and made the class's own documentation untrue. Cancellation is classified by token rather than exception type, so a stopped worker or a closed tab is no longer reported as a board failure, and the middleware no longer logs a client abort as an unhandled 500. A refused connection, NXDOMAIN or TLS failure still says "couldn't reach that page" instead of claiming a timeout. Reviewed by the Flatline Roundtable — 13 of 14 lanes, which caught that last one)
- [x] [#117 — Encrypt sensitive columns at rest, secure by default for every deploy](https://github.com/CryptoJones/OSApplyTrack/issues/117) (phase 1 shipped in 1.26.0; `notes`, `email`, `contact_email`, `phone` and the extracted résumé fields stay deferred)
- [x] [#118 — Public beta terms, acceptance at sign-in, and a typed-confirmation data delete](https://github.com/CryptoJones/OSApplyTrack/issues/118)
- [x] [#120 — Saved answers reused across fields that ask for different units, periods or currencies](https://github.com/CryptoJones/OSApplyTrack/issues/120)
- [x] [#121 — Optional questions get filled with placeholder answers instead of left blank](https://github.com/CryptoJones/OSApplyTrack/issues/121)
- [x] [#337 — [SEC] Magic-link host-header poisoning on the quickstart compose and quadlet deploys](https://github.com/CryptoJones/OSApplyTrack/issues/337) (fixed in 1.55.6: sign-in links fall back to the request Host only when it is loopback, the api refuses to boot with SMTP but no `App:PublicBaseUrl`, and the quickstart compose + quadlet env template now carry `App__PublicBaseUrl`/`AllowedHosts`)
- [x] [#338 — [SEC] Poller: fetch_public / probe read whole bodies before the size cap and have no overall deadline](https://github.com/CryptoJones/OSApplyTrack/issues/338) (fixed in 1.55.10: bodies are streamed and abandoned at the 2 MB cap, probes never read a body, and every fetch/probe has a 15s wall-clock deadline clamped onto each socket read and DNS lookup; gzip/deflate are inflated in bounded steps so a compression bomb stops at the cap too)
- [x] [#339 — [SEC] Poller SSRF guard admits CGNAT (100.64/10) and other non-global ranges](https://github.com/CryptoJones/OSApplyTrack/issues/339) (fixed in 1.55.11: the poller requires `is_global`, so CGNAT/Tailscale, TEST-NETs and the rest of the special-purpose ranges are refused; NAT64, 6to4, Teredo and IPv4-compatible addresses are unwrapped like the API's; redirect hops are held to ports 80/443; both guards now run one shared parity fixture, and the API now unwraps Teredo and refuses the TEST-NET and IPv6 documentation ranges too)
- [x] [#340 — Poller never receives APPLYTRACK_SECRETS_KEY, so sealed board sessions silently fail to decrypt](https://github.com/CryptoJones/OSApplyTrack/issues/340) (shipped in 1.55.5: both compose files and the quadlet hand the poller the key — env or the api's key file, mounted read-only; seal/unseal refuse an empty key, bad base64 is a SealError, and a session that won't unseal logs a WARNING)
- [x] [#341 — [SEC] Login CSRF: GET /api/auth/verify signs any browser into the token's account; link scanners burn tokens](https://github.com/CryptoJones/OSApplyTrack/issues/341) (fixed in 1.55.7: the link opens a confirm page that POSTs the token, so scanners can't spend it; tokens are bound to a pre-auth cookie set by the request, so only the requesting browser can sign in; a successful sign-in replaces that browser's old session)
- [x] [#342 — [SEC] Prompt-injected postings can exfiltrate résumé data via markdown images in the cover-letter preview](https://github.com/CryptoJones/OSApplyTrack/issues/342) (fixed in 1.55.8: CSP `img-src` is now `'self' data:` only, the SPA renders cover letters and notes with a DOMPurify config that drops images/media/`style`, and the drafter strips image markup from the model reply and tells it to ignore instructions in the posting)
- [x] [#343 — [SEC] Board-account domain matching uses a two-label approximation](https://github.com/CryptoJones/OSApplyTrack/issues/343) (fixed in 1.55.9: registrable domains now come from the Public Suffix List, private section included, embedded in the API — board-account matching, the top-level navigation allowlist and the sign-in-mail host check no longer treat `evil.co.uk` as `acme.co.uk` or one `*.github.io`/`*.azurewebsites.net` customer as another, and an account saved for a bare public suffix covers nothing beneath it)
- [x] [#344 — [SEC] Résumé PDF upload and account import: unbounded parse, chunked bodies skip the size gate, no rate limit](https://github.com/CryptoJones/OSApplyTrack/issues/344) (fixed in 1.55.12: résumé upload and account import are throttled per IP and per tenant (10 per 5 min each); a chunked body is capped as it streams instead of falling through to Kestrel's 30 MB default; the PDF parse runs off the request thread under a 10 s budget, reads at most 20 pages and stops at the text cap; any parse failure is a 400)
- [x] [#345 — [SEC] Least-privilege gaps: poller as schema owner; quadlet agent as owner without hardening; table-wide agent UPDATE](https://github.com/CryptoJones/OSApplyTrack/issues/345) (fixed in 1.55.13: the poller connects as its own `applytrack_poller` role (only the tables and columns it touches; no sessions, tokens, résumés, board passwords or DDL); the api creates and re-passwords both restricted roles on boot from `AGENT_DB_PASSWORD`/`POLLER_DB_PASSWORD`; the agent's `UPDATE` on applications is column-level, never `tenant_id`/`name`; the quadlets give the agent and poller their own env files, run api/agent/poller read-only as uid 1654 with no capabilities, and publish the api on 127.0.0.1:8080 only — see deploy/quadlet/README.md for the upgrade steps)
- [x] [#346 — [SEC] Hardening grab-bag: hashed session IDs, users.status, sign-out-everywhere, per-tenant LLM limit, slug validation, URL userinfo in logs](https://github.com/CryptoJones/OSApplyTrack/issues/346) (fixed in 1.56.0: sessions are stored as the SHA-256 of the cookie's id — existing rows are hashed in place, so nobody is signed out; a session slides 30 days from last use, capped at 90 from sign-in; `users.status` other than `active` stops sessions and sign-in links; `GET`/`DELETE /api/account/sessions` and Settings · Account list sessions and sign out everywhere else; the model routes are throttled per tenant as well as per IP, and `Llm__DailyCapPerTenant` (default 200) caps requests on the operator's key; Greenhouse/Lever slugs must match `[A-Za-z0-9_-]{1,100}` in both runtimes; LLM log lines carry only `scheme://host:port`)
- [x] [#390 — [SEC] uv.lock pins anyio 4.14.0 — pip-audit flags three CVEs (fixed in 4.14.2)](https://github.com/CryptoJones/OSApplyTrack/issues/390) (fixed in 1.65.2: uv.lock now pins anyio 4.14.2 on Python 3.10 and 4.15.1 on 3.11+, and CI also audits the locked set — `uv export --locked`, markers stripped so every Python-version fork of the lock is checked, not just the runner's — so the lock can no longer drift vulnerable behind a clean fresh resolution)
- [x] [#459 — CI failed on main: .NET, Python and Web audit jobs](https://github.com/CryptoJones/OSApplyTrack/issues/459) (fixed on main, no release needed — the three weekly-run failures were all newly published advisories against existing dependencies, not code: MailKit → MimeKit pulled `System.Security.Cryptography.Pkcs` 10.0.0 (GHSA-846f-f764-76mx high, GHSA-2fmv-rv2c-59x7 moderate), so the api project now carries a direct reference to 10.0.14 as a transitive floor; `uv.lock` moves certifi 2026.6.17 → 2026.10.23 (PYSEC-2026-4320); `package-lock.json` moves follow-redirects 1.16.0 → 1.16.2 and qs 6.15.3 → 6.17.1 via `npm audit fix`. The patched Pkcs ships in the next tagged release)
- [x] [#392 — Agent can't start Playwright as uid 1654: /app/.playwright/node/linux-x64/node is mode 744 root (breaks every packet build since the #345 units)](https://github.com/CryptoJones/OSApplyTrack/issues/392) (fixed in 1.65.3: the api image makes the Playwright driver tree readable by all and its node binary executable by all (`chmod -R a+rX /app/.playwright`), so the agent under the #345 hardening — uid 1654, read-only root, noexec `/tmp`, no capabilities — starts the driver again. Verified by driving `Playwright.CreateAsync()` → `Chromium.ConnectAsync()` → a screenshot from the image under exactly that profile: EACCES before, clean after; the driver needs nothing writable beyond `/tmp` (a read-only `$HOME` is fine). A new `image` CI job builds the api image and starts the driver that way on every PR. Deploy: pull 1.65.3 and put the agent back on `User=1654:1654`)

## Operations and scalability

- [x] [#53 — Add hardened production container defaults](https://github.com/CryptoJones/OSApplyTrack/issues/53)
- [x] [#54 — Paginate or delta-refresh the applications list](https://github.com/CryptoJones/OSApplyTrack/issues/54)
- [x] [#88 — Poller: `_fetch_feed_set` parses up to the whole-run limit on every category feed](https://github.com/CryptoJones/OSApplyTrack/issues/88)
- [x] [#100 — CI is failing on main](https://github.com/CryptoJones/OSApplyTrack/issues/100) (auto-filed; a boot-order race in the SPA after #98)
- [x] [#89 — `uv.lock`: exceptiongroup's typing-extensions marker was dropped in the 1.16.1 release commit](https://github.com/CryptoJones/OSApplyTrack/issues/89)
- [x] [#347 — [PERF] Poller: link checks and employer resolution repeat per tenant, sequentially; sources fetched one at a time](https://github.com/CryptoJones/OSApplyTrack/issues/347) (fixed in 1.56.1: a poll run keeps one per-URL cache of employer resolutions and reachability probes across every tenant, so a shared listing is resolved and probed once per run, not once per tenant; a tenant's candidates are resolved and probed on a bounded pool (8 at once, at most 2 per host), and the worker's source gather runs the same way; custom feeds share one SSRF-pinned client per pool thread instead of a new client and TLS handshake per feed)
- [x] [#348 — [PERF] Poller: full dedupe history reloaded every run, one round-trip per seen key, Jev scored before cheap filters](https://github.com/CryptoJones/OSApplyTrack/issues/348) (fixed in 1.56.2: an empty batch reads nothing from the database; otherwise only the batch's own URL and slug keys are read from `seen` (a primary-key lookup) instead of the tenant's whole history, and the run's newly seen keys are written in one statement at the end instead of one autocommit per listing; LinkedIn/Handshake `seen_url` reads the ledger's URL keys once per run and answers from memory; the location and dead-end filters run before any Jev call, and Jev is asked 4 at a time after a first canary call, so an outage still costs one timeout)
- [x] [#349 — [PERF] SPA's 5-second idle poll always runs the heavy /api/errors query](https://github.com/CryptoJones/OSApplyTrack/issues/349) (fixed in 1.56.3: `/api/errors` carries its own ETag, a single cheap fingerprint of what the view reads — the applications revision, the evidence written, the queue's pending set, the newest packet, the failures still inside the retry window, and the two agent settings that decide a row's next step — and the SPA asks for it with `If-None-Match`, so an unchanged view is a 304 instead of the per-row query; migration 0044 indexes the evidence and packet reads the fingerprint makes)
- [x] [#350 — [PERF] No data retention for evidence screenshots, sessions, magic tokens, agent_events and seen keys](https://github.com/CryptoJones/OSApplyTrack/issues/350) (fixed in 1.56.4: a daily sweep in the migrating api container, per tenant and idempotent — evidence screenshots older than `Retention__ScreenshotDays` (90) lose their bytes while the row and its text stay, never a submission's, an application's newest `Retention__ScreenshotsKeptPerApplication` (3), or any while it is in Ready/Errors; sessions and magic-link tokens go `Retention__ExpiredGraceDays` (1) after expiry; agent events older than `Retention__AgentEventDays` (180) go except verdicts, packet builds, accounts created and events about a live lead or Ready application; the `seen` ledger is kept unless `Retention__SeenDays` is set (default 0 = off); documented in README)
- [x] [#351 — [PERF] N+1 and row-by-row writes: /api/pipeline, ReadyPromoter, bulk Ready actions, per-row revision trigger on import](https://github.com/CryptoJones/OSApplyTrack/issues/351) (fixed in 1.56.5: `/api/pipeline`, the Ready promotion pass and bulk Submit read every packet's gates in one query of the provider, questions and review list — no sealed columns decrypted; bulk Pass is one `UPDATE … RETURNING`, and Prepare/Submit read the names' status and link in one query instead of `SELECT *` each; the account import writes applications in `unnest` batches of 1000; migration 0045 makes the list-revision trigger statement-level with transition tables, so a 10k import bumps the tenant row ten times, not 10k)
- [x] [#352 — [PERF] Small wins: compression, pooled LLM handler, cacheable screenshots, evidence partial index, notes snippet, pool size](https://github.com/CryptoJones/OSApplyTrack/issues/352) (fixed in 1.56.6: Brotli/gzip for the SPA's static files and the polled `/api/apps`, `/api/errors`, `/api/pipeline` lists only — every other API response stays uncompressed against BREACH; tenant LLM endpoints share one pooled, SSRF-guarded handler with a two-minute connection lifetime, no shared cookie jar, and a per-call deadline that covers the body read; screenshots are `private, max-age=86400, immutable` with an ETag from the evidence id; migration 0046 adds a partial index for submitted evidence; the list reads the first 1024 chars of whitespace-collapsed notes; the agent worker's pool defaults to 8)
- [x] [#358 — Feature: operator admin CLI (users, allowlist, usage, disable) and a Backup & restore runbook](https://github.com/CryptoJones/OSApplyTrack/issues/358) (shipped in 1.63.0: `dotnet ApplyTrack.Api.dll admin users|usage|allow|disallow|disable|enable`, run in the api container with the owner's connection — the agent's and poller's roles are refused; `usage` shows per-account applications, letters, evidence rows and bytes, live sessions, last sign-in, last seen and last poll, which the poller now stamps in `users.last_polled_at` (migration 0052, column grant to `applytrack_poller`); `disable` sets `users.status`, and the agent now skips a disabled account's passes and queued runs too; `docs/operations.md` is the backup & restore runbook — `pg_dump` plus custody of `APPLYTRACK_SECRETS_KEY` or the key file, compose and quadlet, a restore that lets the api recreate the roles, the retention sweep on an old dump, and a restore drill)
- [x] [#359 — Feature: observability — opt-in OpenTelemetry, /metrics, and a poll_runs table](https://github.com/CryptoJones/OSApplyTrack/issues/359) (shipped in 1.64.0: the poller writes a `poll_runs` row per account per pass — started/finished, `ats_only`, `leads_added`, and `errors` naming only that account's failing sources (migration 0053; `applytrack_poller` gets INSERT on it and nothing else) — read by `GET /api/poll/status` and shown under the Applications heading as "Last polled 12 min ago · 2 sources failing" with the reasons in a keyboard disclosure; pruned by `Retention__PollRunDays` (30, never an account's newest pass of either kind); `users.last_polled_at` stays the operator's cross-account stamp. Opt-in OpenTelemetry via `OTEL_EXPORTER_OTLP_ENDPOINT` — ASP.NET Core, HttpClient and Npgsql traces (route templates, host-only outbound URLs, Telegram untraced) plus metrics — and a Prometheus `/metrics` only on `Metrics__Port`, never the app's port; both off by default. Custom meters: LLM call latency and errors, agent runs by ATS provider and outcome, submit-queue depth; no metric carries a tenant, host or URL)

## Discovery and workflow

- [x] [#310 — Agent: the person answers the same question again whenever a form words it differently](https://github.com/CryptoJones/OSApplyTrack/issues/310) (the bank matched only exact wording and the drafter never saw the person's own answers — five hand-typed answers to "how did you hear about…" on the live tenant; fixed in 1.53.2: the drafter's prompt carries the person's past answers and reuses one for the same question reworded, never for a merely similar one)
- [x] [#308 — Agent: a clean Easy Apply dry run with nothing to fill is never promoted](https://github.com/CryptoJones/OSApplyTrack/issues/308) (the #302 "mapped nothing" bar parked every Easy Apply dialog that only shows pre-filled contact details — fixed in 1.53.2: a dry run that walked the dialog to LinkedIn's own Submit records `reached_submit`, which stands in for "something was mapped". Also 1.53.2: a posting whose Apply is LinkedIn's "Apply ↗ — Responses managed off LinkedIn" button — eleven leads on 09-23 read "no Apply of any kind" — now has the address that button opens read from the page's own `window.open` (nothing is opened or sent), and the lead moves to the employer's link with its packet rebuilt; a LinkedIn listing staged as "aggregator", which nothing drove, goes to the LinkedIn driver when Easy Apply is on. **1.54.1:** on live LinkedIn that Apply is an `<a target=_blank>` through LinkedIn's own interstitial, `linkedin.com/safety/go/?url=<employer address>` — the check had discarded every linkedin.com link, so 16 leads still read "no Apply of any kind"; the employer's address is now read from its `url=`)
- [x] [#314 — Agent: Ready-queue defects found 2026-09-23](https://github.com/CryptoJones/OSApplyTrack/issues/314) (**1.54.2:** a native date/month/datetime-local input gets its ISO form and a refused fill no longer ends the run (evlo); "Submit Application" is found from the DOM when an aria-modal panel hides it from role queries (ElevenLabs); an email step with a terms box is a sign-in and the box is ticked before Continue (DTCC, Oracle Recruiting); a `?gh_jid=` employer page that shows no form opens Greenhouse's own copy (Bridgeway); Easy Apply's City / Location typeahead falls back to the résumé location (SMX). **1.54.3**, from the first live runs on 1.54.2 — Bridgeway and Teachstone **submitted**: Oracle's email step kept an aria-hidden `tabindex=-1` honeypot on screen, which hid the sign-in from the email-only test, and its terms box is drawn over an input neither `check()` nor a forced one can reach — the input's own `click()` is what its binding hears (DTCC, verified on the live page); and LinkedIn's City typeahead now clicks the suggestion its list shows, trying the place's head ("Minden") when the full text finds nothing, instead of ArrowDown + Enter blind (SMX); and a page whose Next stays disabled hands its unanswered questions back to the drafter even when none is marked required — evlo's "Willing to relocate?" had no star, and Next waited on it (verified on the live form). **1.54.4**, from the 1.54.3 runs — DTCC now signs in (terms ticked, code from the mailbox): discovery skips honeypots too (Oracle's `honey-pot` had become a question the model could not answer, blocking the packet); a radio group titled by a bare starred `<label>` over a `role=radiogroup` is named by that title and read as required (evlo's five Eligibility questions came back named by their radios' `name`, optional); and the disabled-Next hand-back also runs after a click the page refused, since evlo's Next starts enabled and disables itself once pressed)
- [ ] [#318 — Agent: drive Oracle Recruiting Cloud's apply flow past the sign-in (6-box PIN, profile import step)](https://github.com/CryptoJones/OSApplyTrack/issues/318) (DTCC and Wingstop now get through the email step on 1.54.4 — terms ticked, honeypot ignored, code in the mailbox — but the code does not land in Oracle's six single-character PIN boxes, and DTCC's flow opens on a "Import your profile from resume" step. **1.54.5, first step:** the code now goes one digit per box — Oracle's `pin-code-1…6` carry no `maxlength`, so it had all gone into the first box — and a page of code boxes no longer counts as the application form, which is how Wingstop's run declared itself signed in on the PIN page and read the six boxes as questions. Mapped read-only on the live page, 2026-09-24. An address Oracle does not know skips the PIN and lands straight on the form (`apply/section/1`, "step 1 of 4"). **1.54.6:** signed in, Oracle opens "easy apply" — a résumé import, or a resumed draft at `easy-apply/section/N/block/M` (DTCC's "20 issues") — and the run now follows its "manually fill out your application" link to the standard `apply/section/1` flow; Oracle's 0×0 radios (pressed through their `<label for>`) and its `button role=radio` "pills" (sponsorship, education, referral) are now discovered, filled, and checked for required-but-empty; and a sign-in code from Oracle's shared sender must name the employer, so Wingstop no longer reads DTCC's code — the wrong codes had locked Wingstop out ("Too Many Attempts", 30 min). **1.65.1:** Oracle's dropdowns are Oracle JET `oj-select-single`, whose input (`oj-searchselect-input`) the search-box filter took for a job search, so discovery and the required-field sweep never saw one — checked against a real JET widget rendered from Oracle's own bundle on a public Oracle careers page; it is now discovered, typed at through JET's filter box, and chosen from its grid rows (not `role=option`); a dependent list (City under State under Country) that had nothing to offer on its turn is retried once its parent is set; and a required Education/Work History block with no item ("You need to add at least 1 Education item") is named by the sweep instead of clicked through. **Still open, needs a live signed-in run:** filling those repeatable blocks (Add → the tenant's education/experience form → Save) and confirming sections 2–4 walk end to end)
- [x] [#324 — Agent: required questions first met on a later page are refused, never drafted (Vector / ClearCompany)](https://github.com/CryptoJones/OSApplyTrack/issues/324) (Vector's pre-screen was answered, then "considered for other positions?" behind Continue stopped every run and never reached the model. A required question the page still holds empty and the packet has never seen now goes to the drafter, and the run goes round again; unnamed boxes are named by their label)
- [x] [#322 — UI: search finds an application wherever it is — across statuses, lanes and the Errors view](https://github.com/CryptoJones/OSApplyTrack/issues/322) (asked "which page is DTCC on?" — it was stuck under Errors, and the search box only searched the list on screen. Now any search looks everywhere, tags a stuck one "In Errors", and hides Ready's bulk checkboxes while searching)
- [x] [#326 — UI: search results page — number, where it is, links; filters and pages](https://github.com/CryptoJones/OSApplyTrack/issues/326) (every application shows its number, `#15339`, and searching it finds it; the results page lists each match with where it is — Errors or its status, one click away — a link that opens it, and its posting; filtered by where and lane, twenty per page)
- [x] [#328 — Agent: a remote listing whose employer page says hybrid/on-site (or never says remote) is passed](https://github.com/CryptoJones/OSApplyTrack/issues/328) (LinkedIn called DTCC "Tampa, FL (Remote)"; DTCC's page says "hybrid model of 3 days onsite". The browser keeps the employer's rendered posting, and a remote-only profile passes the lead with the employer's own words as the reason — an explicit "hybrid", "N days on-site", "on-site" or "not remote" statement)
- [x] [#330 — Agent: Errors triage bugs — LinkedIn cap stand-downs never retried, a moved lead stuck in Errors, résumé by upload button](https://github.com/CryptoJones/OSApplyTrack/issues/330) (cap stand-downs are `deferred`, requeued when the day's window has room and out of Errors; a packet built after a failure takes the application out of Errors; an "Upload Resume/CV" button's file picker is handed the PDF)
- [x] [#332 — Agent: Greenhouse's Education block is never filled from the résumé](https://github.com/CryptoJones/OSApplyTrack/issues/332) (school, degree and discipline from the highest completed degree, each the option that best names the résumé's words; end year only when stated)
- [x] [#334 — Agent: 'never says remote' passed a remote job (Bankjoy)](https://github.com/CryptoJones/OSApplyTrack/issues/334) (hotfix 1.55.4: silence is no longer judged, only explicit hybrid/on-site statements; Bankjoy restored. 1.56.7: silence is judged only next to the employer's structured workplace from its ATS's public API — Ashby workplaceType/isRemote, Lever workplaceType, a Greenhouse location naming a city; Remote there, or no such field, never passes a silent posting)
- [x] [#336 — Feature: daily applied-jobs email digest (toggle in Settings)](https://github.com/CryptoJones/OSApplyTrack/issues/336) (shipped in 1.58.0: Settings · Notifications · Daily digest — off by default — mails the account's own address once a day, at a chosen UTC hour, through the `EmailNotifier`: subject always `NN application(s) submitted.` (00 included), then each application marked applied the previous UTC day — number, company, role, posting link, open-in-app link; "Insult me" (also off by default) opens it with one of 36 shipped anti-human quips, never yesterday's. Each day is claimed before the send, so it goes at most once)
- [x] [#353 — Feature: status history, per-application timeline and funnel analytics](https://github.com/CryptoJones/OSApplyTrack/issues/353) (shipped in 1.59.0: `status_events` (migration 0049) records every status an application enters — written by statement-level triggers on `applications`, so the API's edits and imports, the agent's flips and the poller's leads are all covered; existing applications are seeded with one event dated by their Applied date or creation. `GET /api/apps/{name}/history` feeds a Status history timeline on the application sheet; `GET /api/analytics?since=` feeds a new Analytics view off the pipeline strip — funnel (applied → screen → onsite → offer), response rate, median days to first response, and the same by source and lane, as accessible tables with inline-SVG bars. The account export carries `status_events` and the import restores them)
- [x] [#354 — Feature: follow-up and interview reminders, plus a per-tenant ICS calendar feed](https://github.com/CryptoJones/OSApplyTrack/issues/354) (shipped in 1.60.0: interviews on an application (`app_events`, migration 0050; add/remove on the sheet, `/api/apps/{name}/interviews`) and its follow-up date join the timeline; a Due chip and view (`GET /api/due`) with Overdue badges on the cards; a daily reminder at a chosen UTC hour — follow-ups due today and interviews in the next 24 h — fanned out to Telegram and email under the new `notify_followup_due` toggle; and `GET /api/calendar.ics?token=` behind a revocable, feed-only token in a new `api_tokens` table (hash only, `calendar` scope, shaped for #356). Interviews travel in the export/import)
- [x] [#355 — Feature: email as a general notification channel (EmailNotifier alongside Telegram)](https://github.com/CryptoJones/OSApplyTrack/issues/355) (shipped in 1.57.0: an `EmailNotifier` over the instance's own SMTP sender (`IEmailSender.SendAsync`, reusable by the #336 digest) mails the account's own address; every moo fans out to Telegram and email, each recorded per channel; `notification_settings.email_enabled` plus per-event toggles — packet ready, security code needed, submit failed (new: a real run that did not go through now moos why) — under Settings · Notifications, with a test-email button. The follow-up-due toggle lands with the reminders in #354)
- [x] [#356 — Feature: personal API tokens for scripting, clippers and calendar feeds](https://github.com/CryptoJones/OSApplyTrack/issues/356) (shipped in 1.61.0: `api_tokens` (0050) takes `read` and `write` scopes and a name (migration 0051); `TenantMiddleware` accepts `Authorization: Bearer atk_…` when there is no session — read tokens get GETs, write tokens every method, and no token reaches `/api/account/tokens`, `/api/account/sessions`, `/api/calendar/feed`, `DELETE /api/account` or the secret-bearing settings (`/api/llm-settings`, `/api/notifications`, `/api/board-accounts`); a disabled account's tokens stop; `GET/POST/DELETE /api/account/tokens` and Settings · Account · API tokens, the secret shown once and stored by SHA-256; 120 requests a minute per token; `last_used_at` written at most once a minute. The calendar feed keeps its own feed-only token from #354)
- [x] [#357 — Feature: complete account backup (export v2) and a CSV export](https://github.com/CryptoJones/OSApplyTrack/issues/357) (shipped in 1.62.0: `/api/account/export` is `version: 2` — adds `resume` (the `/api/resume` shape plus `source_pdf` base64 and its name), `cover_letters`, `answer_bank` (human-source rows only), `agent_settings`, `llm_settings` (endpoint, model, cover-letter switch and signature) and `notification_settings` (email switch, event toggles, reminder and digest); no secret travels — not the LLM key, the Telegram token or chat id, mailbox or board passwords, sessions or tokens. Import still takes v1 files, ignores unknown sections, keeps the #344 caps, checks a PDF like an upload, never flips the agent's on or dry-run switches, drops a stored LLM key when the file names another endpoint, and skips the LLM and notification sections for an API token. `GET /api/account/export.csv` gives the applications as RFC 4180 CSV with `= + - @` cells prefixed `'`; a CSV button under Settings · Account)
- [x] [#360 — Feature: contacts and a dated notes log per application](https://github.com/CryptoJones/OSApplyTrack/issues/360) (shipped in 1.65.0: `contacts` + `application_contacts` + `app_notes` (migration 0054); `GET/POST /api/contacts`, `GET/PUT/DELETE /api/contacts/{id}` with the `?expected_version=` 409 flow; `GET/POST/DELETE /api/apps/{name}/contacts` (link an existing contact or add one inline, with their part in the process); `GET/POST/DELETE /api/apps/{name}/notes`, the log joining `/history` as kind `note`; People and Notes log sections on the sheet; contact email, phone, profile link and notes and every note body sealed at rest and covered by the key-rotation sweep; revoked from the agent role; contacts, links and notes travel in the export and the import restores them, a contact matched by name and email. The application's own `contact` fields stay. Attachments remain a later item)
- [x] [#313 — CI is failing on main](https://github.com/CryptoJones/OSApplyTrack/issues/313) (auto-filed for the Python job on #311's merge commit; main was green on every merge after it — #312, #315, #316, #317 — closed 2026-09-24)
- [x] [#302 — Agent: a clean dry run is promoted to a real submission that runs as a dry run again — an unbounded loop against LinkedIn](https://github.com/CryptoJones/OSApplyTrack/issues/302) (two Easy Apply packets looped every ~32 s in production on 1.51.0, 142 iterations, opening LinkedIn's dialog and Discarding each time — a run asked for as real was downgraded to a dry run mid-flight, and the clean dry run promoted itself back to a real run. Fix in [#303](https://github.com/CryptoJones/OSApplyTrack/pull/303): promotion also requires the run to have been *requested* as a dry run, so nothing downgraded can re-promote; the LinkedIn daily cap stands the run down before the browser instead of silently downgrading it; and a run that mapped nothing is no longer "clean" — in the worker and in `IsCleanDryRun`, which is the same bar `ReadyPromoter` uses every pass; **shipped in 1.51.1**)
- [x] [#300 — Poller: evaluate Jev (TypeSafe System One) for the semantic half of `classify`, keeping the rules in code](https://github.com/CryptoJones/OSApplyTrack/issues/300) (`_keyword_re` has been patched three times — [#81](https://github.com/CryptoJones/OSApplyTrack/issues/81), [#85](https://github.com/CryptoJones/OSApplyTrack/issues/85), [#86](https://github.com/CryptoJones/OSApplyTrack/issues/86) — because the regex approximates a question it cannot express: is this posting about the work I do? **Evaluated** in `tools/jev_eval/` on 257 live listings, hand-labelled, with the owner's keyword profile: keyword `classify()` 184/257 (71.6%), Jev `noul` 226/257 (87.9%), Jev `score` 222/257; discordant pairs Jev 60 / keywords 18, McNemar exact p = 2e-6; ~1,360 input tokens ≈ $0.000057 per posting; no decision flipped on a 40-listing re-ask. Most of the gain is recall on roles the keyword list never names. **Implemented opt-in** in [#304](https://github.com/CryptoJones/OSApplyTrack/pull/304): `agent_settings.jev_classify` (migration 0038, Settings · Agent), effective only with the operator's `TYPESAFE_API_KEY` on the poller; keyword `classify()` stays the default and the fallback on any error, so a self-hoster with no key loses nothing. The browser/form-finding path stays out of scope)
- [x] [#69 — Allow sorting by fit, date posted, or company name](https://github.com/CryptoJones/OSApplyTrack/issues/69)
- [x] [#70 — Allow adding custom RSS feeds for job listings](https://github.com/CryptoJones/OSApplyTrack/issues/70)
- [x] [#77 — Add RemoteFirstJobs and WorkAnywhere.pro as built-in sources, and the remaining We Work Remotely category feeds](https://github.com/CryptoJones/OSApplyTrack/issues/77)
- [x] [#80 — Poller: a cross-listed posting spends a slot in every category feed it appears in](https://github.com/CryptoJones/OSApplyTrack/issues/80)
- [x] [#81 — Poller: keyword matching is raw substring, so short keywords fire mid-word](https://github.com/CryptoJones/OSApplyTrack/issues/81)
- [x] [#85 — Poller: the `.net` word-boundary guard blocks ASP.NET, VB.NET and ADO.NET titles](https://github.com/CryptoJones/OSApplyTrack/issues/85)
- [x] [#86 — Poller: the keyword suffix allowance re-opens the rag/ml/go false positives](https://github.com/CryptoJones/OSApplyTrack/issues/86)
- [x] [#87 — Poller: listings with no link bypass the cross-feed dedupe entirely](https://github.com/CryptoJones/OSApplyTrack/issues/87)
- [x] [#106 — Follow a company's Paylocity recruiting board](https://github.com/CryptoJones/OSApplyTrack/issues/106)
- [x] [#110 — Grey out "Mark applied" once a role is already applied](https://github.com/CryptoJones/OSApplyTrack/issues/110)
- [x] [#111 — Show the running build version in the header](https://github.com/CryptoJones/OSApplyTrack/issues/111)
- [x] [#112 — A dropped submit request leaves no trace anywhere](https://github.com/CryptoJones/OSApplyTrack/issues/112)
- [x] [#201 — Mobile: the Ready list will not scroll — swiping down past the bulk bar rubber-bands and Safari refreshes the page](https://github.com/CryptoJones/OSApplyTrack/issues/201) (fixed in 1.31.0)
- [x] [#114 — A crashed browser submission leaves no evidence at all](https://github.com/CryptoJones/OSApplyTrack/issues/114)
- [x] [#233 — LinkedIn as a signed-in discovery source: search with the candidate's own account, store only the employer's posting, skip Easy Apply](https://github.com/CryptoJones/OSApplyTrack/issues/233) (shipped in 1.38.0: the `linkedin` source searches the tenant's keywords through the endpoints LinkedIn's own web app uses, with the session the agent's browser keeps on the `linkedin.com` board account — password sign-in, the app tap asked for by a moo and waited on, the emailed PIN read from the mailbox, the cookies sealed for 300 days; every offsite-apply posting is staged with the employer's link, Easy Apply ones are remembered and skipped, the ledger is asked before a posting is read; LinkedIn's guest search is the fallback with no session; `linkedin.com` is an aggregator host in both runtimes)
- [x] [#235 — Apply that opens a social sign-up wall (Alignerr: only "Continue with Google / LinkedIn") is reported as "no form appeared"](https://github.com/CryptoJones/OSApplyTrack/issues/235) (fixed in 1.38.1 and 1.38.2: a page whose only way in is identity-provider buttons, with nothing to type into, is named as such — what Apply led to, which providers, and that the posting is the person's by Copy answers and open — instead of "no form appeared" or "nothing matched" on its terms checkbox; and the new tab Apply opens is adopted whenever it arrives during the ten-second wait, not only in the moment after the click — through pluto's proxy Alignerr's arrived later, so the run kept watching the posting and never saw the wall)
- [x] [#238 — Poller: jobright.ai is an aggregator — a LinkedIn posting whose Apply leads there should resolve on to the employer, or be apply-by-hand](https://github.com/CryptoJones/OSApplyTrack/issues/238) (fixed in 1.38.2: `jobright.ai` is an aggregator host in both runtimes, so the #191 rule follows the listing on to the employer's posting when it can and the lead is apply-by-hand otherwise)
- [x] [#239 — Browser: Paycor's pre-screening step (a radio question and Continue before the form) is read as "no Apply button or link on the page"](https://github.com/CryptoJones/OSApplyTrack/issues/239) (fixed in 1.39.0: a page whose only controls are radio/checkbox questions and a Continue is a pre-screening step, not a formless dead end — discovery answers it from the tenant's standing answers and reads the form behind it, submit answers it from the packet and presses through to that form, and a step that cannot be answered is named instead of "no Apply button or link on the page")
- [x] [#243 — Poller: sundayy.com is an aggregator — its Apply is a redirect chain through more aggregators, not a form](https://github.com/CryptoJones/OSApplyTrack/issues/243) (fixed in 1.39.1: `sundayy.com`, and the two other job-board hosts its Apply chains through — `thebigjobsite.com` and `lensa.com` — are aggregator hosts in both runtimes, so the #191 rule follows the listing on to the employer when it can and the lead is apply-by-hand otherwise instead of a failed browser run)
- [x] [#246 — Browser: a form collapsed behind "Apply Now" with a hidden résumé input is read as "no Submit button found" (Freshteam)](https://github.com/CryptoJones/OSApplyTrack/issues/246) (fixed in 1.39.5: the reveal's early return no longer counts a hidden file input as "the form is on screen", so a form shipped collapsed behind an "Apply Now" link is expanded by the click and every real field is discovered — a complete Copy-answers packet — instead of only the résumé field and a "no Submit button found". A reCAPTCHA still, correctly, blocks auto-submit for this posting)
- [x] [#259 — SPA: status chips (lead/ready/applied/passed) should open a list in the main pane, like Pipeline](https://github.com/CryptoJones/OSApplyTrack/issues/259) (fixed in 1.41.0: a status chip opens a main-pane table of that status's applications — company · role, lane, fit, posted, applied — that follows the sidebar's lane, search, and sort; each row opens its application, and un-pressing the chip closes the view)
- [x] [#262 — SPA static files carry no Cache-Control, so browsers keep running an old app.js after a deploy](https://github.com/CryptoJones/OSApplyTrack/issues/262) (fixed in 1.41.1: every static file is served `Cache-Control: no-cache`, so a load revalidates against the ETag — a cheap 304 — and a deploy is seen without a hard reload)
- [x] [#267 — Add Handshake integration](https://github.com/CryptoJones/OSApplyTrack/issues/267) (fixed in [#271](https://github.com/CryptoJones/OSApplyTrack/pull/271): the `handshake` source searches campus recruiting through the endpoint Handshake's own web app calls, signed in with the tenant's own account — the board account holds the school email, and the agent's browser signs in and seals the ninety-day `hss-global` session. One GraphQL search returns each posting's copy *and* `jobApplySetting`, so an externally-applied posting is staged with the employer's own URL (iCIMS, BambooHR, …) and a Handshake-hosted one — the school-scoped posting that exists nowhere else — is staged apply-by-hand. The official EDU API is read-only, institution-scoped and partner-gated, so a self-hosted tenant cannot use it; and there is no guest path, because Handshake's public pages expose fifteen postings per URL and hide the employer's URL. Reviewed by the Flatline Roundtable — 13 of 14 lanes, one of them truncated, which found that `jobApplySetting` was selectable in the search itself and so deleted the second round-trip entirely)
- [x] [#276 — Poller: an employer apply link the employer's server refuses to show a bot is thrown away, and the lead is staged on the LinkedIn listing nobody can drive](https://github.com/CryptoJones/OSApplyTrack/issues/276) (fixed in 1.48.5: a 401/403/405/406/429/451/503 from the employer's careers site is a refusal to talk to a bot, not a retired posting — the link LinkedIn handed over is kept and the reachability gate is skipped for it, since the listing was live a moment ago. A 404/410, a redirect home or a generic index is still dead, and the listing's own link stays in place)
- [x] [#277 — Agent: create a candidate account on any account-only ATS it meets (Workday, Taleo, iCIMS, UKG, SuccessFactors) and apply through it](https://github.com/CryptoJones/OSApplyTrack/issues/277) (reverses "Workday stays manual, permanently" at the owner's direction, 2026-09-19: an opt-in gate, one sealed Workday credential pair reused across employer tenants with Create Account and the verification mail read from the tenant's mailbox, a discover-fill-Next step loop that stops at Review on a dry run, and per-step drafting. **1.49.3, first step:** a Workday posting's page is a script under an HTTP 200 whether the job exists or not, so rows the browser never opens sat in Ready after the posting was taken down (CVS Health, Voya). The JSON behind the page (`/wday/cxs/<tenant>/<site>/job/…`) answers 404 for a removed job: the judge-time check uses it, and every pass asks it of a few lead/ready Workday rows, each at most twice a day. Flow mapped read-only 2026-09-21: `adventureButton` → `applyManually` → Create Account (`email`, `password`, `verifyPassword`, `createAccountCheckbox`, `createAccountSubmitButton` under a `click_filter` overlay, and `beecatcher`, a honeypot that must stay empty). **1.49.4:** live, that check retired nothing — the page fetcher sends `Accept: text/html`, which Workday's JSON answers with a **406**, not the 404. The liveness request now asks for JSON, and the test answers 406 to anything else. **Scope widened 2026-09-21 at the owner's direction** to any account-only ATS; design on the issue. **1.49.6, UKG (UltiPro) first:** UKG's login is one shared Auth0-style account (`signin-us.ultipro.com`) reused across every board it hosts, so one candidate account created and proven by sign-in serves every UKG employer; it is saved as the `ultipro.com` board account. After sign-in a first-time candidate lands on the employer's own profile step — name, phone, a privacy consent and "Create account", all web components — which the agent now fills from the packet and advances; the application's Submit is a `<ukg-button>` with the real button in a shadow root. Live on onestream: signed in, registered, into the application. The account itself was created by hand for now; creating it from the agent is the next step **1.53.0:** the agent creates the account itself — opt-in per tenant (`agent_settings.create_accounts`, migration 0039, **off by default**, stated plainly in Settings · Agent). A run that stops at a sign-in no saved account covers now says which host; with the opt-in on and a recipe for it, the agent registers with the application email and a 24-character generated password, verifies from the mailbox (a link on the board's own site, or a code), **proves the account by signing in with it in a fresh browser**, and only then seals it under Board accounts for that exact host — an `account_created` event in the agent's activity, and the run goes round again signed in. "Already has an account" is reported, never reset; a captcha ends it. Recipes: Workday (the flow mapped on 09-21, fixture-tested end to end) and haystack.cv (its sign-up page mapped 09-22; its emailed link lands a candidate with no account there, #280). `BoardAccount.For` prefers the exact host, and on Workday, Taleo and iCIMS a shared domain no longer counts as cover — one employer's account is never offered to another. **1.53.1:** Oracle Taleo's recipe — every tenant's IAM registration (User Name, password twice, email twice, Register), mapped read-only on upmcjobs.taleo.net, the email as user name and a letters-and-digits password, since Taleo's policy names only those. **1.54.0 — done:** UKG's account is created too (its one sign-in for every board: the posting's Apply → Sign up → email and password → Continue, kept under `ultipro.com`); iCIMS is named and says which wall stopped it — an hCaptcha before it takes an email, or an email-then-password sign-in no account can be proved through — and nothing is registered there; the résumé carries education, read from an uploaded résumé's EDUCATION section; and UKG's Work Experience and Education panels are filled row by row from it — the three most recent jobs and every school, each row kept with its own Save, a month the résumé does not state left blank for the person, never guessed (verified live on OneStream's board: three schools added and kept). A UKG posting that says "This opportunity is currently not available" is retired as closed.)
- [x] [#278 — Agent: apply to LinkedIn Easy Apply postings (today the poller skips them entirely)](https://github.com/CryptoJones/OSApplyTrack/issues/278) (shipped in 1.49.0, opt-in and **off by default**: `agent_settings.linkedin_easy` (migration 0036) is read by both runtimes. On, the poller stages Easy Apply postings as `auto:linkedin:easy` on the posting's own link — unresolved and unprobed, since linkedin.com answers a bot with a 999 — and `AtsProvider` reads that as `linkedin_easy`, driven only by its own switch, never the long tail's. The browser starts signed in from the kept `li_at`/`JSESSIONID` session, which the form-filling side had never been handed, and `LinkedInEasyApply` walks the dialog mapped from the live one on 2026-09-20. Its questions exist only once their step is reached, so a run hands back the required ones it met for the first time, `PacketBuilder.ExtendAsync` adds and drafts them, and the run goes round again — a question is only ever discovered once, so it cannot loop. A dry run stops at Submit and Discards; a run that needs the person Saves, keeping LinkedIn's own draft. "Follow the company" is unticked, real sends are capped at ten a day, and a sign-in or checkpoint ends the run. The fixture reproduces LinkedIn's real markup, and caught that a label says itself twice — once to be seen, once for a screen reader. **1.49.1**, found by running 1.49.0 against the real page: the posting's own page — the one a stored link opens — has an *anchor* known only by its `aria-label` where the search page's side panel has a button, and renders the dialog inside a shadow root (`div#interop-outlet`) where `document.querySelector` and a document-level `label[for]` see nothing. The entry selector takes both, and every script starts from the dialog element and asks its own root; the fixture now does the same, behind a decoy "Easy Apply" filter pill. **Open until verified end to end on the live tenant.** 2026-09-20, in dry-run: with a session the agent's browser had just minted, the driver opened the real dialog on pluto, walked its steps, met the employer's "Are you eligible to obtain a U.S. Security Clearance?", handed it back, saved the draft, and the worker drafted an answer and queued the second run by itself — the session survived. An earlier run on a stale session got LinkedIn's signed-out page, which is what the run now has to say rather than "no Easy Apply button". **1.49.2:** that second run found no entry, because a saved draft turns "Easy Apply" into "Continue"; on the posting's own page both are the same link into `…/apply/`, which is what is looked for now. Separately, and worth remembering: the same `li_at` loaded into a *different browser engine* on another machine was revoked within minutes — never replay a session across engines. **1.50.0:** that replay revoked the session on 09-21, and every renewal since waited 240 s for a tap in the LinkedIn app at an hour nobody chose, or was cut off by a deploy — twenty-five Easy Apply packets each spent a browser trip and a retry on the signed-out page. `POST /api/board-accounts/{host}/renew` — **Sign in now** beside the account — has the worker sign in within a minute, while the person holds the phone; a run with no kept session stands down before the browser, is neither retried on the clock nor counted against the packet, and is queued again by the renewal that keeps one. **Verified end to end on the live tenant 2026-09-22**: Sign in now sealed the session until 2027-07-19, the 26 stalled runs queued themselves again, and the first of them submitted for real (starburst, 16 mapped) with five more clean dry runs behind it. **1.52.0:** fourteen of those 26 came back on one line — "no Easy Apply button on the posting — already applied to, or the employer moved it to its own site" — which guessed between two outcomes that want opposite things: an application already sent is finished, and an offsite Apply is a lead to re-stage on the employer's own link. The page says which, so the run reads it: LinkedIn's own mark on a posting it holds an application for closes the row, an Apply that is not Easy Apply names the employer's host, and a page that is neither is quoted rather than guessed at **Closed 2026-09-22, verified live:** 13 real Easy Apply submissions, each confirmed by LinkedIn's own apply call answering 200.)
- [x] [#280 — Agent: deterministic form-finding failures found by re-running Ready on 2026-09-19](https://github.com/CryptoJones/OSApplyTrack/issues/280) (a Greenhouse posting that redirects to an embed on the employer's own domain — drive `embed/job_app?for=<board>&token=<id>` instead; an Ashby checkbox addressed by an id the live form no longer renders; employer career sites with no recognised Apply control; and an Ashby submit whose POST returns 200 with no recognised confirmation, which needs the GraphQL body read for the outcome) — **partly fixed in 1.48.8**, each verified against the live page: a hosted Greenhouse posting whose board redirects to the employer's own site (fieldwire) was refused as an off-site navigation and left the run on a blank page — it now opens Greenhouse's own `embed/job_app?for=<board>&token=<id>`; an Apply link whose accessible name opens with the job title (Allstate: `aria-label="Lead .Net Software Engineer Apply Now…"`) is found by what it visibly says; Ashby's drawn checkbox hides the real input, and the 20 s wait threw a `System.TimeoutException` that `catch (PlaywrightException)` never saw, ending gravie's run four times — bounded, caught, and set through its label; iCIMS and Oracle Taleo are named as account-only. **1.48.9**, found by running 1.48.8 live: Ashby's yes/no is two buttons and a hidden checkbox that only mirrors them, so an answer of No "matched" the already-unchecked box with nothing pressed — gravie's dry run came back clean and its real submit was rejected as unanswered; the button is now pressed and only its own `aria-pressed` believed. And a run that *lands* on Workday, Taleo or iCIMS (hosts the guard lets through) says it needs an account instead of "no form appeared within 10 s". **1.49.3**, from the screenshots the failed runs had already saved: the two Ashby "Submit was clicked, no confirmation" rows were **never submitted** — actai had three required radio questions blank and ElevenLabs its Location typeahead empty; Ashby answered 200 and stayed on the form. Ashby marks a question required only with a generated `_required_` class on its *title label*, and titles a radio group with a `<label>` in a legend-less `<fieldset>`, so discovery named the group by its radios' `name` (a UUID) and called it optional, nobody answered it, and the required-empty sweep never saw it. Discovery reads the title and the class; the sweep checks label-required radio groups, yes/no pairs and typeaheads; a drawn radio is pressed through its label. McGraw Hill was a bare `403 Forbidden` to pluto — now said in those words, not "no Apply button". **1.49.4–1.49.5**, by re-running the two Ashby rows after each fix: a typeahead with no `id`/`name` whose title's `for` matches nothing (ElevenLabs' Location) is found through the entry its title sits in; a long radio option is matched by its opening words after an exact pass fails; and — the root of most Ashby radio failures — a radio group's `name` is `<form instance>_<field>` with the first half **new on every page load**, so a packet built on one load named a control the next load never had. Radios are matched by the stable field half, discovery and the sweep key by the title's `for`. ElevenLabs is now down to "How did you hear about us?", which the drafter rightly leaves for the person. **1.49.7:** a "select all that apply" checkbox group was swept box by box, so every unticked member of a fully answered group read as a required field left empty and Fieldwire was refused the click; the group is judged as a whole. **1.50.0:** a form in pages is walked — ClearCompany's "Page 1 · Page 2 · Page 3", evlo's five-step wizard — Next pressed, each page read as discovery reads a form, filled from the packet, the questions it has never heard of handed back for the drafter and the run gone round again (the #278 mechanism), never past a page whose required question has no answer; a radio group titled by plain text above its options (ClearCompany's `<span class="control-label">`) is discovered by that title, not its radios' UUID name; a posting whose link now lands on the board's job list (Darwinbox took 3pillar's down without a word) is closed, not "no Apply button"; a Submit that is there but disabled is waited on ten seconds, and "no Submit button found" lists every button the page had — ElevenLabs' nine such runs reproduce nowhere off pluto (a new `APPLYTRACK_LIVE_URL` harness drives any posting with the application mutation aborted), so the next one says what it saw. **1.51.0:** the tab Apply opens is adopted even when the context's `Page` event never fired for it — the context's own page list is asked on every pass of the wait — and, when no tab arrives at all, where the Apply link *pointed* names the ATS: Allstate's `target="_blank"` anchor now reads "Apply leads to allstate.wd5.myworkdayjobs.com (Workday), which only takes applications from a signed-in candidate account" instead of "no form appeared" about a page the run never left. And a field titled by plain text above it and nothing else — no aria, no `label[for]`, no wrapping label, no legend, which is how Workable renders a screening question over an input whose id *and* name are the same generated hex — is named by that text, with the star in its aria-hidden sibling read as required; Stevens' and Pinewood's questions came back named by the hex, so the drafter answered questions it could not read and so did the person. **1.52.1 — closed:** a form behind an email sign-in that the packet never saw (micro1: the packet was built from the sign-in page, the only thing a dry run reaches, so "nothing on this form could be filled") now hands its questions back to the drafter, and the run goes round again as a real run — a dry run can never see that form. ElevenLabs' "no Submit button found" no longer reproduces: the live harness, now able to drive production's own browser and proxy (`APPLYTRACK_LIVE_WS` / `APPLYTRACK_LIVE_PROXY`), found and clicked its Submit on 1.51.1 with the POST blocked — the nine failures all predate 1.50.0's wait for a disabled Submit. haystack.cv needs a candidate account (its magic link lands on sign-up), which is #277's account creation, and moves there.)
- [x] [#281 — Dead-end aggregator network (bestjobtool / fetchjobs.co / thebigjobsite / sundayy): stop staging its listings, and retire the ones already parked](https://github.com/CryptoJones/OSApplyTrack/issues/281) (fixed in 1.48.6: reported as "doesn't know to click the apply now button" — driven in a real Chromium, "Quick Apply" is a plain link to `us.thebigjobsite.com/redirectjob`, which answers with a Cloudflare bot check and never a form, and the listing was a New York civil-service posting under an invented title. The poller no longer stages a listing on the network or one whose Apply leads to it, and every agent pass marks a `lead` or `ready` row on it `passed` with the reason recorded, no browser needed. `lensa.com`, where the chain ends, stays apply-by-hand)
- [x] [#283 — Browser: a cookie dialog named for nothing but its text is never dismissed (Radancy / UnitedHealth Group) — accept cookie banners across the board](https://github.com/CryptoJones/OSApplyTrack/issues/283) (fixed in 1.48.7: optum failed four runs with "the Apply button did not respond within 5 s" behind `div#system-imessage` "Important System Message", whose only mention of cookies is its text. Radancy's button by id plus Didomi, Usercentrics, Quantcast, CookieYes, Complianz, Iubenda, Termly, Klaro, Civic and Cookie Script; links as well as buttons; and any dialog or box pinned over the page whose text is about cookies — unless it holds fillable fields, so "I agree" sitting with a real form is still an answer)
- [x] [#284 — An "N errors" view between Ready and Applied: a failed run drops back into Ready and looks like a packet nobody has tried](https://github.com/CryptoJones/OSApplyTrack/issues/284) (fixed in 1.48.7: `GET /api/errors` lists Ready applications with nothing queued whose newest evidence is `failed` — what went wrong, when, how many runs, and what happens next: a retry the agent will make by itself and when, or that it needs you and why. `/api/stats` gains `errors`; the strip shows an **N errors** chip after lead and ready, and the ready chip and list no longer count the errored ones. Each row has Retry. A derived view, not a new status — the status list is part of the schema both runtimes share)

## Agentic auto-apply

An attached model evaluates qualifying leads, drafts the materials, answers the
screening questions, and prepares a packet. With dry-run-only disabled, the worker
automatically promotes a clean browser dry run to a real submission. Required
review items and unsupported forms still need human attention.

- [x] Step 1 — Read the job posting before drafting the letter ([#91](https://github.com/CryptoJones/OSApplyTrack/pull/91))
- [x] [#92 — Step 2: the agent reads the posting and forms its own fit verdict](https://github.com/CryptoJones/OSApplyTrack/issues/92)
- [x] [#93 — Step 3: prepared packets and the Ready-to-submit queue](https://github.com/CryptoJones/OSApplyTrack/issues/93)
- [x] [#99 — Notify by Telegram (moo) when a packet is ready to submit](https://github.com/CryptoJones/OSApplyTrack/issues/99)
- [x] [#94 — Step 4: human-triggered browser submission, dry-run by default](https://github.com/CryptoJones/OSApplyTrack/issues/94)
- [x] [#95 — Step 5: Lever, Ashby, and the unknown long tail](https://github.com/CryptoJones/OSApplyTrack/issues/95)
- [x] [#133 — Optional review items permanently block a packet from being submitted](https://github.com/CryptoJones/OSApplyTrack/issues/133) (fix in [#132](https://github.com/CryptoJones/OSApplyTrack/pull/132))
- [x] [#135 — Résumé counted as attached when the board rejected it; captcha forms burn runs](https://github.com/CryptoJones/OSApplyTrack/issues/135) (fix in [#136](https://github.com/CryptoJones/OSApplyTrack/pull/136))
- [x] [#138 — Résumé attach check races the uploader: dry run says mapped, the real run says unmapped](https://github.com/CryptoJones/OSApplyTrack/issues/138)
- [x] [#151 — Greenhouse submit blocked by unfilled required fields (country, location, react-select questions) — not reCAPTCHA](https://github.com/CryptoJones/OSApplyTrack/issues/151) (fixed in 1.26.0)
- [x] [#124 — Reduce posting→applied latency so the agent reaches postings before they close](https://github.com/CryptoJones/OSApplyTrack/issues/124)
- [x] [#129 — Agent abandons a run when the form has a radio group or checkbox](https://github.com/CryptoJones/OSApplyTrack/issues/129) (fix in [#128](https://github.com/CryptoJones/OSApplyTrack/pull/128))
- [x] [#159 — Submit and packet/prepare never queue a browser run from the api container — only the agent has Browser__Endpoint](https://github.com/CryptoJones/OSApplyTrack/issues/159) (fix in [#172](https://github.com/CryptoJones/OSApplyTrack/pull/172): the worker heartbeats, the api reads it)
- [x] [#168 — Take the Greenhouse security code back from a Telegram reply to the moo](https://github.com/CryptoJones/OSApplyTrack/issues/168) (fix in [#173](https://github.com/CryptoJones/OSApplyTrack/pull/173))
- [x] [#180 — Long-tail boards whose Apply leads somewhere else: Workable, Breezy, SmartRecruiters, join.com, aggregator links](https://github.com/CryptoJones/OSApplyTrack/issues/180) (fixed in 1.30.0)
- [x] [#183 — Prepare from the api container should hand discovery to the worker, not skip it](https://github.com/CryptoJones/OSApplyTrack/issues/183) (fixed in 1.30.0)
- [x] [#184 — Worker heartbeat cadence is tied to the submit lane, so worker_running flickers on a worker without a browser](https://github.com/CryptoJones/OSApplyTrack/issues/184) (fixed in 1.30.0)
- [x] [#185 — Ready packets with a clean dry run never promote once dry-run is switched off](https://github.com/CryptoJones/OSApplyTrack/issues/185) (fixed in 1.30.0)
- [x] [#186 — Bulk actions in the Ready lane: prepare / submit / pass the selected packets without tripping the rate limit](https://github.com/CryptoJones/OSApplyTrack/issues/186) (fixed in 1.30.0)
- [x] [#187 — Field locator prefers a substring label match over an exact one, so a second similarly-named field gets filled twice](https://github.com/CryptoJones/OSApplyTrack/issues/187) (fixed in 1.30.0)
- [x] [#188 — Salary expectation should carry a period and a currency, and convert within the same currency](https://github.com/CryptoJones/OSApplyTrack/issues/188) (fixed in 1.30.0)
- [x] [#189 — Answer bank edits should be able to refill already-built packets, and packets should not freeze profile facts](https://github.com/CryptoJones/OSApplyTrack/issues/189) (fixed in 1.30.0)
- [x] [#190 — The "filled" moo and the evidence label should say what still needs the person](https://github.com/CryptoJones/OSApplyTrack/issues/190) (fixed in 1.30.0)
- [x] [#191 — Poller: resolve aggregator listing links to the employer's posting, or mark the lead apply-by-hand](https://github.com/CryptoJones/OSApplyTrack/issues/191) (fixed in 1.30.0)
- [x] [#192 — Worker-level tests for the parked-on-code path, and a live check of the Telegram reply](https://github.com/CryptoJones/OSApplyTrack/issues/192) (worker-level tests in 1.30.0; live check passed on pluto 2026-09-13 — the code came back through a Telegram reply and GitLab was submitted)
- [x] [#195 — Greenhouse résumé listed optional by the API but required on the form: a failed attach is not unmapped, so the click goes ahead into "Resume/CV is required"](https://github.com/CryptoJones/OSApplyTrack/issues/195) (fixed in 1.31.1: a failed attach always refuses, the uploader gets 2.5 s more to fail, the pre-click sweep reads the upload group's own required flag and retries Enter manually there)
- [x] [#205 — Zoho Recruit's consent banner arrives after load with a full-viewport freeze layer: Apply times out under it](https://github.com/CryptoJones/OSApplyTrack/issues/205) (fixed in 1.31.1: consent is dismissed again right before the Apply click, and once more if the click still would not land)
- [x] [#207 — Zoho Recruit long-tail form: Country, LinkedIn and Facebook get the wrong answers — the label fallback matches too loosely](https://github.com/CryptoJones/OSApplyTrack/issues/207) (fixed in 1.31.2: discovery reads a web component's own label attribute and the row's label, keeps same-id boxes by label; a question with no readable label is never put to the model; social-profile boxes take that site's link or stay empty)
- [x] [#210 — join.com: the agent clicks "Apply later" (the email-me-the-link box) instead of Apply, so the board emails the posting link and no application is sent](https://github.com/CryptoJones/OSApplyTrack/issues/210) (fixed in 1.33.0: a lone email box is not an application form; nothing that reads as "later / send me the link" is ever the Apply trigger or the Submit button; a side form's required email is ignored by the required-empty sweep. The email sign-in behind Apply is driven too: a dry run stops there and says what Submit would do; a real Submit types the candidate's email, presses Continue, and parks the way the Greenhouse security code does — the emailed code or link, pasted in the app, replied to the 🔐 moo, or read from the mailbox, takes the run on to the form in the same session; a link is followed only onto the board's own site)
- [x] [#211 — Pipeline view: click the strip's PIPELINE label to see the submit queue and what each request will do when drained](https://github.com/CryptoJones/OSApplyTrack/issues/211) (shipped in 1.33.0: `GET /api/pipeline` and the Pipeline view — queue in claim order with phase and what the worker will do, the Ready packets not queued and what holds them, Submit all clean)
- [x] [#200 — Last name drafted as "K. Clark": middle initials land in the last name, and the name fields cannot be corrected in Settings · Answers](https://github.com/CryptoJones/OSApplyTrack/issues/200) (fixed in 1.31.0: initials stay out of the split, and First name / Last name are rows in Settings · Answers)
- [x] [#202 — Browser: cookie-consent dialogs ("Accept all") sit over the Apply button, so the click times out and the run reports no form](https://github.com/CryptoJones/OSApplyTrack/issues/202) (fixed in 1.31.0)
- [x] [#203 — Submit drives the packet's stale provider, and a posting that is gone (HTTP 410/404) is reported as "no form found"](https://github.com/CryptoJones/OSApplyTrack/issues/203) (fixed in 1.31.0)
- [x] [#224 — Demographic answers (gender, race, veteran, disability) are never saved or loaded on queued jobs](https://github.com/CryptoJones/OSApplyTrack/issues/224) (fixed in 1.36.0: bankable, answered once in Settings · Answers or on any packet, applied to every packet whose form offers the same option, filled by the browser, never guessed, never flagged)
- [x] [#221 — MyGreenhouse: renew the portal session through the agent's browser when it lapses](https://github.com/CryptoJones/OSApplyTrack/issues/221) (shipped in 1.37.0: the agent's pass signs in at `my.greenhouse.io` in its browser when the kept session is missing, bounced or within two days of running out — Send security code, the code read from the tenant's mailbox, the `_session_id` cookie sealed on the board-account row for thirteen days; a tenant is tried at most every six hours; the poller only searches with the kept session and clears one the portal bounces — its own plain-client sign-in, which the portal never sent the mail for, is gone)
- [x] [#222 — SuccessFactors driver leftovers on Kiewit: pinned picker answers not selected, Acknowledgement checkbox and a years box regressed](https://github.com/CryptoJones/OSApplyTrack/issues/222) (fixed in 1.37.0, read against the real form: ids that start with a digit are looked up by attribute so the picker's menu is read and the pinned answer chosen; the required star is stripped from labels on both sides so a field the board renumbered, or two boxes it names `-1`, are found by label; a label match that is not a control no longer sinks the one before it; the Acknowledgement box the widget re-renders is read fresh after the click; "No Selection" is never picked; an account that already applied is told so)
- [x] [#218 — MyGreenhouse as a discovery source: the poller signs in to Greenhouse's candidate portal (emailed code) and pulls its job search](https://github.com/CryptoJones/OSApplyTrack/issues/218) (shipped in 1.35.0: the `mygreenhouse` source, per tenant, signed in with the greenhouse.io board account by the security code read from the tenant's mailbox, the session kept sealed on the account row; keyword searches plus the newest postings, remote-only when the profile is)
- [x] [#216 — Account-only ATSs (SAP SuccessFactors): save the candidate's own board account and let the browser sign in and apply](https://github.com/CryptoJones/OSApplyTrack/issues/216) (shipped in 1.34.0: Settings · Agent · Board accounts with `GET/PUT/DELETE /api/board-accounts`, password write-only and sealed; the browser signs in when Apply lands on a password page whose host an account covers, opens folded accordion sections, counts a résumé already on the account as attached, presses SuccessFactors' Apply and answers the confirm dialog; no account → the run names the host and where to save one. 1.34.1: a picker the account already filled — Country, State, Education Type — is left alone and counts as answered, and the option finder honours aria-owns)
- [x] [#214 — SuccessFactors career sites: the agent takes the posting page's job-search and job-alert boxes for the application form, never clicks Apply now, and Apply leads to an account-only sign-in](https://github.com/CryptoJones/OSApplyTrack/issues/214) (fixed in 1.33.1: search and job-alert widgets are never form controls — not for "is the form on screen", not for discovery, not for the required-empty sweep; Apply that leads to SuccessFactors or Workday is reported as needing a signed-in candidate account, yours by Copy answers and open; `successfactors` is a known provider the browser never drives)
- [x] [#177 — Browser fill misses plain forms: labels-as-text never match, Ashby's single Name box and late-loading form, and a zero-field dry run reports as filled](https://github.com/CryptoJones/OSApplyTrack/issues/177) (fix in [#178](https://github.com/CryptoJones/OSApplyTrack/pull/178): the form is found in any frame, after it renders)
- [x] [#174 — Answer bank: every question the agent meets, with the answer it gave, editable in one place and reused next time](https://github.com/CryptoJones/OSApplyTrack/issues/174) (fix in [#175](https://github.com/CryptoJones/OSApplyTrack/pull/175): Settings · Answers)
- [x] [#251 — Apply that opens a menu (Gainwell) or a sign-up wall that renders slowly in a new tab (Alignerr) still reports "no form appeared"; FetchJobs.co is an aggregator chain](https://github.com/CryptoJones/OSApplyTrack/issues/251) (fixed in [#253](https://github.com/CryptoJones/OSApplyTrack/pull/253): an Apply menu item is clicked; an adopted tab is watched 30 s; `fetchjobs.co` is apply-by-hand in both runtimes. Statheros' "no Submit button found" predates the #246 deploy — re-run it)
- [x] [#252 — Pipeline: "last run failed" should say when the run was](https://github.com/CryptoJones/OSApplyTrack/issues/252) (fixed in [#253](https://github.com/CryptoJones/OSApplyTrack/pull/253): ready rows carry `last_at`, shown in the reader's clock)
- [x] [#255 — CI is failing on main](https://github.com/CryptoJones/OSApplyTrack/issues/255) (auto-filed for #253's E501 on a test line; fixed in [#254](https://github.com/CryptoJones/OSApplyTrack/pull/254))
- [x] [#264 — Pipeline: show item counts in the "Ready, not queued" and "Submit Queue" section headings](https://github.com/CryptoJones/OSApplyTrack/issues/264) (fixed in [#265](https://github.com/CryptoJones/OSApplyTrack/pull/265): each heading carries its own count as `: (N)`, read from the same `queue`/`ready` arrays the section renders from, so a heading cannot disagree with its list and an empty section reads `(0)`; sentence case kept, the zero case and the exact heading names pinned by test. Reviewed by the Flatline Roundtable — 13 of 14 lanes, the `mistralai/mistral-large-2512` lane being a dead route)
- [x] [#269 — Deduplicate opportunities across Lead, Ready, and Pipeline stages](https://github.com/CryptoJones/OSApplyTrack/issues/269) (fixed in 1.48.2 and 1.48.3: opportunities progress strictly from Lead to Ready to Pipeline without duplicate entries; canonical identity enforced via normalized URL and fallback company/role slug; poller avoids minting duplicate `-N` rows upon collision; migration `0034_dedupe_leads.sql` and `0035_dedupe_flow.sql` purge existing duplicate leads across stages and within lead; strict mutual exclusivity enforced between Ready and Pipeline views so unqueued ready packets do not duplicate in Pipeline; `bestjobtool.com` added to aggregator hosts)
- [x] [#274 — Ready: a packet whose last run failed is never retried, and a Ready row with no packet is never built — both sit forever](https://github.com/CryptoJones/OSApplyTrack/issues/274) (fixed in 1.48.4: a `ReadyReconciler` on every agent pass queues a prepare for a Ready row with no packet and another dry run for one whose last *dry* run died on something transient — a crash, a timeout, an Apply button that never answered — three failures per seven days, six hours apart. Never for a captcha, a sign-in wall, an unmapped required field, a provider the browser may not drive, a real run, or a submit whose confirmation was not recognised: the board may have taken that one, and a second application is worse than a parked one. New crash evidence carries `transient`, so the rule stops depending on wording)
- [x] [#393 — Errors view: a "Retry All" button at the top right](https://github.com/CryptoJones/OSApplyTrack/issues/393) (fixed in 1.66.0: a **Retry all** button on the Errors heading row queues a dry-run retry for every stuck row in one request through the Ready lane's bulk route, which now takes `dry_run: true` to force a dry run whatever *Dry run only* says — so the batch counts once against the submit rate limit and never makes a real click; what was queued and what was not, and why, is announced; focus returns to the heading)
- [x] [#394 — Due view: follow up agentically — the agent drafts (and optionally sends) the follow-up](https://github.com/CryptoJones/OSApplyTrack/issues/394) (fixed in 1.66.0: **Follow up** on each Due row and **Follow up on all** walk the follow-ups in one dialog — the tenant's model (any OpenAI-compatible endpoint, metered like any draft, no call at all with drafting off) writes a short email from the application and its dates, the contact, the cover letter, the notes log and the last messages the mailbox has exchanged with that contact; the person edits it, then **Send** mails it from their **own** account (new SMTP host/port on the mailbox settings, same username and sealed app password, host pinned to a public address like the IMAP side) — never the instance's sender — or **Mark followed up** logs one made elsewhere; either way it lands in the notes log and the date moves or clears. No contact on file: the row says so and the draft is still written, to copy. Unreviewed sending split out as #398)
- [x] [#401 — Mobile: Errors and Due tables overflow or crush on a phone — Retry cut off](https://github.com/CryptoJones/OSApplyTrack/issues/401) (fixed in 1.66.1)
- [ ] [#398 — Follow-ups: opt-in to let the agent send a drafted follow-up without review](https://github.com/CryptoJones/OSApplyTrack/issues/398)
- [x] [#408 — Agent: LinkedIn SDUI Easy Apply — the contact step's City typeahead is still left unmapped (SMX)](https://github.com/CryptoJones/OSApplyTrack/issues/408) (fixed in 1.67.0: the "City" was a Work-experience role editor LinkedIn opens on Next, not the contact City; the driver now answers what Next brings up, names each role's fields, and fills Description from the résumé. Found re-running the Ready lane after #406: `CityLabel()` matches and the résumé location is typed, but the typeahead is never committed on the SDUI dialog; the run stops at the contact step with `City` the only unmapped field)
- [x] [#410 — Agent: a packet's whole answer draft rides one LLM completion under the API's 120 s timeout — Orbis's 29-question form timed out twice and every answer was voided](https://github.com/CryptoJones/OSApplyTrack/issues/410) (fixed in 1.68.7: screening questions go to the model eight per completion, so a completion that stalls or errors voids only its own chunk and the chunks already drafted are kept; two failed completions in a row stop the draft and hand the rest to the person instead of waiting out another deadline per chunk. The worker's model calls run under their own `Agent__LlmTimeoutSeconds` (default 300; 0 inherits) rather than the api's request-sized `Llm__TimeoutSeconds`, so pluto's `Llm__TimeoutSeconds=600` override on the agent can come off. No `max_tokens` on the drafting call: a thinking model that writes its scratchpad into `content` would be cut off before the JSON, and the completer already tolerates that scratchpad)
- [x] [#409 — Agent: LinkedIn SDUI Easy Apply — Trility's dialog never reaches Submit within 12 steps](https://github.com/CryptoJones/OSApplyTrack/issues/409) (fixed in 1.67.0: the dialog really is 14 pages; the limit is 40 and a Next that changes nothing twice fails naming the page. Was: `mapped: 0, unmapped: []` — the driver presses Next twelve times on a step it cannot leave and reports nothing about it; needs the screenshot and the live dialog)
- [x] [#431 — Agent: a checkbox group is discovered as one Yes/No question per option (Fieldwire, Stripe) — fixed in 1.67.0: one multiselect titled by its legend; old split packets still fill](https://github.com/CryptoJones/OSApplyTrack/issues/431)
- [x] [#432 — Agent: a react-select question is discovered with no options, so drafted prose matches no choice — fixed in 1.67.0: menus are opened and read; prose is fitted to the option it means](https://github.com/CryptoJones/OSApplyTrack/issues/432)
- [x] [#433 — Agent: Greenhouse education picked from the résumé stays listed unmapped (Stripe) — fixed in 1.67.0](https://github.com/CryptoJones/OSApplyTrack/issues/433)
- [x] [#434 — Agent: an employer page with ?ashby_jid= is not followed to Ashby's form (Hercules) — fixed in 1.67.0](https://github.com/CryptoJones/OSApplyTrack/issues/434)
- [x] [#435 — Agent: a posting that renders its gone-notice late is reported as an unfillable form (Torentify, Fullstack) — fixed in 1.67.0: retired as closed](https://github.com/CryptoJones/OSApplyTrack/issues/435)
- [x] [#436 — Agent: page scripts cannot see a form drawn inside a shadow root (Manatal careers-page.com) — fixed in 1.67.0](https://github.com/CryptoJones/OSApplyTrack/issues/436)
- [x] [#437 — Agent: Flexhire's notice blocks the résumé button and its Filestack picker is never finished — fixed in 1.67.0](https://github.com/CryptoJones/OSApplyTrack/issues/437)
- [x] [#438 — Agent: phone numbers rejected — an international widget's dial code, and a résumé parse overwriting the box (Motion Recruitment, Robert Half) — fixed in 1.67.0](https://github.com/CryptoJones/OSApplyTrack/issues/438)
- [x] [#439 — Agent: a generic placeholder ("Enter") is taken for a field's label — fixed in 1.67.0](https://github.com/CryptoJones/OSApplyTrack/issues/439)
- [ ] [#440 — Agent: Manatal (careers-page.com) Radix selects and checkbox are not driven](https://github.com/CryptoJones/OSApplyTrack/issues/440) (Yo AI Labs: after #436 the résumé attaches and the hidden questions are found, but the Nationalities / years / salary Radix selects and the terms checkbox are custom widgets neither discovered nor driven)
- [x] [#443 — Agent: drive Workday's application wizard end to end](https://github.com/CryptoJones/OSApplyTrack/issues/443) (`BrowserCanSubmit` refused Workday, so #277's account recipe never got a run to fire in; NVIDIA JR2023533 was walked by hand on 2026-10-12. **1.68.0:** Workday is driven. The application starts at the manual flow (`…/apply/applyManually`, never the résumé autofill, whose parse explodes an "earlier roles" list into rows with no company); the way in presses Apply Manually and "Sign in with email" — the password box sits behind that choice — and the sign-in types the saved account key by key (a fill Workday sometimes never hears), presses the click_filter overlay beside the aria-hidden Sign In, waits for a complaint rather than a spinner, and loads the address again afterwards, because a sign-in from inside the flow draws "Something went wrong — please refresh". A step is read only once its controls have held still (the address and phone arrive a second after the names). Discovery and the sweep now see Workday's drop-down buttons (`aria-haspopup=listbox`, named by their label, their group, or — for a named button — the text above them), its search boxes (chips beside the input are the answer, committed with Enter), and radio groups required on their wrapper; the page header's own menus are never questions. The walk reads each step's menus so a question handed back carries its choices, ticks the terms box, attaches the résumé to the file input the packet never saw, declines a demographic drop-down Workday requires an answer to (never a guess), leaves a question the draft already answers alone, waits out the Save and Continue Workday disables while it autosaves, and hands back only starred fields when Next stays disabled. My Experience's Work Experience and Education rows are filled from the structured résumé (three jobs, every school: title, company, month/year spinbuttons, "I currently work here", description; school, Degree menu, Field of Study, years) and rows a draft already holds are completed, not duplicated. A long menu (Country) is typed at so the listbox jumps to the entry. The drafter's phone rule no longer answers "Phone Device Type" with the number. Verified live on nvidia.wd5 as a dry run: three rounds — page 2's rows and page 3's authorization pair handed back and answered — then every step to Review.)
- [x] [#446 — Agent role cannot write board_accounts: every judging pass has died since 2026-10-10](https://github.com/CryptoJones/OSApplyTrack/issues/446) (`agent_role.sql` granted the agent INSERT/UPDATE on its own tables but not on `board_accounts`, which it writes when it takes a Sign in now, keeps a renewed session, or saves an account #277 just made; `RenewSessionsAsync` runs before the judging pass, so once greenhouse.io's session came due the pass died on every tick and the Ready reconciler, the verdicts and portal renewal all stopped — the last `agent_events` row was 2026-10-09. **1.68.1:** the grant is re-applied on boot, and `DbRoleTests` proves the three writes and that a delete is still refused.)
- [x] [#448 — Account creator glances for Workday's Apply instead of waiting: finastra.wd3 gets "no Create Account form after Apply"](https://github.com/CryptoJones/OSApplyTrack/issues/448) (the first Workday run after #446, finastra.wd3 on 2026-10-13: `RegisterWorkdayAsync` pressed Apply and Apply Manually with a glance the instant the posting's HTML landed, and Workday draws Apply only once its script has run — ~8 s there — so nothing was pressed and no form came; step 1 of the flow is fetched after the flow page is drawn too, blank for ~7 s. **1.68.2:** each step of the way in is waited for — Apply, Apply Manually, then whichever of Create Account, Sign In or "Sign in with email" the tenant shows, with 30 s for the lazy step — and the fixture draws Apply and the form late so a glance fails the test.)
- [x] [#450 — Workday tenants that open step 1 on Create Account: the saved password is typed into Create Account and the sign-in is judged refused (finastra.wd3)](https://github.com/CryptoJones/OSApplyTrack/issues/450) (after #448 the creator made, proved and saved a finastra.wd3 account, and the re-run was "refused": Finastra's step 1 opens on Create Account — the same email and password boxes as Sign In plus Verify New Password and a "Sign In" link — so the saved account was typed into Create Account and the link pressed as the sign-in button, flipping the form empty. **1.68.3:** the way in waits up to 30 s for whatever step 1 shows, turns a Create Account over to Sign In through its link before any typing, and the same guard runs where the walk's own sign-ins start; a fixture variant that opens on Create Account, drawn late, signs in through the link and reaches Review.)
- [x] [#453 — Workday My Information hands back the same five fields every round (finastra.wd3)](https://github.com/CryptoJones/OSApplyTrack/issues/453) (on 1.68.3 the run signed in and filled the page, then handed back résumé, email, phone type, city and "How Did You Hear About Us?" on every round. Four causes, replayed from makemake: Workday's search box says "Search" in its placeholder and discovery read it as a site search and dropped it, while the sweep named it anyway; the sweep keyed a box by id and discovery by name, so City was unmapped under `address--city` and never drafted under `city`; Phone Device Type was discovered before its menu was read and the phone rule drafted the number; the email is drawn as text with no box. **1.68.4:** a Workday search box is a question and is typed into key by key before Enter; the sweep keys name then id as discovery does; an email already shown on the page is answered; the drafter answers the kind of phone (Mobile), where the posting was seen (LinkedIn, or the job-board option) instead of leaving it to the person, and the city and state of the résumé's location as their own boxes, with US abbreviations expanded for a fixed list.)
- [x] [#455 — Workday My Experience: the filler counts the saved rows before they are drawn and adds blank ones (finastra.wd3)](https://github.com/CryptoJones/OSApplyTrack/issues/455) (on 1.68.4 the first visit to My Experience filled three jobs and four schools; every visit after pressed Add Another again and handed back the blank rows, because a saved draft draws the block and its Add button half a second before its rows — more over pluto's proxy — and the filler counted none. Racing the late rows also put the wrong school and field on two education rows, and the search box took the first option ("Accounting") when nothing read as the field. **1.68.5:** a block's rows are waited for until their count has held still; held education rows are completed, never added to, as jobs already were; a search box with no match is left empty unless it is required; a date spinbutton that still disagrees after typing is cleared and typed once more.)
- [x] [#417 — Agent: a résumé dropzone whose wrapping label is only "Click or drag & drop PDF" is never recognised as the résumé (Ethos) — fixed in 1.67.0: dropzone text never names a file field; its row's title does](https://github.com/CryptoJones/OSApplyTrack/issues/417)
- [x] [#418 — Agent: an application form folded behind "Easy Apply" is never revealed (N-iX) — fixed in 1.67.0: "Easy/Quick/One-click Apply" open the form (N-iX itself then ends at a reCAPTCHA v2 handoff)](https://github.com/CryptoJones/OSApplyTrack/issues/418)
- [x] [#419 — Agent: a footer newsletter signup (mc4wp) is discovered as an application question (Tecbrains) — fixed in 1.67.0: newsletter forms are page widgets](https://github.com/CryptoJones/OSApplyTrack/issues/419)
- [x] [#420 — Agent: Lever's invisible hCaptcha holds Submit and nothing is posted — fixed in 1.67.0: a Submit that sends nothing on a form with a captcha widget is a captcha handoff; parseResume is uploader chatter](https://github.com/CryptoJones/OSApplyTrack/issues/420)
- [x] [#421 — Agent: Ashby's GraphQL submit answer is never read — fixed in 1.67.0: FormSubmitSuccess confirms, errors[].message is reported](https://github.com/CryptoJones/OSApplyTrack/issues/421)
- [x] [#422 — Agent: drafter: "hourly" matched the website rule's "url" — fixed in 1.67.0: "url" as a whole word; hourly rate is a salary question](https://github.com/CryptoJones/OSApplyTrack/issues/422)
- [x] [#423 — Agent: the Apply finder took English "Candidate …" for Apply (Ford) and missed Spanish "Postular" — fixed in 1.67.0](https://github.com/CryptoJones/OSApplyTrack/issues/423)
- [x] [#424 — Agent: a form whose Submit is a JavaScript anchor ends "no Submit button found" (Tential, JazzHR) — fixed in 1.67.0](https://github.com/CryptoJones/OSApplyTrack/issues/424)
- [x] [#425 — Agent: required-empty sweep ignored a starred label and a valued placeholder option (HubSpot) — fixed in 1.67.0](https://github.com/CryptoJones/OSApplyTrack/issues/425)
- [x] [#426 — Agent: Apply inside an embedded ATS iframe, or a second Apply on the page Apply led to, was never pressed (Voleon, 7seventy) — fixed in 1.67.0, plus noopener tabs no longer hang and ad vignettes are blocked](https://github.com/CryptoJones/OSApplyTrack/issues/426)
- [x] [#427 — Agent: expired / "no longer accepting profiles" / "taken offline" postings read as "no Apply button" — fixed in 1.67.0: reported as closed](https://github.com/CryptoJones/OSApplyTrack/issues/427)
- [x] [#428 — Agent: Cloudflare's block page read as "no Apply button" — fixed in 1.67.0: "the site refused this server"](https://github.com/CryptoJones/OSApplyTrack/issues/428)
- [x] [#429 — Agent: ClearCompany's returning-applicant email sign-in mid-wizard — fixed in 1.67.0: an email-only page is a sign-in, its Next is never pressed](https://github.com/CryptoJones/OSApplyTrack/issues/429)
- [x] [#430 — Agent: LinkedIn Easy Apply timed out behind the "Job search safety reminder" (KRON) — fixed in 1.67.0: "Continue applying" is pressed](https://github.com/CryptoJones/OSApplyTrack/issues/430)
- [x] [#416 — Agent: two controls under one label — OneStream's address Country lost to the phone's country picker](https://github.com/CryptoJones/OSApplyTrack/issues/416) (fixed in 1.66.6: when a locator matches several controls, the one whose id or name is the question's own id wins; a react-select reads empty after a choice, so "prefer the empty one" took the phone's picker every time.)
- [x] [#414 — Agent: a consent question that mentions the phone number gets answered with the phone number (Prenuvo SMS opt-in)](https://github.com/CryptoJones/OSApplyTrack/issues/414) (fixed in 1.66.6: the drafter's phone and email rules skip a label that asks for consent, opt-in or agreement, or names SMS, text messages, WhatsApp or subscribing; plain "Phone" / "Mobile phone number" still take the number.)
- [x] [#412 — Agent: LinkedIn SDUI years-of-experience text boxes refuse prose answers ("5+ years")](https://github.com/CryptoJones/OSApplyTrack/issues/412) (fixed in 1.66.5: the SDUI dialog draws "How many years …?" as a plain 20-character text box, so the #332 whole-number coercion keyed on `type=number` never fired and the drafter's "10+ years — …" was refused after Next. A label that asks "How many years" now gets the whole number its answer states, and "Less than 1 year" reads 0, not 1. Found on the 2026-10-05 Ready sweep: seven LinkedIn rows rebuilt on Claude answered every question and still dry-ran 0 mapped.)
- [x] [#406 — Agent: Ready rows stuck on failures the worker should have recovered from — DNS blip stamped non-transient, browser crash unflagged, LLM 429 hammered every tick](https://github.com/CryptoJones/OSApplyTrack/issues/406) (fixed in 1.66.4: "the posting's host could not be resolved" is a `TransientRunException`, retried once after two seconds and recorded as transient, so the reconciler retries it; a run the browser died under ("Target page, context or browser has been closed") is flagged `transient` by the submitter and read by those words from before; and a pass stops judging at the first `LlmUnavailableException` and holds the tenant off for 15 minutes instead of asking the next eleven leads every five minutes. The eight rows the fix could not reach — four stored `transient: false`, launchdarkly's crash, and three LinkedIn runs from before #403 — were queued again by hand.)
- [x] [#403 — Agent: LinkedIn Easy Apply times out on the new SDUI dialog (no .jobs-easy-apply-modal)](https://github.com/CryptoJones/OSApplyTrack/issues/403) (fixed in 1.66.2: the driver now also recognizes LinkedIn's SDUI `<dialog>`. It finds Next / Review / Submit application and Dismiss by their text or label, reads fields from `label[for]` / `aria-label` and radio options from the text beside each one, catches errors that appear only after a refused Next, unticks Follow by its aria-label, and answers the blank mobile-number box from the packet's standard phone. Checked against the live dialog on 2026-10-03.)

## Production audit — 2026-09-12, closed out 2026-09-13

Pluto ran **v1.25.5** at audit time with **102 dry runs, 26 failures, and zero
confirmed submissions**. Between 1.26.0 and **1.27.0** (all deployed the same night)
the chain was fixed end to end, each step found by reading the next production run:

1. **Required fields the packet never knew** (Greenhouse's Country picker, its
   react-select city and custom selects) and no check of the rendered form — 1.26.0.
2. Country on every Greenhouse packet, a geocodable city answer, a required cover
   letter entered as text, "Page not found" retired as closed — 1.26.1.
3. The click read the page in the quiet moment before reCAPTCHA and the POST even
   started; it now waits for the form's verdict and records the application request.
   Country pick-list spellings and combined eligibility questions drafted as the
   form accepts them — 1.26.2.
4. **The click itself**: the finder took the posting's "Apply" anchor, first in
   document order, never the form's Submit — 1.26.3.
5. **Greenhouse's captcha fallback**: a bot-scored submission is answered 428 and an
   emailed security code. The run parks in the same session, moos, and finishes once
   the human pastes the code (`POST /api/apps/{name}/security-code`) — 1.27.0.
6. Auto-apply gated per account by the operator's `agent_allowlist` — 1.27.0.

**First real submission: Aperia, 2026-09-13 04:37 CDT**, confirmed by Greenhouse and by
the employer's email. Greenhouse applications go one at a time: each parks for its
code for up to eight minutes. Follow-ups: [#159](https://github.com/CryptoJones/OSApplyTrack/issues/159)
(the api container could not queue a browser run, so the SPA's Submit and Prepare did
nothing on a stock deploy) — fixed in 1.29.0 by #172, the worker heartbeat; Eleventh
Hour needs a human answer to a required question.

Proudly Made in Nebraska. Go Big Red! 🌽 https://xkcd.com/2347/