# ADR-002 — Separating Job (the opportunity) from JobApplication (the pursuit)

- **Status:** Accepted (partially implemented)
- **Date:** 2026-07-17
- **Phase:** 0 (foundation corrections)
- **Supersedes:** the 0-byte placeholder previously at `docs/_archive/decisions/ADR-002-job-application-model.md`

---

## Context

`docs/MASTER_IMPLEMENTATION_GUIDE.md` names this as the product's #2 priority workflow:

```
Find Job → Import Job → Review Details → Prepare CV → Prepare Cover Letter
         → Add Supporting Files → Submit Application → Track Progress
```

`docs/01-glossary.md` is explicit that "A Job may exist before an application is submitted" and models the hierarchy as `Company → Job Opportunity → Application`.

**The code could not represent any of that.** The 2026-07-17 discovery audit found:

1. There was no `Job` entity. `JobApplication` was the only entity, carrying 43 members that mixed opportunity data (`JobTitle`, `Description`, `JobUrl`, `Salary*`, `Deadline`, `Tags`, `Location`) with application data (`Status`, `DateApplied`, `ResponseReceived`, `FollowUpAt`).
2. `JobPipeline.Stages` began at `Applied`. There was no `Saved`/`Interested`/`Preparing`.
3. `DateApplied` was non-nullable with a `DateTime.UtcNow` default.

Consequences:

- A job you had not applied to could not be tracked. The 6-step add-job wizard (`components/AddJobModal.tsx`) walks a user through *preparing* an application and then had nowhere to save it except `Applied` with a fabricated date.
- Applying twice to the same reposted role duplicated the entire job description.
- Teal — the market leader — offers `Saved` in its **free** tier. This was a table-stakes gap.

## Decision

Split `Job` from `JobApplication`, **additively and in stages**. Phase 0 lays the foundation only.

### Delivered in Phase 0

1. **`Job` entity** (`JobTrackerApi/Models/Job.cs`) — the opportunity: company, title, description, URL, location, salary, deadline, tags, plus `SavedAt`. Owner-scoped by the same deny-on-null global query filter as every other tenant entity.
2. **`JobApplication.JobId`** — a nullable FK to `Job`, `OnDelete: SetNull`.
3. **Prospect stages** — `JobPipeline` gained `PipelineCategory.Prospect` and the stages `Saved`(1), `Interested`(2), `Preparing`(3), ahead of `Applied`(4). No schema change: `Status` is a free-text column by deliberate prior design.
4. **`DateApplied` is now nullable**, and `SavedAt` was added to `JobApplication`.
5. **`JobPipeline.SyncAppliedDate`** — the single enforcement point for the invariant below.

### Explicitly NOT done in Phase 0

- **No dual-write.** Nothing writes or reads `Job` yet. `JobApplication` remains the sole source of truth for every read and write.
- **No backfill of `Job` rows.** The table ships empty.
- **No legacy columns dropped.** `JobApplication` keeps its full copy of the opportunity fields.

This keeps Phase 0 a pure schema-and-vocabulary change with **zero behavioural change** to existing workflows, which is what "unblock future phases safely" requires.

### Phase 1 status (2026-07-31)

Create and update paths now synchronize `Job`, and startup idempotently backfills a linked `Job` for every legacy application missing one. Reads and legacy columns remain unchanged until the production validation and observation release in `docs/operations/job-opportunity-cutover.md` complete.

### The invariant

> `DateApplied` is set **if and only if** the job has left the pre-application stages.

Enforced in exactly one place — `JobPipeline.SyncAppliedDate(job, now)` — called from all three status-write paths (`POST /jobapplications`, `PUT /jobapplications/{id}`, `PATCH /jobapplications/{id}/status`) so they cannot drift.

- Entering a real stage stamps `DateApplied` if unset.
- Moving **back** into a Prospect stage **clears** it.

The backward clear is deliberate. The alternative — a `Saved` job still carrying an applied date — would silently count it as applied in analytics and expose it to the follow-up/ghosting rules. The original date stays recoverable from the `StatusChanged` `JobEvent` history, so this is denormalised-field loss, not data loss.

## Consequences

### Safe by construction

