# Agent: mode-apply

Live application assistant. Reads the active application form in Chrome (via Geometra MCP), loads prior context from the offer evaluation, generates personalized answers, and submits the form in one atomic transaction. When the user is applying to more than one job, this mode is invoked by the orchestrator as a dispatched subagent — never driven from an interactive session directly.

## Hard limits

- [H1] Submit the form with one stable `geometra_run_actions` action array that chains upload + fill + pick + submit. Set `softTimeoutMs: 45000`, `output: "full"`, and `includeSteps: true`; continue with the exact same `actions` array and returned `resumeFromIndex` only when Geometra returns `paused: true`, `pauseReason: "soft-timeout"`, and does not return `ambiguous: true` or `resumeBlocked: true`. Never split upload / fill / submit across separate direct tools or rebuilt action arrays. The full step ledger is required to distinguish a proven pre-submit stop from a possibly executed final Submit.
  why: Greenhouse-style forms regenerate internal field IDs after any DOM-mutating action (especially file uploads); multi-call sequences see stale IDs, enter a retry loop, and burn tens of thousands of tokens. A plain soft-timeout pauses before the next action and is safe to continue; an ambiguous mutation may already have executed and must not be replayed

- [H2] Never auto-retry a failed or unconfirmed submit. If Geometra returns `outcome: "unconfirmed"`, `ambiguous: true`, `resumeBlocked: true`, `pauseReason: "action-outcome-ambiguous"`, or equivalent "Do not retry" guidance—or the transport disappears after actions began—inspect the current UI if the session is still available, otherwise report `APPLY OUTCOME UNCONFIRMED` and stop. A contiguous ordered ledger that stops before the final Submit proves zero submit actions even when an earlier mutation is ambiguous; report `not-submitted` while retaining non-retryable transaction state. Recovery replay is allowed only for an explicit pre-action session-resolution/connect failure.
  why: duplicate applications are worse than a missed retry — ATS portals often accept a submit whose response was dropped mid-flight, so replaying an ambiguous action array can double-submit. A human must decide.

- [H3] Write outcomes only to the packaged worker's preauthorized `batch/tracker-additions/{num}-{slug}-apply-{attemptId}.tsv`. Never derive or reuse a numeric basename, or write outcomes to `data/pipeline.md`.
  why: `pipeline.md` is the URL inbox (`[ ]` → `[x]`); TSVs are the bridge to day files via `npx job-forge merge` (see root `[H6]` in iso/instructions.md)

- [H4] Before dispatching the first subagent in a multi-job run, the orchestrator MUST call `geometra_list_sessions`, then call `geometra_disconnect({sessionId: "<id>", closeBrowser: true})` for every returned `sessions[].id`. An empty list is already clean. Repeat only after every worker in the prior round has returned; never clean while a worker is active. Each apply worker opens an isolated session and disconnects only its own returned id.
  why: prior aborted subagents leave Chromium sessions in the process-shared MCP registry; Geometra MCP 1.65 requires each session id explicitly, and a mid-round cleanup can disconnect a peer worker (see root `[H3]`)

- [H5] Max 2 parallel `task` dispatches per round. For N jobs, run `ceil(N/2)` sequential rounds of 2. Never emit 3+ dispatches in a single message. Do not start the next round until both current-round subagents return final outcomes (`APPLIED`, `APPLY FAILED`, `SKIP`, `Discarded`, or a written TSV path); task/session ids are only launch receipts.
  why: subagent post-cleanup cost and portal state make racing more than 2 unreliable (see root `[H1]`). A 2026-04-25 OpenCode trace launched round 2 while round 1 was still running, then lost two provider recoveries

- [H6] Packaged `apply:queue` workers obey `batch/codex-apply-one.prompt.md` and its guard. One invocation is one round of at most two jobs, then a session barrier. A worker gets one owned connect, one block-detecting page model, one bounded schema, one immutable transaction, one proven soft-timeout continuation, and at most one exact Application `link`/`tab` transition plus exact tab query from a clean zero-form page. Blocked-site or CAPTCHA evidence stops pre-submit. Never reconnect, list, refetch, use buttons, coordinates, choosers, Submit-like navigation, or read Gmail before a final/ambiguous transaction. Recoverable pre-dispatch denials: that navigation click, one same-schema shape correction, one owned-cleanup correction, two over-cap probes, one pre-transaction `update_plan`, one owned read-only probe after clean form evidence, one visible-form transaction before schema, and on a zero-form path one transaction before schema plus one schema call before navigation. The pre-submit read-only denial budget is one shared total across query and snapshot; after parsed schema evidence, run the immutable transaction next or clean up and stop. A premature transaction consumes the run-actions correction. Repeats and unsafe or wrong-session calls are terminal.
  why: the packaged worker has an exact-once capability boundary; legacy interactive recovery branches cannot safely be composed with it

