# Agent–dashboard contract

This document is the **machine-oriented** contract between Command Center (the static dashboard) and any **automation** that fills your job pipeline: Hermes, n8n, Google Apps Script, a custom worker, etc. For step-by-step setup stories, see [SETUP.md](SETUP.md) (Hermes, OAuth, deploy).

## How to read this (two interfaces)

The “contract” is **not** one thing — it is **two separate agreements** that happen to connect through the same product:

| Interface                 | What it is                                                         | Who implements it                                                                        |
| ------------------------- | ------------------------------------------------------------------ | ---------------------------------------------------------------------------------------- |
| **A — Pipeline sheet**    | Shape of rows on the **Pipeline** tab (columns A–Q, optional R–T). | Anything that **writes** Google Sheets: your agent, Apps Script, n8n.                    |
| **B — Discovery webhook** | JSON **POST** when the user clicks **Run discovery** (optional).   | **Your** HTTPS endpoint; the dashboard only **sends** this; it does not host a receiver. |

You can implement **A only** (cron job that appends rows) and never touch **B**. You can implement **B** that triggers a job which then does **A**. The dashboard does not care _how_ rows appear, only that they match **A** when they show up.

**Machine-readable Pipeline row (Interface A):** [schemas/pipeline-row.v1.json](schemas/pipeline-row.v1.json) — column letters, header labels for row 1, and enums where the UI constrains values (Status, Priority, “Did they reply?”). CI asserts this file matches [README.md](README.md) Sheet Structure and the status/priority lists in [`app.js`](app.js).

**Machine-readable webhook shape:** [schemas/discovery-webhook-request.v1.schema.json](schemas/discovery-webhook-request.v1.schema.json) (JSON Schema for `schemaVersion` **1**). Example bodies: [examples/discovery-webhook-request.v1.json](examples/discovery-webhook-request.v1.json) (minimal, lets the worker use stored profile state) and [examples/discovery-webhook-request.v1-with-profile.json](examples/discovery-webhook-request.v1-with-profile.json) (profile, snapshot, search plan, and optional per-run company filters filled).

---

## Pipeline tab (integration surface)

- The dashboard reads **only** the **Pipeline** sheet tab; other tabs are ignored.
- **Required columns A–Q** (see [README.md](README.md) — Sheet Structure). Optional **R–T** extend reply tracking and company logos.
- **Row identity (dedupe):** Automations should treat **column E (Link)** as the stable key when avoiding duplicate roles. Before appending a row, if a row with the same job URL already exists, **update** that row (e.g. refresh fit score, date found) instead of inserting a second line for the same posting.
- **Append:** New discoveries are **new rows** below the header, following the column order the README documents.
- **Re-discovery merge (column ownership):** each column in `schemas/pipeline-row.v1.json` declares `discoveryMerge`. `overwrite` (E Link, H Fit Score, T Logo URL, U Match Score): discovery replaces the cell. `lockable` (B Title, C Company, D Location, G Salary): discovery replaces it unless the row's Edit Lock (Y) names the field. `fillIfEmpty` (A, F Source, I Priority, J Tags, K Fit Assessment, L, M, Q Talking Points, V, W, X): discovery writes it only while it is empty, so a user's value is kept. `preserve` (N, O, P, R, S, Y): discovery never writes it. The worker writes only the cells that change, never the whole row.
- **Concurrency:** the worker serializes its own writes per Sheet, re-checks each target row's Link right before writing (a row that moved is found again by Link; one that vanished is skipped with a warning), and re-reads column E before appending so a URL another writer added in between is not appended twice. Agents that write the Sheet directly should do the same: address rows by Link, not by a remembered row number.
- **Text, not formulas:** the worker writes `USER_ENTERED` values and prefixes a `'` to any text cell that starts with `=`, `+`, `-` or `@`, so posting text or model output is stored as text. Agents writing untrusted text should do the same, or write with `RAW`.

### Recommended values (agents)

These keep the UI and filters predictable. Other text usually still **displays**, but may not match dropdowns or filters.

