# Session 2026-06-07 (cont.) — Phase 5: apply adapters with safety rails

## What's live
- `applications` table in MySQL (job_id, profile_id, resume_id, applier, confirm_token + expiry, status, payload_path, etc.)
- `appliers/` subpackage with the same plugin shape as `scrapers/`:
  - `base.py` — `AbstractApplier`, `ApplyPlan`, `DryRunResult`, `SubmitResult` dataclasses
  - `helpers.py` — confirm-token issue/validate, daily submit cap, payload persistence
  - `greenhouse.py` — multipart POST to `boards-api.greenhouse.io/v1/boards/{token}/jobs/{job_id}`
  - `lever.py` — multipart POST to `jobs.lever.co/{site}/{posting}/apply`
  - `registry.py` — `detect_for_source`, `detect_for_url`, `get(name)`
  - `cover.py` — simple template cover letter generator
- 6 new MCP tools: `plan_apply`, `dry_run_apply`, `submit_apply`, `list_applications`, `cancel_application`, plus existing scoring tools

## Safety rails baked in
- **Hard rule**: `submit_apply` requires a `confirm_token` produced by `dry_run_apply` in the last 30 minutes. Compared with `secrets.compare_digest`. Cannot be skipped.
- **Hard rule**: `plan_apply` returns `can_auto_apply=False` when:
  - the ATS posting has any custom required questions (verified live on Airbnb — 18 required custom questions, applier refused)
  - the profile is missing email or full_name
  - no resume is registered for the profile
- **Hard rule**: `assert_under_daily_cap()` enforces max 10 real submits / 24h
- **Dry-run is read-only on the network** — only writes a JSON file to `data/applications/<id>.json` and a `Application` DB row in `dry_run` state. Never hits the ATS.
- **Token clears after submit** — `confirm_token` is set to NULL on success/failure so the same token can't be reused

## Verified live
Dry-run pipeline against a real Greenhouse posting (Airbnb job id=33):
- `plan_apply` correctly identified the Greenhouse ATS, returned the correct POST URL
- Detected 18 custom required questions → `can_auto_apply=False` (correct)
- `dry_run_apply` saved payload JSON + cover letter to disk, issued confirm_token
- `cancel_application` cleared status + token
- **Zero network traffic to Greenhouse during the test** — verified by application status only changing within our DB

## Where it stands

| Adapter | API endpoint | Status |
|---|---|---|
| greenhouse | `boards-api.greenhouse.io/v1/boards/{tok}/jobs/{id}` | ✅ plan + dry_run live; refuses when custom Qs present |
| lever | `jobs.lever.co/{site}/{posting}/apply` | ✅ plan + dry_run wired (not exercised live yet) |
| workday | per-tenant Playwright | not yet — Phase 6 |
| generic | LLM-driven form fill via Playwright | not yet |

## How a real submit works (when Michael decides to)

```
# Inside Claude Desktop:
top_jobs min_score=0.4 limit=20            # pick a job from the queue
plan_apply job_id=42                       # check if auto-apply is possible
dry_run_apply job_id=42                    # build payload, get confirm_token
                                            # → review form/cover in chat
submit_apply application_id=N confirm_token="<token>"
```

The cover-letter generator is template-based right now. Michael can override
`cover_letter=` directly in `dry_run_apply` to paste a tailored one.

## Open / next
- Real resume PDF — `data/resumes/_dummy.pdf` is a 1-line stub. Upload an actual PDF via scp to `data/resumes/` then call `register_resume` from MCP
- Profile real values — current sample profile has generic Python skills, salary $120k/$160k. Call `set_profile` with the actual targets
- Application status polling — eventually wire to the Gmail MCP to detect confirmation emails and set `status=confirmed`
- Workday + generic LLM-driven appliers (next phase)
- Daily scheduled scrape + score — APScheduler job in `worker/scheduler.py` (empty), runs nightly so morning queue is pre-ranked
- More sources from `docs/ADD_SOURCE.md` (Wellfound, Ashby, SmartRecruiters, RemoteOK)