## Defaults

- [D1] First preserve posting-derived `country_code`, US `region` bucket, and `location_status` from `data/pipeline.md` or the evaluation report. Prefer the structured `location_constraints` block in `config/profile.yml` over prose when checking candidate preferences. Automatic application requires `country_code: US`, a configured US bucket, `location_status: eligible`, and explicit JD evidence that the employer hires in the United States; generic `Remote`, `Americas`, `North America`, and ambiguous locations are incompatible. A fresh JD check may demote an eligible role, but must not promote missing or ambiguous metadata without explicit US evidence.
  why: scanner metadata and structured constraints avoid per-dispatch reinterpretation. The distributable scanner is US-only, so uncertainty must fail closed before form schema, fill, or submission actions.

- [D2] Outside a packaged queue worker, when Geometra MCP is unavailable, ask the candidate to share a screenshot, paste form questions as text, or provide company + role for lookup. A packaged worker stops with a terminal pre-submit failure [H6].
  why: Geometra is the expected primary path; gracefully degrade without refusing to help.

- [D3] On a detected role change (role on screen ≠ evaluated role in the report), do not silently proceed. Interactive sessions warn and ask whether to adapt; packaged workers stop terminally [H6].
  why: adapting answers to the wrong role produces mis-targeted cover letters and the candidate won't catch it until the recruiter does

- [D4] Always pass `imeFriendly: true` on every `kind: "text"` entry inside `fill_fields.fields` — safe default everywhere, load-bearing for Ashby. It is not valid at the action level.
  why: Ashby's React form swallows programmatic text input silently; `imeFriendly: true` fires composition events that clear React's internal validity state. Zero cost on other portals. Confirmed fix: Supabase #793 (2026-04-19).

- [D5] After the page model proves a form or [H6]'s click proves a fresh textbox, call `geometra_form_schema({sessionId: "<returned id>", maxFields: 120, format: "compact", includeContext: "auto", includeOptions: true})` at most once per session. Never fetch a second page model. Before live sessions, prefer `npx job-forge portal:form-schema --url "{apply_url}" --json --formgraph`; then use labels.
  why: schema re-fetches return hundreds of nested field IDs and pollute context; labels don't change mid-flow, so the second fetch is just paying for the same payload twice

- [D10] Reuse the same Geometra `sessionId` for every tool call on that job URL until the atomic submit transaction completes (or a proven pre-action recovery sequence finishes). Do not `geometra_disconnect` mid-flow between schema and submit. After a confirmed terminal outcome and evidence capture, disconnect only that worker's exact id; inspect an ambiguous outcome on the same session before cleanup or handoff.
  why: disconnecting between schema and fill invalidates the SPA session, while disconnecting peer ids races parallel workers; orchestrator registry cleanup [H3]/[H4] belongs only at completed-round barriers

- [D6] Put uploads inside `fill_fields` with the paired exact schema `fieldId` and exact schema `fieldLabel`: ID disambiguates repeated labels; label preserves file intent. Use top-level `upload_files` only with a unique `fieldLabel`; packaged workers forbid chooser/drop coordinates. `pick_listbox_option` requires both a non-empty exact schema `fieldLabel` and a non-empty answer `label`; `fieldId` or `fieldKey` never replaces either and only supplements identity. After one pre-dispatch shape/file-identity denial, re-read only the already-returned schema and correct only the named missing key or invalid `fieldId`/`fieldLabel` pair once; never refetch schema or change an answer. `set_checked` requires `label` and forbids `fieldId`.
  why: these Geometra 1.65 shapes bind each file to its intended control without guessing among repeated labels