| Column              | Letter | Suggested values                                                                                                               |
| ------------------- | ------ | ------------------------------------------------------------------------------------------------------------------------------ |
| **Status**          | M      | `New`, `Researching`, `Applied`, `Phone Screen`, `Interviewing`, `Offer`, `Rejected`, `Passed`, `Expired` (case-insensitive for matching) |
| **Priority**        | I      | `🔥` (hot), `⚡` (high), `—` or empty (normal), `↓` (low) — see template / README                                              |
| **Fit Score**       | H      | Number **1–10** or empty                                                                                                       |
| **Did they reply?** | S      | `Yes`, `No`, or `Unknown` (optional column)                                                                                    |
| **Logo URL**        | T      | Company logo image URL (optional; discovery agents auto-populate, dashboard derives fallback from job Link domain when empty)  |

Hermes, n8n, or your agent **writes** these cells; the dashboard **reads** them and supports **manual write-back** (status, notes, etc.) when the user signs in with Google.

### Expired job cleanup agents

Expired cleanup is a safe move, not deletion: write `Expired` to column M only when the posting is confirmed closed, and append an audit line to Notes with timestamp, previous status, checked URL, evidence, confidence, and source. Column E remains the row identity. Cleanup agents should default to blank/New/Researching rows; Applied, Phone Screen, Interviewing, Offer, Rejected, Passed, and already Expired rows are protected unless a human deliberately handles them. HTTP 403, captchas, timeouts, network failures, and ambiguous pages must be reported as needs-review/unknown, not auto-expired.

The worker's cleanup writes one cell set per expired row: Status (M) `Expired`, Follow-up Date (P) cleared, and an audit line appended to Notes (O), the same set `/pipeline-update` writes for `stage: "Expired"`. It checks four postings at a time, writes every 25 rows (a pass killed by the scheduler keeps what it wrote), skips rows whose Notes carry a `[JobBored YYYY-MM-DD]` check from the last 7 days, and re-reads each row by Link right before writing: a row a user moved out of New/Researching during the pass is left alone (`status_changed`), and a row that vanished is skipped (`row_moved_or_removed`).

Scheduled expired cleanup is separate from scheduled discovery refresh. Its default mode is dry-run and its logs/report counts must make checked, open, needs-review, skipped, and would-expire outcomes clear. Automatic writes require explicit `--write`.

The dashboard surfaces review work through one top-bar review control and a single modal. Do not add per-card expired-review badges; the modal lists the active postings to check and links directly to each job listing.

---

## Manual “Run discovery” (browser → your webhook)

When the user clicks **Run discovery** and a **discovery webhook URL** is configured (`discoveryWebhookUrl` in config or **Discovery drawer → Connection**), the dashboard runs [`triggerDiscoveryRun`](app.js) in the browser:

1. **POST** `Content-Type: application/json` to the configured URL.
2. Body shape: see **Discovery webhook JSON** below (includes `schemaVersion`, optional `discoveryProfile`, and optional per-run `companyAllowlist`).
3. The automation should **enqueue or run** a search pass (use `variationKey` to vary queries and reduce duplicate leads).
4. When the job finishes, new or updated rows appear in **Pipeline**; the dashboard refreshes on its normal cadence (or the user refreshes).

There is **no** Command Center backend: the browser talks **directly** to **your** HTTPS endpoint. That endpoint must allow **CORS** from your dashboard origin (`Access-Control-Allow-Origin` reflecting the request origin or your site URL). See [SETUP.md](SETUP.md).

### Webhook receiver checklist (copy-paste)

Use this when wiring **any** HTTPS handler (Apps Script, Cloudflare Worker, n8n HTTP node, your own server):

