- consolidate API ownership and remove dead vendor code - add Stripe billing, learning paths, and public CV hardening - add migration, recovery, security, audit, and browser gates
19 KiB
Phase 0 — Foundation Corrections Report
Date: 2026-07-17
Scope: the five priorities set after the discovery review. Nothing else was touched.
Companion to docs/application-discovery-report.md, docs/architecture/current.md, docs/decisions/ADR-002-job-application-model.md.
Nothing was committed. All changes are in the working tree for review.
1. Completed
Priority 1 — Restore accurate architecture documentation ✅
The active docs/ tree was a scaffold: 139 files, median ~480 bytes, while the real documentation sat archived. docs/AI_SESSION_START.md sends every future session to read those stubs, so each one started from fiction.
-
docs/architecture/current.md— restored fromdocs/_archive/SYSTEM_OVERVIEW.md(20 KB) and re-verified line by line against the code. Was a 3-line "TODO: Complete documentation." stub. Nine corrections to the archived original:Archived claim (2026-07-02) Reality (verified 2026-07-17) "CRA/react-scripts 5, TypeScript 4.9" Next.js 16 + TypeScript 5.9; CRA survives only as the test runner Root Controller/folder is dead codeRemoved in 519c32eCI runs "a whitelist of 10 frontend test files" Whitelist gone; whole suite runs, with a comment forbidding its return Match score ❌ missing Exists ( JobCvMatchService,/match-score,/candidate-fit)Salary is a text field Structured ( SalaryMin/Max/Currency/Period)8 migrations 11 (after this phase) ~15 controllers 19, with exact line counts Rate limiting: 2 policies 3 ( auth-login,auth-email,auth-2fa-challenge)6 hosted services 7 ( DatabaseBackupHostedServiceadded)Also documented for the first time: the three-toolchain frontend, the absence of any state-management layer, the AI provider reality vs.
docs/00-ai-context.md's fiction, and six historical decisions that existed only as code comments. -
docs/research/competitors.md— restored fromdocs/_archive/PRODUCT_RESEARCH.md(13 KB, sourced). Was a stub. Six feature-matrix rows corrected against code and marked[corrected 2026-07-17]. The scope gap is stated plainly at the top: Novoresume, Reactive Resume and ElegantCV are analysed nowhere in this repo, and Reactive Resume is a Phase 4 prerequisite. -
docs/decisions/ADR-002-job-application-model.md— written. Was a 0-byte file naming the single most consequential decision in the system. -
Ten 0-byte files deleted (verified empty first): the four ADR placeholders, five
09-research/*analyses, andknown-issues.md. They advertised content that never existed.
Priority 2 — Secure the AI sidecar ✅
The sidecar published port 8001 to the host and had no authentication of any kind — the only Authorization header in app.py was outbound to Groq. Anyone reaching the host could call /cv/rewrite, /summarize, /extract-text: draining the Gemini/Groq API key and running arbitrary text through the model. This matters more than it looks, because jobtracker_shared is an external, shared Docker network — other containers could reach it too.
Two layers, both required:
- Network — host port mapping removed;
expose: "8001"only. The backend reaches it in-network athttp://ai-service:8001. A comment tells the next person to usedocker-compose.override.ymlfor local debugging rather than re-addingports:. - Shared secret —
X-Ai-Service-Tokenrequired on every endpoint except/health(which the backend probe and the compose healthcheck both need, and which exposes no data or generation path). Compared withhmac.compare_digestto avoid a timing leak.
Where the enforcement lives matters. The token is unset → open, so local dev and the existing test suite keep working keyless. Production cannot reach that state: docker-compose.yml declares AI_SERVICE_TOKEN=${AI_SERVICE_TOKEN:?...}, so the stack refuses to start without it. Misconfiguration fails loudly at deploy rather than silently booting open at runtime. Verified: docker compose config with no token exits non-zero.
The backend side is one place — all seven call sites route through the named ai-service HttpClient, so the header is set once in Program.cs.
Priority 3 — Introduce Job separate from JobApplication ✅ (foundation)
Job (JobTrackerApi/Models/Job.cs) is the opportunity; JobApplication is the pursuit of it. JobApplication.JobId is a nullable FK (OnDelete: SetNull). Job carries the same deny-on-null tenant query filter as every other owned entity.
Deliberately additive: nothing reads or writes Job yet, no dual-write, no backfill, no legacy columns dropped. That keeps Priority 3 a pure schema change with zero behavioural risk, which is what "unblock future phases safely" asks for. The Phase 1 cutover plan (dual-write → backfill → flip reads → drop columns) is in ADR-002.
Job also carries Source and CountryCode, so market is a data dimension from day one — per the "Norway first, but no hardcoding Norway" decision. The entity is ready; the plugins still assume NO (roadmap 6.6).
Priority 4 — Expand pipeline stages beyond Applied ✅
JobPipeline gained PipelineCategory.Prospect and three stages ahead of Applied:
Saved(1) · Interested(2) · Preparing(3) → Applied(4) · Waiting(5) · Interview(6) → Offer(7) → Rejected(8) · Ghosted(9)
No schema change was needed for the stages themselves — Status is free-text by deliberate prior design, so custom values survive. Synonyms map in (bookmarked/wishlist/to apply → Saved, shortlisted → Interested, drafting → Preparing).
This is what unblocks the guide's #2 priority workflow. The 6-step add-job wizard already existed and walked users through preparing an application — with nowhere to put the result but Applied with a fabricated date.
The supporting change: DateApplied is now nullable, with SavedAt added. The invariant — DateApplied is set if and only if the job has left the Prospect stages — is enforced in exactly one function, JobPipeline.SyncAppliedDate, called from all three status-write paths so they cannot drift.
Priority 5 — Update the roadmap ✅
docs/implementation-roadmap.md re-scoped against the real architecture, with all six product decisions folded in. Phase 1 shrank (~1–1.5 wk → ~3–5 days) because Phase 0 absorbed most of it; Phase 5 shrank (docs fix, not an abstraction build); Phase 6 shrank and reordered (no scraping; extension before search backend); Phase 3 grew slightly (relational profile is now a committed L). Four new tasks came out of Phase 0 findings.
2. Files changed
Backend
JobTrackerApi/Models/Job.cs(new) — the opportunity entityJobTrackerApi/Models/JobApplication.cs—JobId,SavedAt, nullableDateApplied,DaysSince→int?JobTrackerApi/Data/JobTrackerContext.cs—JobsDbSet, tenant filter, FK config, indexJobTrackerApi/Services/JobPipeline.cs—Prospectcategory, 3 stages,IsProspect,SyncAppliedDateJobTrackerApi/Services/RulesEngine.cs— prospect guard + null-DateAppliedfail-safeJobTrackerApi/Services/AnalyticsService.cs— exclude prospects from applied-volume, average age, response-time;SavedAtfallback for stage entryJobTrackerApi/Services/FollowUpReminderHostedService.cs— null-safe applied dateJobTrackerApi/Controllers/JobApplicationsController.cs—SyncAppliedDateon all 3 write paths,SavedAtin DTO, null-safe analytics/draftsJobTrackerApi/Controllers/JobApplicationDtos.cs—DateApplied→DateTime?,DaysSince→int?,SavedAtaddedJobTrackerApi/Controllers/ExportController.cs— null-safe CSVJobTrackerApi/Program.cs—X-Ai-Service-Tokenon theai-serviceHttpClient
Frontend (type-driven; the compiler found every site that would have rendered a null)
src/types.ts—dateApplied: string | null,daysSince: number | null,savedAt: stringsrc/components/JobFlowBar.tsx— omits the "Applied" milestone when there is no applied datesrc/components/KanbanBoard.tsx— sort falls back tosavedAt; "Applied Nd ago" hidden when nullsrc/components/JobTable.tsx,JobDetailsDialog.tsx— render—src/components/EditJobDialog.tsx,ImportExportJobs.tsx— null handling
AI service
tools/summarizer/app.py— token middlewaredocker-compose.yml—exposenotports;AI_SERVICE_TOKEN+Ai__ServiceTokenwith:?guards.env.example—AI_SERVICE_TOKENdocumented as required
Tests (+31)
JobTrackerApi.Tests/RulesEngineProspectTests.cs(new) — 5 casesJobTrackerApi.Tests/JobPipelineTests.cs— +21 casestools/summarizer/tests/test_app.py— +5 token-guard casesjob-tracker-ui/src/workflow-trust-signals.test.tsx— fixture updated
Docs
docs/architecture/current.md,docs/research/competitors.md,docs/decisions/ADR-002-job-application-model.md,docs/implementation-roadmap.md, this report. Ten 0-byte files deleted.
Not mine:
views/CareerWorkspacePage.tsxandviews/ProfilePage.tsxshow as modified — that is your pre-existing uncommitted work (the inert CV Builder tab). I did not touch either.
3. Database changes
Migration 20260717071417_AddJobEntityAndProspectStages:
JobApplications.DateApplied→ nullable (SQLite table rebuild)JobApplications.JobId→ nullable FK toJobs,SetNullJobApplications.SavedAt→ new, backfilled= DateAppliedJobstable created (+IX_Jobs_OwnerUserId,IX_Jobs_CompanyId,IX_JobApplications_JobId)
The migration was hand-edited, and that was necessary
dotnet ef migrations add also scaffolded CreateTable for TrustedDevices, TwoFactorRecoveryCodes, UserSessions and AddColumn for six AspNetUsers columns. Those tables already exist in every real database — the reconciler in StartupInitializationExtensions provisioned them, not a migration, so the prior ModelSnapshot never knew them and the scaffolder diffed them as missing.
Verified against the live dev database: all three tables are present. Shipping the scaffolded migration 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 with the Status(50) prefix length MySQL requires and the scaffolded DDL lacks.
The regenerated snapshot now includes those tables, so future migrations will no longer re-scaffold them. This migration closes that drift permanently.
Verified against real data, not reasoned about
EF warned: "An operation of type 'SqlOperation' will be attempted while a rebuild of table 'JobApplications' is pending." Rather than trust the reasoning, the migration SQL was applied to a copy of the real dev database (13 rows):
| Check | Result |
|---|---|
| Rows preserved | 13 → 13 ✅ |
| All pre-existing columns survive the rebuild | 36 → 38 (+JobId, +SavedAt), 0 dropped ✅ |
DateApplied values preserved |
✅ |
SavedAt backfilled from DateApplied |
✅, no 0001-01-01 sentinels left |
DateApplied nullable afterwards |
✅ |
Jobs created |
✅ |
Reconciler-era data (ShortSummary) preserved |
✅ |
The only DROP TABLE is EF's standard rebuild of JobApplications itself (create temp → copy → drop → rename).
4. Security improvements
| Finding | Before | After |
|---|---|---|
| AI sidecar unauthenticated (High) | Port 8001 published to host; zero auth on /cv/rewrite, /summarize, /extract-text; anyone reaching the host could drain the Gemini/Groq key |
Backend-only, verified live (2026-07-17). No host port; on a private ai_internal network with the backend as its only other member; X-Ai-Service-Token required on all non-/health endpoints, constant-time compared; compose refuses to start without a token. See §4a. |
| Auto-ghosting a job nobody applied to (new risk introduced by Priority 4) | — | Guarded before any date arithmetic; a null DateApplied fails safe rather than reading as "infinitely old". Covered by RulesEngineProspectTests, including a Saved-a-year-ago job against 1–2 day thresholds |
| Tenant isolation on the new entity | — | Job carries the same deny-on-null global query filter as every other owned entity |
4a. AI service lockdown — completed and verified 2026-07-17
The original Phase 0 fix was half a fix, and it was never applied. Two gaps:
expose:only removes the host mapping.ai-servicewas still attached toshared_services— which isexternal: true(jobtracker_shared). Any other compose stack on the host can join that network and would have reached port 8001. It was also ondefault, which the frontend shares.- Nothing had been deployed. The running container still mapped
0.0.0.0:8001because the compose change had never been applied, and.envhad noAI_SERVICE_TOKEN— so the stack would in fact have refused to start.
Now three layers:
| Layer | Mechanism |
|---|---|
| No host port | ports: removed, expose: "8001" only |
| Private network | New ai_internal bridge with exactly two members — ai-service and backend. Removed from default and shared_services. Not internal: true, so egress to Gemini/Groq still works |
| Shared secret | X-Ai-Service-Token on every endpoint but /health, hmac.compare_digest. ${AI_SERVICE_TOKEN:?...} on both services so a deploy that forgets it fails loudly |
Verified against the live stack — not just configured:
| Check | Result |
|---|---|
Host → localhost:8001/health |
connection refused ✅ |
Frontend container → ai-service:8001 |
wget: bad address — cannot even resolve it ✅ |
/summarize no token (from ai_internal) |
401 ✅ |
/cv/rewrite no token |
401 ✅ |
/extract-text no token |
401 ✅ |
| Wrong token | 401 ✅ |
/health no token |
200 ✅ (the probe and compose healthcheck need it; no data, no generation) |
Backend (172.23.0.3) with token → /summarize |
200 OK ✅ |
docker compose config with empty token |
rejected ✅ |
| App up | frontend 200, backend 200 ✅ |
AI_SERVICE_TOKEN was generated and written to .env (64-hex, gitignored, verified). Rotating it requires recreating both containers together — they must agree.
Bonus verification: recreating the backend applied migration 20260717071417_AddJobEntityAndProspectStages to the container's database cleanly (__EFMigrationsHistory row written). That is a second, independent confirmation on a real database, alongside the dev-file test in §3.
Caveat for shared Ollama: if OLLAMA_BASE_URL points at an Ollama in another compose stack, address it by host IP — ai-service can no longer resolve container names on shared_services, by design. The bundled ollama profile is on ai_internal and still resolves by name.
Still open — both need you
- DataProtection keys remain recoverable from git history (
519c32e,955cae6). Untracked, but not unrecoverable. Flagged 2026-07-03, still open. Needs an operator with production access — I cannot rotate these. - CORS wildcard landmine — found during this phase.
Program.cs:96-102: ifCors:Originscontains*, the policy usesSetIsOriginAllowed(_ => true)together withAllowCredentials(). That is reflected-origin-with-cookies: any website could make authenticated requests as the signed-in user. Not currently active — compose never setsCors__Origins, so it defaults tolocalhost:3000— but it is one config value from session theft. The fix (reject*+credentials) is ~3 lines. Left alone deliberately: it was outside the five priorities and is not currently exploitable. Now roadmap task 1.5.
5. Verification
| Suite | Result |
|---|---|
Backend (dotnet test) |
232 passed, 0 failed (was 206 — +26 new) |
AI sidecar (pytest) |
16 passed (was 11 — +5 new) |
Frontend typecheck (tsc --noEmit) |
clean |
Frontend (react-scripts test) |
60 passed, 5 failed — see below |
| Solution build | clean |
| Migration against real data | verified (§3) |
docker compose config without token |
correctly rejected |
The 5 frontend failures are pre-existing, not mine
profile-page.test.tsx and settings-view.test.tsx fail. I verified this rather than assumed it: I stashed all Phase 0 changes, re-ran both suites against untouched main, and got the identical 5 failures, then restored the work and re-confirmed 232 backend tests still pass.
Neither suite touches anything I changed. Both exercise ProfilePage/SettingsView — and ProfilePage.tsx is part of your uncommitted working-tree changes.
This means main is currently red. CI runs the whole frontend suite, so every future change lands on a broken baseline. Now roadmap task 1.8.
Not verified
The app was not run end-to-end. Phase 0 changed no UI behaviour by design (the prospect stages are not yet surfaced — that is Phase 1), so there was nothing new to drive in a browser. The migration is verified against real data; a production run is not.
6. Remaining decisions
Everything blocking is now either a credential or a small engineering call.
Needs you
- Stripe keys — gates roadmap 7.5. Tiers are decided; only credentials are missing.
- DataProtection key rotation — needs production access.
Raised by Phase 0 — needs a call before the phase it touches
-
Orphan database tables. The dev database contains
CareerProfiles,CareerProfileVersions,CvVariants,CvVersions,TailoredApplications,InterviewPrepNotes,AiWorkspaceNotes— in no EF model and no migration. This is an abandoned Career Workspace attempt, and its table names map almost exactly onto roadmap Phase 3 (CvVariantsis the glossary's "CV Variant";CareerProfileVersionsis section history). Was there a previous design worth recovering, or is this dead weight? Answer before Phase 3 re-treads the same ground. (Roadmap 1.9.) -
Kanban column count. The pipeline now has 9 stages; the board is built for 6. Do the three Prospect stages get their own columns, collapse into one "Not applied" column, or sit behind a toggle? Needed to finish Phase 1. (Roadmap 1.1.)
-
Dev DB is missing 2 migrations (
AddCorrespondenceEmailFields,AddShortSummary) yet hasSyncModelSnapshot— the reconciler patched those columns instead. Local-only, but it means the dev database is not a faithful rehearsal for a production migration. Worth reconciling before the Phase 1 cutover. (Roadmap 1.10.)
Deliberately deferred
- Backward transition clears
DateApplied. MovingApplied→Savedclears the applied date. The alternative — a Saved job still carrying one — silently counts it as applied in analytics and exposes it to the ghosting rules. The original date stays recoverable fromStatusChangedhistory. Documented in ADR-002; flag it if you disagree. Jobships empty. No backfill, by choice: nothing consumes it yet, and a later cutover may shape the data differently.- The inert CV Builder tab (
careerViewprop, never read) is untouched — it is your uncommitted WIP. Roadmap 1.6.