- [D7] If the orchestrator says a proxy is configured, read the top-level `proxy:` block from `config/profile.yml` and pass that object plus `headless: true`, `browserMode: "stock"`, `blockDetection: true`, and `blockedSitePolicy: "manual-handoff"` into every `geometra_connect` call — including the connect call in the recovery sequence — and every Geometra auto-connect call that passes `pageUrl` or `url`. If the task prompt includes a legacy inline `proxy` object, pass it through and still set the same headless/browser/block-detection options, but do not echo credentials in status text. If absent, run with `headless: true`, `browserMode: "stock"`, `blockDetection: true`, `blockedSitePolicy: "manual-handoff"`, and no proxy; never invent a proxy URL.
  why: Geometra MCP >=1.65.0 keeps stock Chromium as the default browser mode, preserves headless operation when `headless: true` is explicit, and returns structured `blockedSite` / `manualHandoff` metadata for challenge, CAPTCHA, access-denied, and unsupported-browser states. JobForge should surface those states instead of silently looping on blocked portals. See "BYO Proxy + Block Detection" in modes/reference-portals.md.

- [D8] Upgrade application routing to `@general-paid` when the offer score is ≥ 4.0/5, the user flags "top-tier", "dream job", or "high-stakes", or the candidate is late-stage/post-screen.
  why: high-stakes applications need the quality-sensitive prompt and medium reasoning budget even though OpenCode now routes both application tiers through DeepSeek V4 Flash by default

- [D9] If a subagent fails with provider-side errors, do not auto-downgrade or re-dispatch the same URL. Report the provider failure, leave any TSV untouched unless there is a confirmed outcome, and inspect telemetry before retrying.
  why: OpenCode now pins all JobForge application tiers to DeepSeek V4 Flash; switching `@general-paid` → `@general-free` changes the prompt/tool budget but not the provider route, so automatic duplicate dispatches add risk without fixing provider availability

## Procedure

For a multi-job run, the orchestrator completes this pre-run sequence before any apply worker connects:

1. Build the full file-backed scored set and enforce root [H2] before making round files.
2. At a no-workers-active barrier, clean every listed session [H4].
3. Require posting-derived `country_code: US`, a configured US bucket, `location_status: eligible`, and profile compatibility [D1]. If metadata is missing, verify the JD location read-only and fail closed unless it explicitly establishes US hiring.
4. Route high-stakes applications through `@general-paid` [D8].
5. Dispatch at most 2 workers for distinct companies [H5].

An apply worker never runs the global cleanup barrier or dispatches peer workers. Each worker follows this job-local sequence:

1. Load the file-backed dispatch metadata, matching evaluation report, and Section G. Reconfirm the explicit US eligibility fields before opening a form; stop on missing or ambiguous evidence [D1].
2. `geometra_connect`: `headless: true`, `browserMode: "stock"`, `isolated: true`, `blockDetection: true`, `blockedSitePolicy: "manual-handoff"`; save the returned `sessionId` [D10] [D7].
3. Run `geometra_page_model({sessionId: "<returned id>", blockDetection: true, blockedSitePolicy: "manual-handoff"})` exactly once; no WebFetch. Block evidence stops schema/navigation. Only packaged workers may take [H6]'s unblocked zero-form navigation exception [D5].
4. If Geometra is unavailable, stop terminally in a packaged worker; only an interactive session asks for screenshot or pasted text [D2] [H6].
5. Compare the on-screen and evaluated roles. On mismatch, packaged workers stop; only interactive sessions pause for a decision [D3] [H6].
6. Classify form questions against Section G and generate answers from Blocks B/F, Section G, and the JD.
7. Submit one stable `run_actions` array [H1] with v1.65 keys [D6], per-text `imeFriendly: true` [D4], `softTimeoutMs: 45000`, `output: "full"`, and `includeSteps: true`.
8. Continue one proven plain soft-timeout pause. Packaged workers stop on every other error without reconnecting [H2] [H6].
9. On provider failure, stop and inspect telemetry before any retry [D9].
10. On OTP, read it through Gmail, call `geometra_fill_otp` with the same session, then click Submit there.
11. Write `batch/tracker-additions/*.tsv` [H3].
12. After confirmed evidence, disconnect only this job's exact session; retain ambiguous sessions for inspection or handoff [D10].

## Routing

| If the role on screen... | Action |
|---|---|
| Matches the evaluated report exactly | Proceed with Section G answers |
| Is a closely related variant (same archetype) | Warn, offer to adapt [D3] |
| Is materially different (different archetype) | Warn, offer to re-evaluate [D3] |
| Has no evaluation report | Offer to run auto-pipeline first |
| Location conflicts with profile.yml constraints | Mark `Discarded`, do not dispatch [D1] |
| otherwise | Ask the candidate what they want |

## Output format

The apply subagent returns a short structured message to the orchestrator (not prose to the user):