- [ ] **HTTPS** URL (browser `fetch` will reject plain `http` except on localhost during dev).
- [ ] **POST** with **`Content-Type: application/json`** body matching [Discovery webhook JSON](#discovery-webhook-json) (validate offline with [examples/](examples/) + the [JSON Schema](schemas/discovery-webhook-request.v1.schema.json)).
- [ ] **CORS** for browser-originated requests: respond with a permissive **`Access-Control-Allow-Origin`** for your dashboard (reflect the request `Origin` header, or set your deployed site URL). Without this, the dashboard shows a network/CORS error.
- [ ] **OPTIONS** (preflight): if your stack does not auto-handle it, respond to **`OPTIONS`** on the same path with **`204`** (or **200**) and the same CORS headers as **`POST`** (`Access-Control-Allow-Methods`, `Access-Control-Allow-Headers: content-type`, etc.). Many platforms handle this for you.
- [ ] **Relay auth (Cloudflare relay from `npm run cloudflare-relay:deploy`):** every request, including the `GET /runs/<id>` status poll, carries the per-dashboard relay token as **`Authorization: Bearer <RELAY_TOKEN>`**; the relay answers **`401`** without it and forwards only the dashboard's worker routes. The token lives in `.jobbored-relay/credential.json`; the relay `curl` and `npm run test:discovery-webhook` commands in [examples/README.md](examples/README.md) show the header and send it only to the relay URL, never to another receiver (the POST body and `schemaVersion` 1 are unchanged).
- [ ] **2xx** on success: return **HTTP 200–299** when the job is **accepted** (queued or started). Non-2xx surfaces an error toast in the dashboard.
- [ ] **Async status polling:** if your response includes `statusPath`, browser clients must preserve that returned path exactly, including query parameters. Hosted Browser Use workers may return `/runs/<runId>?statusToken=...`; stripping or rebuilding the path will break authorized `/runs/:runId` polling.

Changes to request fields are tracked in **[docs/CONTRACT-CHANGELOG.md](docs/CONTRACT-CHANGELOG.md)**.

---

## Discovery webhook JSON

### Minimum response

- Your endpoint should return **HTTP 2xx** if the discovery job was **accepted** (queued or started). Non-2xx shows an error toast in the dashboard.
- Async receivers may return `{ "ok": true, "kind": "accepted_async", "runId": "...", "statusPath": "/runs/...", "pollAfterMs": 2000 }`.
- Treat `statusPath` as an opaque browser polling path. Preserve it exactly when storing, relaying, or polling; do not reconstruct it from `runId` unless the receiver omitted `statusPath` and the client is talking to a known compatible worker.
- In hosted Browser Use worker mode, `GET /runs/:runId` is authorized by either the `statusToken` embedded in the returned `statusPath`, an `x-run-status-token` header, or the full webhook secret. The status token is a bearer credential for that run's status only; do not log it raw.

### Request body (v1)

| Field               | Type     | Description                                                                                                                                            |
| ------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `event`             | string   | Always `command-center.discovery`.                                                                                                                     |
| `schemaVersion`     | number   | `1` for this contract.                                                                                                                                 |
| `sheetId`           | string   | Target spreadsheet ID. May be empty or omitted for a local-mode worker, which then uses the `sheetId` in its worker config; a hosted worker requires it.          |
| `variationKey`      | string   | Non-blank string (any length); use as a seed for query variation.                                                                                      |
| `requestedAt`       | string   | RFC 3339 / ISO 8601 date-time (`2026-09-25T10:00:00.000Z`). Other date strings get `400`.                                                              |
| `discoveryProfile`  | object   | Optional. User preferences from the dashboard (see below). Omitted keys or empty values mean “no preference”.                                          |
| `trigger`           | string   | Optional origin label: `manual`, `scheduled-browser`, `scheduled-local`, `scheduled-github`, `scheduled-cloudflare`, `scheduled-appsscript`, or `cli`. |
| `companyAllowlist`  | string[] | Optional. Per-run company subset selected from the dashboard. Omitted or empty means use the stored company list exactly as before. Capped at 500 entries. Resolved against the stored company catalog; unknown-only lists do **not** silently broaden to unrestricted search unless `allowUnrestrictedFallback` is true. |
| `companyBlocklist`  | string[] | Optional. Non-empty array of trimmed company names/keys to suppress from results. Capped at 50 unique entries. Subtracted from both ATS and normal company pools after skip/allowlist filtering. |
| `googleAccessToken` | string   | Optional. Short-lived dashboard Google OAuth token for this run only; receivers must not persist it.                                                    |
| `mergedUserProfile` | object   | Optional. Master Fit Profile merged with per-run overrides (non-secret; no raw resume text). The worker parser preserves it, strips resume/secret keys, and uses it for this run after ajv validation. Invalid payloads are ignored and the worker falls back to its disk profile. Never persisted. |
| `allowUnrestrictedFallback` | boolean | Optional. Explicit confirmation that an unmatched `companyAllowlist` may fall back to unrestricted stored-company search. When the stored **active** company list and history are empty (typical post-wizard local install), that fallback seeds this run from the requested allowlist names instead of searching with a blank company. Omitted/false fails closed. |
| `idempotencyKey`    | string   | Optional, contract **v1.1**. A key the caller stamps once per user action (one click, one scheduler slot); non-blank, at most 200 characters. When present the worker derives the run id from `sheetId` + `idempotencyKey` (instead of `variationKey` + `requestedAt`), so a retried or double-sent request with the same key answers with the original run's `runId`/`statusPath` and never starts a second run. Omit it for v1 behavior. |

**`discoveryProfile` fields (all optional):**

| Field             | Type   | Description                                    |
| ----------------- | ------ | ---------------------------------------------- |
| `targetRoles`     | string | Titles or roles to target (free text).         |
| `locations`       | string | Cities, regions, or countries.                 |
| `remotePolicy`    | string | e.g. remote-first, hybrid, on-site.            |
| `seniority`       | string | e.g. mid, senior, staff.                       |
| `keywordsInclude` | string | Comma-separated or free text to bias toward.   |
| `keywordsExclude` | string | Terms to avoid.                                |
| `maxLeadsPerRun`  | string | Suggested cap as decimal string (e.g. `"15"`). |
| `groundedWebEnabled` | boolean | Optional per-run grounded-web opt-out. `false` is authoritative in effective-source resolution and excludes `grounded_web` even when the source preset would include it. |
| `profileSnapshot` | object | Optional non-secret metadata proving the current profile/resume/preferences/schedule snapshot used for the run. Raw resume text is not included. |
| `searchPlan`      | object | Optional deterministic daily query/facet bundle. When `searchPlan.query` is present, the worker uses those query fields for this run while preserving the broader profile for observability. Counts as intent for blank-intent guards. |

Effective intent is one object (`intentContractVersion: 1`) derived from `discoveryProfile` fields, `searchPlan.query`, `profileSnapshot`, and `mergedUserProfile.identity`. Master-profile or search-plan roles/keywords are not `blank_intent`.

`companyAllowlist` is ephemeral. It restricts only the current run to matching stored company/history entries after skipped-company filtering. Unknown keys are reported; if none match the catalog, the run stays `blocked_unresolved` (empty pools, no unrestricted grounded-web fallback) unless `allowUnrestrictedFallback` is explicitly true. When that flag is true and the stored **active** list plus history are empty (local wizard installs ship `companies: []` and only example ATS seeds), the worker seeds this run from the requested allowlist names so grounded/ATS scout has real company targets. The worker never writes this field back to `worker-config.json`.

`companyBlocklist` is applied after skip + allowlist filtering and subtracts matching companies from both the normal and ATS pools.

Runtime ATS memory/host-search seeds and final deduplicated leads are re-filtered before write selection. This keeps per-run company restrictions effective for sources created after config merge and for profile-wide lanes such as SerpApi. Shared multi-tenant ATS hosts are never treated as company identity at the write boundary; the lead must still match an allowed company name, key, or alias.

**Validation (worker).** The Browser Use worker validates every body against [`schemas/discovery-webhook-request.v1.schema.json`](schemas/discovery-webhook-request.v1.schema.json) after its field checks, so the schema and the worker accept and reject the same bodies (`tests/webhook/webhook-schema-parity.test.ts`). `discoveryProfile.ultraPlanTuning` and `discoveryProfile.groundedSearchTuning` are closed objects (unknown keys get `400`); `companyAllowlist`/`companyBlocklist` entries must be non-blank and duplicates are collapsed. The one rule JSON Schema cannot express is **blank effective intent**: a present `discoveryProfile` with no roles or keywords (and no `searchPlan`, `profileSnapshot` or `mergedUserProfile` intent) gets `400`.

Older automations that ignore `schemaVersion`, `discoveryProfile`, `companyAllowlist`, `mergedUserProfile`, and `googleAccessToken` keep working if they only read `event`, `sheetId`, `variationKey`, and `requestedAt`.

### Evolving this contract

- **Sheet columns:** If you add columns, prefer **after** T or a new tab — changing A–Q breaks existing sheets. Document changes in this file and README.
- **Webhook:** Bump **`schemaVersion`** to `2` only when you introduce **breaking** request-field changes. The dashboard should send the new version when we ship it; until then it sends `1`.
- **Non-breaking:** New optional fields inside `discoveryProfile` or optional top-level fields can be documented here without a version bump if old receivers ignore unknown keys.

---

## Pipeline update (`POST /pipeline-update`, schemaVersion 2)

An external agent advances an existing Pipeline row from inbound signals. Local-first: authenticated with `x-discovery-secret`; the worker writes with its own Google credential (no token in the request).

- `event`: `"command-center.pipeline-update"` (const)
- `schemaVersion`: `2` (const). Version `1` bodies are still accepted; they cannot send `source`, and `stage: "Applied"` without a date defaults Applied Date to today.
- `sheetId`: target Google Sheet (required)
- `job`: row identity — `url` (preferred), or both `company` and `title`
- `fields` (at least one): `stage` (one of: New, Researching, Applied, Phone Screen, Interviewing, Offer, Rejected, Passed, Expired), `contact`, `note` (prepended as a dated, deduped line), `lastContact` and `appliedDate` (dates as `YYYY-MM-DD`), `didTheyReply` (Yes | No | Unknown), and `source` (v2: where an application went in).
- **Applied (v2):** `stage: "Applied"` requires `appliedDate` and a non-blank `source`. The worker writes Status (M), Applied Date (N), a Follow-up Date (P) 7 days later when the row has none, and a Notes line `[today] Applied via <source>: <note>`.
- **Other stage side effects** (a TS port of `pipeline-transitions.js`): Phone Screen and Interviewing backfill Applied Date to today and set Follow-up +3 / +5 days; Offer, Rejected, Passed and Expired clear Follow-up; Expired adds `Marked Expired` when no note is sent; New clears Applied Date and Follow-up. Re-sending the row's current stage changes nothing but the other fields.

Matching is by normalized job URL, falling back to company+title. The worker checks row 1 against `schemas/pipeline-row.v1.json`, holds its per-Sheet lock, re-reads the matched row by Link before writing, and writes only changed cells (text is formula-escaped).

Responses: `200 {ok, updated, matched, matchedBy, row, rowNumber}`. Errors carry the `api-error.v1` fields `{error, code, detail?, nextStep, retryable}` (plus `ok: false` and `message` for v1 callers): `400 invalid_request`, `401 unauthorized`, `404 not_found` (this contract updates existing rows only; discovery creates rows), `409 header_mismatch` (a Pipeline column moved; nothing written), `409 ambiguous_match` (the job matches more than one row; nothing written), `502 sheet_write_failed` (`retryable: true`). Schemas: `schemas/pipeline-update-request.v2.schema.json` (current), `schemas/pipeline-update-request.v1.schema.json`; fixtures: `examples/pipeline-update-request.v2.json`, `examples/pipeline-update-request.v1.json`.

---

## Worker error envelope (`api-error.v1`)

Every error body from the Browser Use worker (`:8644`) and the local API (`server/index.mjs`, `:3847`) carries **`{ error, code, detail?, nextStep?, retryable }`** ([`schemas/api-error.v1.schema.json`](schemas/api-error.v1.schema.json), fixture [`examples/api-error.v1.json`](examples/api-error.v1.json)). `error` is one sentence safe to show the user; `code` is stable (worker codes are `lower_snake_case`, API codes `UPPER_SNAKE_CASE`); `nextStep` says how to recover; `retryable` is true only when repeating the same request later may work (timeouts, 5xx, rate limits). Worker bodies keep their legacy `ok: false` and `message` fields and route-specific fields (`reason`, `auth`, `remediation`); readers ignore fields they do not know. An unknown path is a JSON `404` with `code` `not_found` (worker) or `NOT_FOUND` (API). The local API applies the envelope to responses with status 400 or higher; the worker also applies it to `/ingest-url`'s HTTP 200 `ok: false` outcomes, with `code` equal to `reason`.

## Run status and cancel (`GET /runs/:runId`, `POST /runs/:runId/cancel`)

- **`GET /runs/:runId`** answers `{ ok: true, ...status }` ([`schemas/run-status.v1.schema.json`](schemas/run-status.v1.schema.json), fixture [`examples/run-status.v1.json`](examples/run-status.v1.json)). `status` is `accepted`, `running`, `completed`, `partial`, `empty` or `failed`; once `terminal` is true the status never changes. A worker restart marks an interrupted run `failed` and writes its DiscoveryRuns row. Terminal snapshots older than 30 days, or beyond the newest 200, are pruned at worker boot. Hosted workers authorize the poll with the `statusToken` in `statusPath`, `x-run-status-token`, or the webhook secret.
- **`POST /runs/:runId/cancel`** (header `x-discovery-secret`, no body) stops a live async discovery run of this worker process: it aborts the run's in-flight work, blocks any Sheet write the run has not started yet, waits for the run to stop (up to 15 s), and only then writes `failed` with `error` `Cancelled by user.` and the DiscoveryRuns row. It also waits (inside the same 15 s) for a Sheet write the run had already started to settle, and a user cancel is never recorded as a `partial` run. Answers: `200 { ok: true, runId, cancelled, stopConfirmed, run }` (`run` is run-status.v1); `cancelled` is `true` normally and `false` when the run finished before it saw the abort, in which case `run` is its real `completed` status; `stopConfirmed` is `false` when the run or a write it had started had not settled by the deadline, so that write may still land (the run's `message` says so); `503` `cancel_status_not_saved` (`retryable: true`) when the run was stopped but its cancelled status could not be saved, in which case the run stays cancellable and a retry tries the write again; `401` without the secret; `404` `run_not_found`; `409` `run_already_terminal` (with the finished `run`); `409` `run_not_cancellable` when the run is not live in this process (a synchronous run, or one started before a restart).

