Active docs/ was stub scaffolding while the real docs sat in docs/_archive/. Restore and correct them, and record the Phase 0 work. - docs/architecture/current.md: verified system map (from archived SYSTEM_OVERVIEW, 9 corrections against code). - docs/research/competitors.md: sourced competitor analysis (from archived PRODUCT_RESEARCH, feature matrix corrected). - docs/decisions/ADR-002-job-application-model.md: the Job/JobApplication split. - docs/application-discovery-report.md, docs/implementation-roadmap.md, docs/phase-0-foundation-report.md, docs/career-workspace-branch-assessment.md. - Remove 10 zero-byte placeholder files that advertised content that never existed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
8.2 KiB
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:
- There was no
Jobentity.JobApplicationwas the only entity, carrying 43 members that mixed opportunity data (JobTitle,Description,JobUrl,Salary*,Deadline,Tags,Location) with application data (Status,DateApplied,ResponseReceived,FollowUpAt). JobPipeline.Stagesbegan atApplied. There was noSaved/Interested/Preparing.DateAppliedwas non-nullable with aDateTime.UtcNowdefault.
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 exceptAppliedwith a fabricated date. - Applying twice to the same reposted role duplicated the entire job description.
- Teal — the market leader — offers
Savedin 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
Jobentity (Models/Job.cs) — the opportunity: company, title, description, URL, location, salary, deadline, tags, plusSavedAt. Owner-scoped by the same deny-on-null global query filter as every other tenant entity.JobApplication.JobId— a nullable FK toJob,OnDelete: SetNull.- Prospect stages —
JobPipelinegainedPipelineCategory.Prospectand the stagesSaved(1),Interested(2),Preparing(3), ahead ofApplied(4). No schema change:Statusis a free-text column by deliberate prior design. DateAppliedis now nullable, andSavedAtwas added toJobApplication.JobPipeline.SyncAppliedDate— the single enforcement point for the invariant below.
Explicitly NOT done in Phase 0
- No dual-write. Nothing writes or reads
Jobyet.JobApplicationremains the sole source of truth for every read and write. - No backfill of
Jobrows. The table ships empty. - No legacy columns dropped.
JobApplicationkeeps 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.
The invariant
DateAppliedis 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
DateAppliedif 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
RulesEnginealready whitelistedApplied/Offer/Rejected/Waitingand explicitly refused to ghost anything else, so prospects were never at risk. AJobPipeline.IsProspectguard was added anyway, ahead of any date arithmetic, plus a null-DateAppliedguard on theAppliedbranch — a null must fail safe rather than read as "infinitely old" and auto-ghost the job. Covered byRulesEngineProspectTests.StageAnalyticsalready filtered toPipelineCategory.Active, so prospects drop out of time-in-stage automatically.
Deliberate behaviour changes
DaysSinceis nowint?, null for prospects. Returning0would render as "Applied 0 days ago" — a lie. The API DTO and the frontendJobApplicationtype 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. JobFlowBaromits 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
JobApplicationtemporarily carries bothJobIdand its own opportunity columns — real duplication, time-boxed to the Phase 1 cutover.- An empty
Jobstable 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
- Dual-write
Jobon every create path (JobApplicationsController.Create,GmailController's job creation, CSV import). - Backfill one
Jobper existingJobApplication; link viaJobId. - Flip reads to
Job, one endpoint at a time. - Drop the duplicated opportunity columns from
JobApplication. - Make
JobIdnon-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
Jobentity. 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
DateAppliednon-nullable, addSavedAtalongside. Rejected: a saved job would still carry a fabricated applied date — precisely the defect this ADR exists to remove.