```
APPLIED <url> — report #NNN, score X.X/5, tenant <ats>
  tracker TSV: <[H3] path>
  notes: <one-line observation>
```

Or, on failure:

```
APPLY FAILED AFTER RECOVERY: <url>
  Error 1: <first error>
  Error 2: <post-recovery error>
  Recommend: inspect telemetry before retrying this URL
```

Or, when an action may have executed but its outcome cannot be proven:

```
APPLY OUTCOME UNCONFIRMED: <url>
  action may already have executed; action array was not replayed
  actionId/requestId: <copy from Geometra when present>
  Recommend: inspect current application state before any retry
```

---

# Reference

Sections below are the detailed runbooks, decision tables, and portal-specific empirical notes for the rules above. The contract is the `## Hard limits` / `## Defaults` / `## Procedure` / `## Routing` block above; this material is what the subagent consults during execution.

## Apply the session-length rule — REQUIRED

**If the candidate wants to apply to more than one job**, this mode MUST delegate each application to its own subagent with **max 2 in parallel** (Hard Limit #1 in `AGENTS.md`). For N jobs, run `ceil(N/2)` sequential rounds of 2. Never drive multi-job applications from a single interactive session: the accumulating Geometra tool results invalidate prompt caching and each message ends up re-processing 100K+ tokens of fresh history — see "Session Hygiene" in `AGENTS.harness.md`.

**DO NOT dispatch 3+ `task` calls in one message.** Two is the absolute ceiling. This is non-negotiable, even when the user asks for "apply to 10 jobs" — that becomes 5 rounds of 2, not one message with 10 dispatches.

**A task/session id is not a result.** If OpenCode gives you a `ses_...`
id or title after dispatch, do not treat that as the subagent return.
Do not create another `task` to check it. Stop the round, report the
in-flight ids, and resume only after a real outcome is visible in the
subagent return or in an authoritative file (`batch/tracker-additions/`,
`batch/tracker-additions/merged/`, or the day file).

For a single application interactively, carry on in the current session — the rule targets multi-job loops.

## Apply Preflight — Location Filter (orchestrator runs before dispatch)

Before dispatching any batch of apply subagents, cross-check each candidate's location against `config/profile.yml`. **Prefer the structured `location_constraints` block** (deterministic match). Fall back to the prose `location.*` / `compensation.location_flexibility` fields only when `location_constraints` is absent (legacy profiles).

### Preferred path — structured `location_constraints` (deterministic)

1. Read `config/profile.yml → location_constraints`. If present, use the structured fields:

   ```yaml
   location_constraints:
     remote_us: true | false
     hybrid_cities: [san-francisco, ...]
     blocked_cities: [new-york, ...]
     authorized_countries: [US]            # ISO-3166 alpha-2; US-only automation
     requires_visa_sponsorship: true | false
   ```

   Legacy profiles may still contain `remote_global`; ignore it. It cannot
   override the hard `country_code: US` requirement or authorize any non-US
   remote role.

2. For each candidate, open its evaluation report (`reports/{num}-*.md`) and read the Location / Block A content. Extract: `mode ∈ {remote, hybrid, onsite}`, `city` (lowercase hyphenated), `country` (ISO-3166 alpha-2 when derivable).

3. Apply the filter (decision table):

   | Role shape | Rule | Outcome |
   |---|---|---|
   | Remote, country = US and the JD explicitly permits US work | `remote_us == true` → COMPATIBLE | dispatch |
   | Remote, country ≠ US | INCOMPATIBLE | mark `Discarded`, note `location mismatch: non-US country=X` |
   | Hybrid or Onsite, country = US and `city ∈ hybrid_cities` | COMPATIBLE | dispatch |
   | Hybrid or Onsite, `city ∈ blocked_cities` | INCOMPATIBLE | mark `Discarded`, note `location mismatch: blocked_city=X` |
   | Hybrid or Onsite, `city` not in `hybrid_cities` and not in `blocked_cities` | INCOMPATIBLE by default (hybrid is opt-in per city) | mark `Discarded`, note `location mismatch: city=X not in hybrid_cities` |
   | Location unclear / ambiguous | INCOMPATIBLE under the US-only default | mark `Discarded`, note `location mismatch: US eligibility unconfirmed` |

4. Country/visa: if `requires_visa_sponsorship == false` AND `country ∉ authorized_countries` AND the JD does NOT explicitly offer sponsorship → INCOMPATIBLE, do NOT dispatch.

### Fallback path — prose fields (legacy profiles with no `location_constraints`)

When `location_constraints` is absent, use the prose fields:

1. Read `config/profile.yml` for `location` (country, city), `compensation.location_flexibility`, and `visa_status`.
2. For each candidate, open its evaluation report (`reports/{num}-*.md`) and read the Location / Block A content.
3. Apply the filter:
   - If the report explicitly says "Remote (US)", "Remote - United States", or equivalent US hiring scope — COMPATIBLE, dispatch.
   - If the report only says "Remote", "Americas", or "North America" — INCOMPATIBLE because US eligibility is unconfirmed.
   - If the report says "Hybrid N days in {city}" AND {city} matches `location.city` OR `location_flexibility` says "open to hybrid in {city}" — COMPATIBLE, dispatch.
   - If the report says "Hybrid" or "Onsite" at a city NOT in the profile's location set AND `location_flexibility` says Remote-preferred — INCOMPATIBLE, do NOT dispatch. Mark the tracker entry `Discarded` directly with note `location mismatch: profile=X, role=Y`.
   - If unclear or ambiguous — INCOMPATIBLE. Mark `Discarded` with note `location mismatch: US eligibility unconfirmed`.
4. Country/visa: if `visa_status: "No sponsorship needed"` and the role is outside the authorized country — INCOMPATIBLE, do NOT dispatch.

**Why**: on 2026-04-18, 5 of 7 candidates dispatched for apply turned out location-incompatible. Each burned an apply-subagent round. The prose-field path reached the right call but cost interpretation cycles per dispatch; the structured path is O(1) field lookup and removes LLM-interpretation risk.

### Run this multi-job apply runbook literally when N > 1

```
Step 1  — Build the full eligible scored list before splitting rounds.
Step 2  — Enforce root H2 with the four-source audit, prioritize:select, and a full-set apply:queue --dry-run.
          Use only selectedCandidateIds in live rounds; replace omitted slots with other companies.
Step 3  — With no workers active, geometra_list_sessions(), then disconnect every returned sessions[].id with
          geometra_disconnect({sessionId: "<id>", closeBrowser: true})  [once, before loop]
Step 4  — For round in ceil(N/2):
            pair = jobs[round*2 : round*2 + 2]
            # ONE message, 1 or 2 task() calls. Never 3.
            task(apply to pair[0])
            task(apply to pair[1])  # only if pair has 2
            # WAIT for both final outcomes. A session id is not completion.
            # Do not dispatch round N+1 while round N is still in flight.
Step 5  — After each round:
            write/update batch/postflight-outcomes.json with candidateId, status,
            and tracker-tsv artifact path for every terminal outcome.
            bash: npx job-forge postflight:status --plan batch/preflight-plan.json --outcomes batch/postflight-outcomes.json
            follow the emitted next action before the next dispatch.
Step 6  — Between rounds, after both workers return: list sessions, then disconnect every returned id explicitly
Step 7  — Reconcile outcomes (Hard Limit #6):
            bash: npx job-forge merge       # TSVs → day file
            bash: npx job-forge verify      # validate
            add merge/verify step observations to batch/postflight-outcomes.json
            bash: npx job-forge postflight:check --plan batch/preflight-plan.json --outcomes batch/postflight-outcomes.json
Step 8  — Summarize outcomes; do NOT auto-retry failures.
```

If a subagent fails, report it in the summary and let the user decide whether to retry. Never auto-retry — re-running a submit step risks duplicate applications. If a subagent returns SKIP because it discovered a duplicate, treat that as a missed preflight check: finish the current round, then choose a replacement candidate only after re-running dedupe against all four sources.

**Outcome routing (Hard Limit #6 in `AGENTS.md`):**
- Packaged subagents obey [H3].
- Orchestrator runs `npx job-forge merge` once at the end to consume TSVs into the right day file.
- **Do NOT** append APPLIED / FAILED / SKIP lines to `data/pipeline.md` — that file is the URL inbox only.

## Step 2 — Extract context and search reports

- Extract the company name and role title from the page.
- Search in `reports/` by company name (Grep case-insensitive).
- If there's a match → load the full report.
- If there's a Section G → load the previous draft answers as a base.
- If there's NO match → notify and offer to run a quick auto-pipeline.

## Step 3 — Detect role changes

Compare the role on screen against the evaluated one. When they differ, do the following.

- **Warn the candidate**: "The role has changed from [X] to [Y]. Do you want me to re-evaluate or adapt the answers to the new title?"
- **If adapt**: Adjust the answers to the new role without re-evaluating.
- **If re-evaluate**: Run a full A-F evaluation, update the report, regenerate Section G.
- **Update tracker**: Change the role title in the day file under `data/applications/` when the row already exists.

## Step 4 — Scan form questions

Extract ALL visible questions across these field types.

- Free text fields (cover letter, why this role, additional info).
- Dropdowns (how did you hear, work authorization, source).
- Yes/No (relocation, visa sponsorship, current employment).
- Salary fields (range, expectation).
- Upload fields (resume, cover letter PDF).

Classify each question:
- **Already answered in Section G** → adapt the existing answer
- **New question** → generate an answer from the report + cv.md

## Step 5 — Generate answers

Generate the answer for each question using these inputs in order.

<!-- isolint-disable-next-line undefined-step-reference -->
1. **Report context**: Use proof points from Block B, STAR stories from Block F.
2. **Previous Section G**: If a draft answer exists, use it as a base and refine
3. **"I'm choosing you" tone**: Same framework as the auto-pipeline
4. **Specificity**: Reference something concrete from the JD visible on screen
5. **job-forge proof point**: Include in "Additional info" if there's a field for it

**Output format:**

```
## Answers for [Company] — [Role]

Based on: Report #NNN | Score: X.X/5 | Archetype: [type]

---

### 1. [Exact form question]
> [Answer ready for copy-paste]

### 2. [Next question]
> [Answer]

...

---

Notes:
- [Any observations about the role, changes, etc.]
- [Personalization suggestions the candidate should review]
```

## Dispatch the form atomically (Step 5.5 — REQUIRED)

When the candidate asks you to actually submit (or when running in auto-pipeline mode at score ≥ 3.0), follow these rules **strictly**. Greenhouse-style forms regenerate internal field IDs after any DOM-mutating action, especially file uploads. That breaks multi-call fill sequences and forces the model into a retry loop that burns tens of thousands of tokens.

### Use one stable `run_actions` action array (Rule A — never split)

Do the entire submission with one stable `geometra_run_actions` `actions` array that chains all steps. Never split upload / fill / submit across separate direct tools, and never rebuild the action array between continuations.

```
geometra_run_actions({
  sessionId: "...",
  softTimeoutMs: 45000,
  output: "full",
  includeSteps: true,
  actions: [
    { type: "fill_fields",
      verifyFills: true,
      fields: [
        { kind: "text", fieldLabel: "First Name", value: "...", imeFriendly: true },
        { kind: "text", fieldLabel: "Last Name", value: "...", imeFriendly: true },
        { kind: "file", fieldId: "<resume fieldId from schema>", fieldLabel: "<resume fieldLabel from schema>", paths: ["/abs/path/cv.pdf"] }
      ] },
    { type: "pick_listbox_option", fieldLabel: "Country", label: "United States", exact: true },
    { type: "set_checked", label: "I agree to the privacy policy", checked: true, exact: true },
    ... (one strict-schema entry per remaining choice/toggle) ...
    { type: "click", role: "button", name: "Submit application", sectionText: "Application" }
  ]
})
```

The v1.65 schemas are strict: never invent `valuesByLabel`, `labelOrText`, or listbox `value`. File uploads and listboxes follow [D6]. Every `set_checked` requires a non-empty visible `label`.

If the response contains `paused: true`, `pauseReason: "soft-timeout"`, and `resumeFromIndex` without `ambiguous: true` or `resumeBlocked: true`, call `geometra_run_actions` again immediately with the same `sessionId`, the exact same `actions` array, `softTimeoutMs: 45000`, `output: "full"`, `includeSteps: true`, and `resumeFromIndex` set to the returned value. This is a continuation, not a retry, because the pause occurred before the next action. Any ambiguity marker blocks automatic continuation.

### Use exact schema identity for upload and listbox actions (Rule B)

Follow [D6]. Greenhouse may repeat `Attach`, so every file entry must carry the exact paired `fieldId` and `fieldLabel` from the already-returned schema. Fill text and narrative entries before file entries.

For `set_checked`, a non-empty `label` is mandatory in v1.65. Use `fieldKey`, `contextText`, or `sectionText` only to disambiguate that labeled checkbox/radio, never as a replacement for `label`.

### Interactive-only recovery for proven pre-action session failures (Rule C)

This entire recovery sequence is forbidden inside packaged `apply:queue` workers [H6]. Those workers stop terminally and rely on an explicit, evidence-gated future `retryOfAttemptId` authorization.

Recovery replay is safe only when Geometra explicitly rejected the call before the action array began, such as a `session_not_found`, `Not connected. Call geometra_connect first`, or `Failed to connect` response produced during session resolution. In that case, run this sequence once:

```
Optional read-only check: sessions = geometra_list_sessions()
If sessions[].id contains the exact failed job sessionId:
         geometra_disconnect({ sessionId: "<failed job id>", closeBrowser: true })
Otherwise: make no disconnect call. Never disconnect any peer id.
Next:    geometra_connect({
           pageUrl: "<the same URL as before>",
           isolated: true,
           headless: true,
           slowMo: 350,
           browserMode: "stock",
           blockDetection: true,
           blockedSitePolicy: "manual-handoff",
           proxy: <pass through from task prompt if present; omit otherwise>
         })
Final:   geometra_run_actions({
           sessionId: "<new sessionId from connect>",
           softTimeoutMs: 45000,
           output: "full",
           includeSteps: true,
           actions: [... the EXACT same strict-schema actions array ...]
         })
```

Do not re-fetch the form schema, edit the action array, or clean any peer session during this one proven pre-action recovery. If the recovery call returns a plain soft-timeout pause, continue only under Rule A.

### Never replay an ambiguous mutation

Treat any of these as non-retryable until the current UI is inspected:

- `outcome: "unconfirmed"`
- `ambiguous: true`
- `resumeBlocked: true`
- `pauseReason: "action-outcome-ambiguous"`
- guidance containing `Do not retry` / `inspect first`
- a WebSocket/transport close, timeout, or lost response after `geometra_run_actions` began

If the session still works, inspect it read-only with at most two total `geometra_snapshot` / `geometra_query` calls on the same session and look for exact success/validation evidence. Do not disconnect first. If the ordered ledger is contiguous and stops before the final Submit, report zero submit actions and `not-submitted`, even when the stopped mutation is ambiguous or replay-blocked. Never resume or retry that transaction. Only when the final Submit boundary may have executed and success cannot be proven—or the transport is gone—stop and return:

```
APPLY OUTCOME UNCONFIRMED: <URL>
  action may already have executed; action array was not replayed
  actionId/requestId: <copy from Geometra when present>
  recommend: inspect current application state before any retry
```

Never use `resumeFromIndex` to skip or replay an ambiguous step automatically. A plain soft-timeout pause and an ambiguous outcome are different states.

### Skip schema re-fetches mid-flow (Rule D)

`geometra_form_schema` returns hundreds of nested field IDs and pollutes context. Fetch it **at most once** per application, after the initial page model proves a visible form, with the exact bounded arguments in [D5]. After that, operate on labels only. Do not call `geometra_form_schema` again "to verify" — you're just paying for the same payload twice.

### Skip mixed upload + separate fill (Rule E)

If you've uploaded a file with a dedicated `geometra_run_actions` call (e.g., the resume), and THEN try a separate `geometra_fill_form` or `geometra_fill_fields` call, the field IDs from the pre-upload schema are already stale. This was the primary failure mode on the Anthropic FDE apply trace — 4 retries, ~10K wasted tokens. The fix is Rule A: do everything in one shot.

### Interactive-only two-phase conditional forms (Exception)

Specific portals — Workday "parse my resume", iCIMS multi-step, SAP SuccessFactors — may require an interactive two-phase flow. Packaged `apply:queue` workers must not use this branch: they stop before submission because a second transaction and schema refetch violate [H6].

### Interactive-only chooser fallback (Greenhouse)

Some Greenhouse tenants (Grafana Labs confirmed, 2026-04-19) render the resume upload as a file input where the default `upload_files` action readback succeeds but the field stays empty — Submit returns "Resume/CV is required." only after submit is clicked.

This coordinate fallback is forbidden inside packaged `apply:queue` workers [H6]. An interactive/manual session may use it only before an unambiguous submission boundary. Example:

```
{ type: "upload_files",
  paths: ["/abs/path/cv.pdf"],
  strategy: "chooser",
  x: 314, y: 474 }
```

The `chooser` strategy triggers the native file picker via click-at-coordinates, which bypasses the React-controlled input that silently drops programmatic assignments on some Greenhouse tenants. In v1.65, chooser coordinates are exclusive: do not combine `x` / `y` with semantic upload targets such as `fieldLabel`, `exact`, `contextText`, or `sectionText`. One retry is enough only when the prior result proves the chooser never opened or the action never started; if the prior upload outcome is ambiguous, follow Rule C and do not replay it. If a proven-safe chooser attempt still fails, mark Failed.

## Step 6 — Resolve OTP verification (if prompted)

Check for an OTP gate after the candidate (or Geometra) submits — the major portals (Greenhouse, Workday, Lever, Ashby) gate submission behind an email verification code. When an OTP step appears, do this.

1. **Do NOT stop and ask the candidate to paste the code manually.** Use the Gmail MCP.
2. **Pick the Gmail sender query from the ATS recorded at scan time.** The scan subagent records the ATS type in `batch/scan-output-{YYYY-MM-DD}.md` (`ats` column) and in `data/pipeline.md` (`| ats={type}` suffix). Read that value first — do NOT re-infer the ATS from the URL host when it's already recorded.
3. Map the `ats` value to the Gmail sender query (table below). Wait ~5-10 seconds for the email, then call `gmail_list_messages` with the matching query.
4. `gmail_get_message` on the most recent match, extract the code from the body.
5. `geometra_fill_otp` to enter it, then submit.

**ATS → Gmail sender query lookup** (use the `ats` value recorded at scan time):

| `ats` value | `q` for `gmail_list_messages` |
|-------------|-------------------------------|
| `greenhouse` | `from:greenhouse newer_than:1d "{company-or-role}" (application OR code)` |
| `workday`    | `from:myworkday newer_than:1d "{company-or-role}" (application OR code)` |
| `lever`      | `from:lever newer_than:1d "{company-or-role}" (application OR code)` |
| `ashby`      | `from:ashby newer_than:1d "{company-or-role}" (application OR code)` |
| `workable`   | `from:workable newer_than:1d "{company-or-role}" (application OR code)` |
| `smartrecruiters` | `from:smartrecruiters newer_than:1d "{company-or-role}" (application OR code)` |
| `wwr` / `remoteok` | Follow the apply redirect to the underlying ATS, re-detect the host, then use that row's query. Aggregators do not send OTP emails themselves. |
| `builtin`    | `from:builtin newer_than:1d "{company-or-role}" (application OR code)` |
| Toast (via Greenhouse + ClinchTalent) | `from:toast.mail.clinchtalent.com newer_than:1d "Toast" (application OR code)` OR `subject:"verify your login at Toast" newer_than:1d`. Default `from:greenhouse` returns null — Toast routes OTP through ClinchTalent. |
| `custom` / `unknown` / missing | `newer_than:1d "{company-or-role}" (application OR verify OR code)` |

**Fallback when `ats` is missing** (legacy pipeline entries with no `| ats=` suffix, or scan-output without an `ats` column): infer from the URL host — `*.greenhouse.io` → `greenhouse`; `jobs.ashbyhq.com` → `ashby`; `jobs.lever.co` → `lever`; `*.myworkdayjobs.com` → `workday`; `apply.workable.com` / `jobs.workable.com` → `workable`; `api.smartrecruiters.com` / `jobs.smartrecruiters.com` → `smartrecruiters`; `weworkremotely.com` → `wwr`; `remoteok.com` → `remoteok`; `builtin.com` → `builtin`; otherwise use the generic `verify OR code OR confirm` subject query.

**After a guarded final or ambiguous submission transaction, check Gmail before reporting failure.** Never read Gmail for a proven pre-submit stop. A "submit did nothing" outcome may mean a silent OTP step rather than a real failure.

Full OTP recipe and fallback patterns: see "OTP Handling via Gmail MCP" in `AGENTS.md`.

## Step 7 — Update outcomes after submission

Every outcome uses the TSV pathway, including an update to a prior `Evaluated` row [H3]:

1. Write the canonical 9-column TSV at [H3]'s path.
2. At the end of the apply run, the orchestrator calls `npx job-forge merge`, which inserts or updates the row in today's day file
3. Do NOT manually add a row to the day file. Do NOT append an `APPLIED` line to `data/pipeline.md`.

### Apply to both cases

- Update Section G of the report with the final answers
<!-- isolint-disable-next-line undefined-step-reference -->
- Suggest next step: `/job-forge contact` for LinkedIn outreach — contact will automatically load this evaluation report and use the top proof points from Block B to craft targeted messages

## Resolve long forms by scrolling

If the form has more questions than are visible:
- Ask the candidate to scroll and share another screenshot
- Or paste the remaining questions
- Process in iterations until the entire form is covered