## Add a job by URL (`POST /ingest-url`, schemaVersion 1)

Request: [`schemas/ingest-url-request.v1.schema.json`](schemas/ingest-url-request.v1.schema.json), fixture [`examples/ingest-url-request.v1.json`](examples/ingest-url-request.v1.json): `event` `ingest.url.request`, `schemaVersion` `1`, `url` (required, at most 2048 characters), optional `sheetId` (falls back to the worker config), `async`, `googleAccessToken` (this request only, never persisted) and `manual` (`title`, `company` and optional `location`, `description`, `fitScore`) to skip extraction. Authenticated with `x-discovery-secret`.

Order: the worker resolves the Sheet and proves a Google Sheets credential **before** any ATS, Gemini, Browser Use or scrape call, and answers `409` `sheets_credential_missing` without one. Answers ([`schemas/ingest-url-response.v1.schema.json`](schemas/ingest-url-response.v1.schema.json), fixture [`examples/ingest-url-response.v1.json`](examples/ingest-url-response.v1.json)): `202` `accepted_async` with a `statusPath` whose terminal status carries the final answer in `ingestResult`; `200` success with `strategy`, `lead` and `appended`; `200` with `ok: false` and `reason` `blocked_aggregator`, `scrape_failed`, `low_quality_extraction`, `duplicate` or `worker_error`; `400` `invalid_url` or `private_network`; `409` `sheets_credential_missing`; `500` `worker_error`. Every failure carries the api-error.v1 envelope.