- **`RulesEngine`** already whitelisted `Applied`/`Offer`/`Rejected`/`Waiting` and explicitly refused to ghost anything else, so prospects were never at risk. A `JobPipeline.IsProspect` guard was added anyway, ahead of any date arithmetic, plus a null-`DateApplied` guard on the `Applied` branch — a null must fail safe rather than read as "infinitely old" and auto-ghost the job. Covered by `RulesEngineProspectTests`.
- **`StageAnalytics`** already filtered to `PipelineCategory.Active`, so prospects drop out of time-in-stage automatically.

### Deliberate behaviour changes

- `DaysSince` is now `int?`, null for prospects. Returning `0` would render as "Applied 0 days ago" — a lie. The API DTO and the frontend `JobApplication` type follow; the UI renders `—`.
- Applied-volume analytics and average-days-since-applied now filter `DateApplied != null`, so prospects cannot drag the average toward zero or inflate applied counts.
- `JobFlowBar` omits the "Applied" milestone entirely when there is no applied date.
- Custom (non-canonical) statuses are **not** treated as prospects. They predate this split and have always counted as applied; assuming otherwise would silently drop them out of existing users' analytics.

### Costs accepted

- `JobApplication` temporarily carries both `JobId` and its own opportunity columns — real duplication, time-boxed to the Phase 1 cutover.
- An empty `Jobs` table ships. Preferred over a backfill that no code consumes and that a later, better-informed cutover might shape differently.

## Migration notes (read before the next migration)

`20260717071417_AddJobEntityAndProspectStages` was **hand-edited after scaffolding**. `dotnet ef migrations add` additionally emitted `CreateTable` for `TrustedDevices`, `TwoFactorRecoveryCodes`, `UserSessions` and `AddColumn` for six `AspNetUsers` columns (`Microsoft*`, `Totp*`).

Those tables **already exist in every real database** — they were provisioned by the idempotent reconciler in `StartupInitializationExtensions`, not by a migration, so the prior `ModelSnapshot` did not know them and the scaffolder diffed them as missing. Verified directly against the live dev database: all three tables are present. Leaving the scaffolded statements in would have failed the deploy with "table already exists". They were removed; the reconciler still creates them on a fresh boot via `CREATE TABLE IF NOT EXISTS`.

`IX_JobApplications_OwnerUserId_IsDeleted_Status` was removed for the same reason: the reconciler applies it, and MySQL needs a `Status(50)` prefix length that the scaffolded DDL does not carry (see the comment in `JobTrackerContext.OnModelCreating`).

**The regenerated snapshot now includes those tables, so future migrations will not re-scaffold them.** This ADR's migration is the one that closes that drift.

EF emits a warning that the `SavedAt` backfill `UPDATE` runs while a rebuild of `JobApplications` is pending. Verified empirically against a copy of the real dev database (13 rows): the migration applies cleanly, all 36 pre-existing columns survive the rebuild, `DateApplied` values are preserved, `SavedAt` backfills correctly with no `0001-01-01` sentinels remaining, and `DateApplied` ends up nullable.

## Phase 1 cutover plan

1. **Done:** dual-write `Job` on every persistence path (`JobApplicationsController` and Gmail suggestion creation; CSV import is preview-only).
2. **Done:** backfill one `Job` per existing `JobApplication`; link via `JobId`.
3. Flip reads to `Job`, one endpoint at a time.
4. Drop the duplicated opportunity columns from `JobApplication`.
5. Make `JobId` non-nullable.

A `Job` with no `JobApplication` row is the eventual clean representation of a saved-but-not-applied job. Until step 4, the Prospect stages on `JobApplication.Status` carry that meaning instead.

## Alternatives considered

- **Full split in Phase 0.** Rejected: touches 38 endpoints, the Kanban, the table, the rules engine and the wizard at once, with no staging environment and a straight-to-prod deploy. The discovery report ranked this the highest-risk item in the plan.
- **Prospect stages only, no `Job` entity.** Would have unblocked the workflow, but leaves the duplication that makes applying twice to one role copy the whole description, and defers the schema foundation the roadmap's Phase 3/4 depend on.
- **Keep `DateApplied` non-nullable, add `SavedAt` alongside.** Rejected: a saved job would still carry a fabricated applied date — precisely the defect this ADR exists to remove.