## Expired-job cleanup pass (`POST /cleanup-expired`)

Request: [`schemas/cleanup-expired-request.v1.schema.json`](schemas/cleanup-expired-request.v1.schema.json), fixture [`examples/cleanup-expired-request.v1.json`](examples/cleanup-expired-request.v1.json): `sheetId` (required), `dryRun` (default true; only an explicit `false` writes), `maxRows`, `timeoutMs`, `googleAccessToken`. Authenticated with `x-discovery-secret`. The `200` answer ([`schemas/cleanup-expired-response.v1.schema.json`](schemas/cleanup-expired-response.v1.schema.json), fixture [`examples/cleanup-expired-response.v1.json`](examples/cleanup-expired-response.v1.json)) carries the counts (`checked`, `open`, `needsReview`, `skipped`, `wouldExpire`, `updated`) and one `results` entry per checked row with its `action` (`would_expire`, `expired`, `open`, `needs_review`, `skipped`). Failures are api-error.v1: `400` (bad JSON or no `sheetId`), `401`, `500` `Cleanup failed.`.

---

## v2 kanban-card data-attributes (Dossier wiring)

Each `.kanban-card[data-stable-key="<n>"]` rendered by `app.js`'s
`renderKanbanCard` MAY carry the following read-only `data-*` attributes.
The v2 dossier view-model in `dawn-data.js`
(`getRoleViewModel`, `getPipelineViewModel`, `getLetterViewModel`) reads
them. Empty/null source values MUST be omitted entirely (do not emit
`data-foo=""`).

| Attribute            | Source field on `job`                                   | Notes                              |
| -------------------- | ------------------------------------------------------- | ---------------------------------- |
| data-jd-snippet      | job._postingEnrichment.description ?? job.fitAssessment | Truncate to 4000 chars             |
| data-notes           | job.notes                                               |                                    |
| data-location        | job.location                                            |                                    |
| data-salary          | job.salary                                              |                                    |
| data-job-url         | job.link                                                |                                    |
| data-source          | job.source                                              |                                    |
| data-applied-at      | job.appliedDate                                         | drives daysInStage + applied label |
| data-follow-up       | job.followUpDate                                        | drives the orange Deadline callout |
| data-tags            | job.tags (CSV)                                          |                                    |
| data-fit             | job.fitScore                                            | numeric, clamped 1–10 by VM        |
| data-enrichment-source | job._postingEnrichment._scrapeSource                  | `cheerio`, `gemini-url-context`, or `title-and-company`; omitted when empty |
| data-enriched-at     | job._postingEnrichment.scrapedAt                        | fetch timestamp as stored; the dossier formats it and shows its age |
| data-enrichment-fallback | job._postingEnrichment._scrapeFallbackReason        | omitted when no fallback occurred; escaped before render |
| data-enrichment-parse-mode | job._postingEnrichment._parseMode                 | `schema` / `loose` / `repaired`; anything but `schema` classifies unknown |
| data-edit-lock       | job._editLock                                           | comma-separated user-overridden field ids (Sheet column Y); outranks scrape lineage |
| data-replied         | normalized job.responseFlag (`Yes`/`No`/`Unknown`)      | omitted when the source is empty; drives the `reply` band |
| data-last-contact    | job.lastHeardFrom                                       | omitted when the source is empty   |
| data-talking-points  | job.talkingPoints                                       | fallback JD section if no snippet  |
| data-contacts        | `[{name: job.contact}]` JSON                            | single-row contact for now         |
| data-company-tagline | job._postingEnrichment.aboutCompany                     |                                    |
| data-employment      | job._postingEnrichment.employmentType                   |                                    |

These attributes are emitted by the legacy renderer regardless of the
`body.jb-v2` flag. They are invisible to the legacy UI and add no
behavior to the off-flag path.

Tests: [`tests/dossier-card-attrs.test.mjs`](tests/dossier-card-attrs.test.mjs) enforces the round trip.

---

## Product health: empty states (copy matrix)

The dashboard and **resume onboarding** are separate: `onboardingComplete` in IndexedDB only reflects the resume/cover-letter wizard. **Agent setup** (sheet + webhook + cron) is independent; users may finish one without the other.

| State | Condition                                         | Primary message (intent)                                                | Primary CTA                                                        |
| ----- | ------------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------ |
| **A** | No pipeline rows **and** no discovery webhook URL | Your pipeline is empty; connect automation or add jobs manually.        | Open **Discovery drawer → Connection**; open **Agent setup** checklist. |
| **B** | No pipeline rows **but** webhook URL is set       | First run pending — trigger discovery or wait for your scheduled agent. | **Run discovery**; link to [SETUP.md](SETUP.md) agent section.     |
| **C** | At least one pipeline row                         | Normal dashboard; filters and Daily Brief apply.                        | None specific.                                                     |

**Daily Brief:** When there are no rows, sections that depend on data stay minimal; the **Pipeline** empty-state messaging carries the main “what to do next” guidance. When rows exist, Brief sections behave as documented in SETUP.

---

## Dossier event family (Direction F build — internal contract)

These events are **internal** to the dashboard (browser-only). They are not part
of the agent integration surface; they exist to keep the dossier's Brief, Workshop,
ATS bus, and write-back bridge decoupled. Workers building Direction F must not
rename or reshape these payloads without orchestrator approval.

| Event                  | Emitter                | Listeners                  | Payload                                                                |
| ---------------------- | ---------------------- | -------------------------- | ---------------------------------------------------------------------- |
| `jb:ats:state`         | `app.js` (state bus)   | dossier Workshop, Letter   | `{ jobKey, status, result?, error? }`                                  |
| `jb:ats:state:request` | dossier Workshop       | `app.js` (state bus)       | `{ jobKey }`                                                           |
| `jb:ats:modal:open`    | dossier Workshop       | `app.js` (state bus)       | `{ jobKey }`                                                           |
| `jb:role:writeback`    | dossier Workshop       | `flowing-writes.js`        | `{ jobKey, field, value }` — see field enum below                      |
| `jb:role:open`         | Today queue            | (none yet)                 | `{ jobKey, source }` — **cancelable** intent; unclaimed, `today.js` performs the same navigation dawn's open-dossier action does |
| `jb:a11y:dialog:opened` | `jb-a11y.js`          | observability only         | `{ el, depth }`                                                        |
| `jb:a11y:dialog:closed` | `jb-a11y.js`          | observability only         | `{ el, depth, reason }`                                                |
| `jb:closure:change`    | dawn / pipeline board / expired review | integrator shim (`app-bootstrap.js`) | `{ jobKey, action: "dismiss"\|"restore"\|"expire"\|"unexpire", source }` — **cancelable**: the shim claims it with `preventDefault()` then writes via `JobBoredPipelineTransitions.planTransition` + `applyCells`; unexpire stays on `updateJobStatus("Researching")` until the planner grows that action |

> `jb:a11y:dialog:*` are **observability only**. `depth` is the LIFO stack position the dialog
> occupied (1 = outermost); `reason` is `"escape" | "programmatic"`. No write behavior may depend
> on them. Like every other `jb:*` family they dispatch on both `window` and `document`.
>
> Stage layer split: `window.JobBoredStages` (`stage-registry.js`) is the read/UI vocabulary and the
> `jb:closure:change` intent bus. `window.JobBoredPipelineTransitions` (`pipeline-transitions.js`) is
> the only cell writer for stage/closure moves. The `isClosed` homonym is deliberate: Stages treats
> Expired as archived (not closed for UI visibility); Transitions treats Expired as closed for writes.
>
> `window.JobBoredDossierProvenance` has exactly one definer, `dossier-field-provenance.js`, exposing
> both `stampProvenance` (fetch-path stamping, 3-day TTL, profileRevision) and `classify` (visible
> label vocabulary). `dossier-provenance.js` must never be loaded.

`field` enum for `jb:role:writeback`:
`"stage" | "heardBack" | "reply" | "followupAt" | "passed"`.

Preserved adjacent contracts (not changed by Direction F):

| Event               | Emitter           | Payload                                              |
| ------------------- | ----------------- | ---------------------------------------------------- |
| `jb:role:opened`    | flowing-chrome    | `{ jobKey }`                                         |
| `jb:role:closed`    | flowing-chrome    | (no payload)                                         |
| `jb:role:action`    | dossier Workshop  | `{ action: "resume-tailor" \| "resume-cover", jobKey }` |
| `jb:role:note`      | dossier Brief     | `{ jobKey, body }`                                   |
| `jb:pipeline:move`  | dossier Workshop  | `{ jobKey, fromStage?, toStage }`                    |

All events dispatch on both `window` and `document` to match existing bridge conventions.

---

## Related docs

- [SETUP.md](SETUP.md) — OAuth, Hermes, webhooks, Daily Brief
- [README.md](README.md) — Column reference, quick start
- [SECURITY.md](SECURITY.md) — Where settings and tokens live
- [schemas/discovery-webhook-request.v1.schema.json](schemas/discovery-webhook-request.v1.schema.json) — JSON Schema for discovery POST body
- [integrations/openclaw-command-center/](integrations/openclaw-command-center/) — Agent skill template (OpenClaw / Hermes)
- [docs/CONTRACT-HARDENING-PLAN.md](docs/CONTRACT-HARDENING-PLAN.md) — Roadmap: fixtures, CI, pipeline schema, webhook evolution
- [docs/CONTRACT-CHANGELOG.md](docs/CONTRACT-CHANGELOG.md) — Dated contract / schema / example changes
- [CONTRIBUTING.md](CONTRIBUTING.md) — Checklist when changing discovery payload or Pipeline columns
- [docs/redesign/handoffs/dossier-df-*.md](docs/redesign/handoffs/) — Dossier Direction F lane briefs
