# Jobjakt — Current Architecture

> **This document describes the system as it actually is.** Every claim was verified against code.
> Last verified: 2026-07-31. Supersedes the archived `docs/_archive/SYSTEM_OVERVIEW.md` (2026-07-02).
>
> **Rule:** if this document and the code disagree, the code wins — and this document is a bug. Fix it.
> Topic documentation is maintained alongside this file; dated completion and review reports remain
> historical snapshots. When documentation conflicts, prefer this file and the code.

---

## 1. What the product is

Jobjakt is a self-hosted, multi-user job application tracking platform with local-AI career assistance:

- Track jobs and applications end-to-end (pipeline stages, follow-ups, deadlines, salary, tags, notes).
- Company CRM (pipeline stage, contact dates, recruiter details) — company-level only, no people entities.
- Correspondence log per application: **Gmail OAuth import with a human review queue**, IMAP, Microsoft Graph.
- Attachments per application with purpose metadata and AI-inclusion toggles.
- CV platform: upload → OCR/extraction → structured parsing → per-job tailored CV drafts → templated PDF via Playwright.
- AI drafts: cover letters, recruiter messages, follow-up drafts, job summaries, match scoring, interview prep.
- Rules engine (auto-ghosting), reminder emails, daily JSON export, event trail, automated DB backup.
- Admin: user management, audit log, system readiness.
- Production: `https://jobs.cesnimda.uk` via Gitea Actions → SSH → Docker Compose.

**Product hierarchy** (from `docs/MASTER_IMPLEMENTATION_GUIDE.md` — job tracking is the core; career tools support it):
Job Tracking → Applications → Workflow → Follow-ups → Communication, then Career Profile → Master CV → CV Builder → Cover Letters → Portfolio → Interview Prep, then Job Discovery.

---

## 2. Architecture overview

```mermaid
flowchart LR
    subgraph Client
        UI[React 19 SPA<br/>MUI 7, react-router 6<br/>Next.js 16 CSR shell]
    end

    subgraph Frontend container
        NGINX[nginx 1.29-alpine<br/>serves static export + proxies /api]
    end

    subgraph Backend container
        API[ASP.NET Core net9.0<br/>JobTrackerApi host]
        BG[7 hosted services:<br/>Rules, FollowUpReminder, DailyExport,<br/>JobEnrichment, SummarizerProbe,<br/>CvProcessing, DatabaseBackup]
        DB[(SQLite default<br/>or MariaDB/MySQL)]
        FS[/Data root:<br/>Attachments, CvArtifacts,<br/>exports, DP keys/]
    end

    subgraph AI stack
        AISVC[FastAPI ai-service :8001<br/>distilbart summarizer,<br/>OCR, docx/pdf extraction]
        PROV[Provider via AI_PROVIDER env:<br/>ollama qwen2.5:7b / gemini / groq]
    end

    EXT1[Google OAuth / Gmail API]
    EXT2[Microsoft Graph / IMAP]
    EXT3[Job sites: Finn, NAV,<br/>LinkedIn, Jobbnorge]
    EXT4[SMTP]
    EXT5[LibreTranslate optional]

    UI --> NGINX --> API
    API --> DB
    API --> FS
    API --> AISVC --> PROV
    API --> EXT1
    API --> EXT2
    API --> EXT3
    API --> EXT4
    API --> EXT5
    BG --> DB
```

### Solution layout

| Project | Role |
|---|---|
| `JobTrackerApi/` | ASP.NET Core host plus its controllers, services, EF models, `JobTrackerContext`, migrations, and Dockerfile. |
| `JobTrackerApi.Tests/` | xUnit, 36 test files incl. authorization + hostile-fixture suites. |
| `job-tracker-ui/` | React SPA inside a Next.js shell. |
| `tools/summarizer/` | FastAPI AI service (own Dockerfile, pytest tests). |
| `tools/hostile-fixture-db/` | Security test fixture generator. |
| `deploy/`, `.gitea/workflows/` | Prod deploy script + CI/CD. |

> Corrected 2026-07-31: the transitional `JobTrackerBackend` link-compilation project and root
> `Models/`/`Data/` directories were retired. Source now compiles from the project that owns it.

---

## 3. Technology stack

**Backend:** ASP.NET Core net9.0, EF Core 9 (SQLite default; Pomelo MySQL/MariaDB via `Database:Provider`), ASP.NET Identity Core, JWT bearer (smart policy scheme: local + Google), built-in RateLimiter, DataProtection (file-system keys), Playwright (PDF export).

**Frontend:** **Next.js 16** + React 19 + **TypeScript 5.9** + MUI 7 (+ x-data-grid, x-date-pickers, lab), axios, react-router-dom 6, @tanstack/react-table, i18n EN + NB (custom provider), Jest/RTL.

> Corrected 2026-07-31: the CRA migration is complete; direct Jest/Babel configuration replaced `react-scripts`.

**AI:** FastAPI + transformers (`sshleifer/distilbart-cnn-12-6`) for summaries; pytesseract/PyMuPDF/pypdf/python-docx for extraction/OCR; **one** generation provider selected by the `AI_PROVIDER` env var ∈ {`ollama` (default, `qwen2.5:7b`), `gemini`, `groq`}; TTL cache.

**Infra:** Docker Compose (backend, frontend/nginx 1.29-alpine, ai-service, ollama opt-in via `bundled-ollama` profile w/ GPU), Gitea Actions CI → SSH deploy → `deploy/deploy.sh`, external `jobtracker_shared` network.

---

## 4. Frontend architecture

**Two routing layers coexist intentionally:**

1. **Next.js 16 App Router** (`app/layout.tsx`, `app/page.tsx`) — a thin shell that mounts a client-side app. The CRA→Next migration was a **CSR lift-and-shift**: no SSR, no server components, no Next routing, no data fetching. Next is effectively a build tool here. Static export → nginx.
2. **react-router-dom v6** — does the actual routing, in **two different patterns inside one file** (`src/App.tsx`): `createBrowserRouter` for public routes (`/`, `/login`, `/forgot-password`, `/reset-password`, `/verify-email`) and a nested `<Routes>` inside a catch-all `Shell` for authenticated routes.

Development leaves static-export mode disabled and rewrites deep links to the root shell; production exports that shell and nginx falls back to `index.html` for unknown paths.

**Routes** (`src/App.tsx`): public — `/`, `/login`, `/forgot-password`, `/reset-password`, `/verify-email`. Authenticated — `/dashboard`, `/jobs`, `/reminders`, `/kanban`, `/companies`, `/correspondence`, `/correspondence/review`, `/profile`, `/career`, `/trash`, `/settings`, `/settings/connected-accounts`, `/admin/{audit,users,system}`.

> `/register` reuses the hardened auth form. Submission remains disabled until registration is enabled by production configuration (§5).

**State management: none.** No Redux/Zustand/React Query. Local `useState` + `axios` per component, with a hand-rolled `refreshToken` counter threaded through props. Two workspace-cache hooks exist (`components/job-workspace/useWorkspaceTabCache.ts`, `useJobWorkspaceBaseData.ts`). This is the root cause of the oversized components below.

**Styling:** MUI `sx` + custom `src/theme.ts` (439 lines), light/dark. Design tokens live inline in component `sx` props rather than in the theme (e.g. the same `boxShadow: "0px 8px 24px -12px rgba(15,23,42,0.12)"` is repeated across pages). There is no component primitives layer and no Storybook.

**Oversized components** (refactor targets, in order): `JobDetailsDialog.tsx` (1400), `CareerProfilePage.tsx` (1293), `JobTable.tsx` (786), `Correspondence.tsx` (732), `DashboardView.tsx` (666), `AdminSystemPage.tsx` (623), `AddJobModal.tsx` (618). (`ProfilePage.tsx` was 1368; Phase 2.2 split it — see §4a.)

---

## 4a. Profile / Career separation (Phase 2, 2026-07-17)

`/profile` and `/career` were one 1368-line component (`ProfilePage`) forked by a `careerOnly`
boolean. Phase 2 scoped their saves; Phase 2.2 split them into two dedicated components. This is
the reference model for how account identity and the master career profile relate.

### Components & routes

| Route | Component | Owns |
|---|---|---|
| `/profile` | `views/ProfilePage.tsx` (~490 lines) | Account identity + security + preferences |
| `/career` | `views/CareerWorkspacePage.tsx` → `views/CareerProfilePage.tsx` (~1293 lines) | The **master career profile** — the single editable source of truth |

`CareerWorkspacePage` is a thin shell (heading + source-of-truth notice) around `CareerProfilePage`.
The CV Builder is a separate routed workspace and consumes the Career Profile as its source of truth.

### Request flow

```
/profile  → ProfilePage
    load:  GET  /auth/me                              (account row only)
    save:  PUT  /auth/profile { email, userName, firstName, lastName, displayName }

/career   → CareerWorkspacePage → CareerProfilePage
    load:  GET  /auth/me
           GET  /profile-cv/runs                      (extraction history)
           GET  /jobapplications?…                    (for per-job CV tailoring context)
    save:  PUT  /auth/profile { profileCvText, profileCvStructureJson }
           (ProfileCvController paths additionally dual-write CareerProfileService —
            CareerProfiles / CareerProfileVersions — see §9 / §16)
```

### Data ownership (the invariant)

Both surfaces persist through the **same** endpoint, `PUT /auth/profile`, which does **partial
updates** (`AuthController.UpdateProfile`): a field is touched only if the request carries it —
`null`/omitted leaves it unchanged, `""` clears it, a value sets it. Email and UserName are never
cleared (login identifiers).

| Field(s) on `ApplicationUser` | Owner (only surface that writes them) |
|---|---|
| `Email`, `UserName`, `FirstName`, `LastName`, `DisplayName`, `AvatarImageDataUrl`, password, TOTP/2FA, linked OAuth accounts | **/profile** |
| `ProfileCvText`, `ProfileCvStructureJson` (the master career profile) | **/career** |

Because the endpoint is partial, `/profile` saving identity does **not** null the master profile,
and `/career` saving the profile does **not** null identity. This is enforced by tests
(`AuthAndSystemControllerTests`: identity-save-keeps-CV, career-save-keeps-identity, empty-clears,
null-leaves).

### API responsibilities

- `AuthController.UpdateProfile` (`PUT /auth/profile`) — partial update of the account row; the
  single write path for both surfaces. Local accounts only.
- `AuthController` (`GET /auth/me`) — returns the whole account row; each surface reads the fields
  it owns.
- `ProfileCvController` — CV ingest/parse/rewrite/export and the `CareerProfileService` dual-write.
  Called from `/career`.
- `JobApplicationsController` — `/career` reads job list for tailoring context only.

### Future extension points

- **`CareerProfilePage` is the foundation for all future career outputs** (Phase 3/4): CV Builder,
  tailored CVs, cover letters, portfolio, interview prep. They attach here, referencing the master
  profile — never duplicating it (per `docs/MASTER_IMPLEMENTATION_GUIDE.md`).
- **Source-of-truth flip (F5):** today `ProfileCvStructureJson` is authoritative and
  `CareerProfileService` mirrors it. A later phase makes `CareerProfiles`/`CareerProfileVersions`
  authoritative; the `/career` save would then route through `CareerProfileService` rather than the
  blob column. The partial-update endpoint and the ownership split above do not change.
- **Full decomposition (roadmap 2.2 residue):** `CareerProfilePage` is still large because it owns
  the whole master-CV surface; the CV Builder work will extract sub-components from it.

---

## 5. Authentication & authorization

- **Smart policy scheme:** inspects the bearer token issuer — Google ID tokens (`accounts.google.com`) → `google` handler (validated against `Auth:GoogleClientId`); everything else → `local` JWT (symmetric `Auth:JwtKey`, issuer/audience validated, 2-min clock skew).
- **Cookie sessions:** local handler also reads `jobtracker_auth` (HttpOnly, SameSite=Lax, Secure-configurable, 30d when persistent). **CSRF double-submit** middleware enforces cookie+header match on all mutating requests when a session cookie is present (login/register/reset/csrf exempt).
- `Auth:Require=true` sets a fallback authorize-all policy (prod compose sets it). Dev without a JWT key generates an ephemeral key + warning; **fails closed** if auth is required but unconfigured.
- Local tokens **must** carry a subject claim (`LocalAuthIdentity`), enforced in `OnTokenValidated` — hardened after finding M013-2.
- **Multi-tenancy:** every tenant entity carries `OwnerUserId`; `JobTrackerContext` applies global query filters `CurrentUserId != null && OwnerUserId == CurrentUserId` (**deny-on-null**). Correspondence/JobEvent/CV entities filter through their parent's owner. Covered by `JobApplicationsAuthorizationTests`, `OwnershipGuardTests`.
- Roles via Identity: admin-only `UsersController`, `AdminAuditController`, `AdminSystemController`.
- **2FA:** TOTP (`Otp.NET`), encrypted secrets, QR enrolment (`QRCoder`), recovery codes, trusted devices (`jobtracker_td` cookie), pending-token flow.
- **Sessions:** `UserSession` entity + `SessionsController` — list/revoke active sessions.
- Password policy: min 8, digit + lowercase. Reset via emailed token (SMTP required).
- **Registration is disabled by default** — `AuthController.cs:135` reads `Auth:AllowRegistration` defaulting to `false` and returns HTTP 403. There is no CAPTCHA anywhere.
- **Rate limiting:** login, auth email, 2FA challenge, and anonymous public-CV PDF export have dedicated fixed-window policies. AI usage is bounded by per-account monthly generation and token ceilings rather than request-window throttling.

---

## 6. Database

EF Core, **11 migrations**. App DbSets + Identity tables.

```mermaid
erDiagram
    ApplicationUser ||--o{ Company : owns
    ApplicationUser ||--o{ Job : owns
    ApplicationUser ||--o{ JobApplication : owns
    ApplicationUser ||--o| UserRuleSettings : has
    ApplicationUser ||--o{ GmailConnection : has
    ApplicationUser ||--o{ CvUploadArtifact : owns
    ApplicationUser ||--o{ CvExtractionRun : owns
    ApplicationUser ||--o{ UserSession : has
    ApplicationUser ||--o{ TrustedDevice : has
    Company ||--o{ Job : "posts"
    Company ||--o{ JobApplication : "has jobs"
    Job ||--o{ JobApplication : "applied to via"
    JobApplication ||--o{ Correspondence : messages
    JobApplication ||--o{ Attachment : attachments
    JobApplication ||--o{ JobEvent : events
    JobApplication ||--o| TailoredCvDraft : "1:1 draft"
    CvUploadArtifact ||--o{ CvExtractionRun : "source of"
```

**Entities:** `Company`, **`Job`**, `JobApplication`, `Correspondence`, `GmailConnection`, `GmailReviewDecision`, `MicrosoftGraphConnection`, `ImapConnection`, `Attachment`, `RuleSettings`, `UserRuleSettings`, `SystemEmailSettings`, `JobEvent`, `CvUploadArtifact`, `CvExtractionRun`, `TailoredCvDraft`, `TwoFactorRecoveryCode`, `TrustedDevice`, `UserSession`.

**Key notes:**
- `ApplicationUser` (IdentityUser) also stores `ProfileCvText`, **`ProfileCvStructureJson`** (the master career profile — a JSON blob, not relational), `AvatarImageDataUrl` (base64 in a column, on the `/auth/me` hot path), Google/Microsoft link info, TOTP secrets, current CV artifact/run pointers.
- **`Job` vs `JobApplication`** — `Job` is the opportunity (title, company, description, URL, salary, location, deadline, tags); `JobApplication` is the user's pursuit of it (status, dates, follow-ups, correspondence, attachments). `JobApplication.JobId` remains nullable for compatibility, but startup backfills missing links and persistence paths synchronize both copies. Legacy reads and columns remain until the production cutover report passes. See §16 and `docs/decisions/ADR-002-job-application-model.md`.
- Salary is **structured**: `SalaryMin`, `SalaryMax`, `SalaryCurrency`, `SalaryPeriod` (plus a legacy free-text `Salary`).
- `Tags` is a **JSON array in a string column** — not queryable; `/tags` and `/tag-trends` must scan.
- Denormalized `HasResume`/`HasCoverLetter`/`HasPortfolio`/`HasOtherAttachment` flags duplicate `Attachments`; kept honest by `AttachmentFlagsRecomputeTests`.
- CV extraction runs retain the newest 20 completed runs per user; expired runs and unreferenced upload artifacts are pruned while the current artifact is preserved.
- `Status` is free-text at the DB level; canonicalized only in the application layer by `JobPipeline.Normalize` — deliberately, so custom user values are never destroyed.
- Indexes include owner-prefixed list/board/reminder composites `(OwnerUserId, IsDeleted)`, `(OwnerUserId, IsDeleted, Status)`, and `(OwnerUserId, FollowUpAt)`, plus CV and provider-specific indexes.
- SQLite lives at `DataRoot/jobtracker.db` (WAL); migrations and legacy-schema reconciliation run at startup through `StartupInitializationExtensions`.

> Corrected 2026-07-31: the model snapshot is current and the API directly carries the EF Design package and `JobTrackerContext`; no temporary project edit is required for `dotnet ef`.

---

## 7. API surface (19 controllers, all under `/api`)

| Controller | Lines | Highlights |
|---|---|---|
| `JobApplicationsController` | **2394** | **37 endpoints.** CRUD, paging/filter/sort, board, reminders, stats, analytics, history, status/follow-up PATCH, soft delete/restore, duplicate-check, **plus** the AI surface: match-score, candidate-fit, focus-plan, generated interview-prep brief, readiness, tailored-CV draft/preview/export/generate, application-drafts, application-package, follow-up drafts + send, ai-metrics. The canonical timeline and editable interview board live in their focused controllers. |
| `ProfileCvController` | **2249** | CV upload artifacts, extraction runs, structure parsing, reprocess/rebuild/improve, rewrite-section, rewrite-preview, templates, Playwright PDF export, benchmark harness. |
| `GmailController` | **1023** | OAuth connect/callback, sync, review queue, import decisions, job matching. |
| `AuthController` | **879** | login/register/me/config, Google + Microsoft exchange and link/unlink, avatar, password change/reset, email verification, session cookie + CSRF. |
| `AdminSystemController` | 342 | System readiness (DB/Gmail/AI). |
| `TwoFactorController` | 341 | TOTP enrol/verify/disable, recovery codes. |
| `AttachmentsController` | 342 | Validated staged upload, managed-root download, metadata-only rename, quarantined delete, purpose/AI-inclusion metadata and restart reconciliation. See `attachment-storage.md`. |
| `UsersController` | 229 | Admin user/role management. |
| `AdminAuditController` | 219 | Audit trail. |
| `CorrespondenceController` | 185 | Per-job messages CRUD. |
| `CompaniesController` | 150 | CRUD, idempotent create-by-name, recruiter/pipeline fields. |
| `MicrosoftGraphController` | 150 | Outlook/M365 mail linking. |
| `SessionsController` | 104 | List/revoke sessions. |
| `ExportController` | 102 | JSON/CSV export. |
| `RulesController` | 101 | Global + per-user rule settings, clamped. |
| `ClientErrorsController` | 100 | Frontend error intake → logs. |
| `ImapController` | 96 | IMAP mail linking (SSRF-guarded). |
| `BackupController` | 89 | Manual backup trigger. |
| `JobImportController` | **27** | **One endpoint:** `POST /preview`. URL parse only — no persistence, no import history. |

**God controllers are a top debt.** `JobApplicationsController` and `ProfileCvController` mix HTTP, business logic, AI prompt construction, and persistence. `docs/MASTER_IMPLEMENTATION_GUIDE.md` forbids exactly this ("Avoid: Massive controllers"). Refactor needs test cover first — the tests exist.

**OpenAPI** is wired (`AddOpenApi` / `MapOpenApi`) but **dev-only** — guarded by `app.Environment.IsDevelopment()`, not exposed in production.

---

## 8. Background services (7 hosted services)

| Service | Function |
|---|---|
| `RulesHostedService` → `RulesEngine` | Periodic auto-transitions (e.g. → Ghosted) from rule settings |
| `FollowUpReminderHostedService` | Reminder emails for due/upcoming follow-ups (dedup via `LastReminderEmailSentAt`) |
| `DailyExportHostedService` | Daily JSON export at a configured local hour |
| `JobEnrichmentHostedService` | Backfills summaries/enrichment |
| `SummarizerProbeHostedService` | Probes AI service readiness |
| `CvProcessingHostedService` + `CvProcessingQueue` | Process-local wake-up queue for CV extraction; queued/running database work is recovered at startup |
| `DatabaseBackupHostedService` → `DatabaseBackupRunner` | Automated DB backup (`VACUUM INTO`, server-derived path) |

Rules, reminders, daily export and enrichment now enumerate owners explicitly, then re-enter normal tenant-filtered scopes. All four have deny-by-default worker switches and remain inactive until their notification/privacy/entitlement/operations prerequisites are ready; see `docs/architecture/background-workers.md`. Caches and worker coordination are process-local. CV work itself is durable and recovered after restart, but the worker remains a deliberate single-instance design without database leasing.

---

## 9. AI pipeline

**Architecture:** the backend does **not** call any LLM in-process. It HTTP-calls a FastAPI sidecar (`tools/summarizer/app.py`) exposing `/health`, `/cv/normalize`, `/cv/classify-block`, `/cv/rewrite`, `/summarize`, `/extract-text`. The sidecar picks **one** provider from the process-wide `AI_PROVIDER` env var ∈ {`ollama`, `gemini`, `groq`}.

> **Important — `docs/00-ai-context.md` is wrong about this.** It describes a provider interface fanning out to OpenAI/Gemini/Claude/Ollama, admin-controlled, with users never locked to one model. **None of that exists.** There is one env var, one provider per deployment, no OpenAI, no Claude, no admin control, no per-user selection. Product decision 2026-07-17: **the docs get fixed, the abstraction does not get built** — revisit only if a customer asks.

**Data flow:**
1. **Job import:** URL → plugin parse (Finn/NAV/LinkedIn/Jobbnorge) or universal JSON-LD parser → optional LibreTranslate → language detect + skill tagging → preview → user accepts → persisted.
2. **Summaries:** `SummarizerService` → `/summarize` (distilbart, TTL-cached, GPU if available) → persisted `ShortSummary`.
3. **CV ingest:** upload (PDF/DOCX/image) → `/extract-text` (OCR) → block classification (`CvAiClassifier`/`CvAiNormalizer` via `/cv/classify-block`) → `StructuredCvProfile` → `ProfileCvStructureJson` on the user.
4. **Tailoring:** job description + structured CV → `/cv/rewrite` → `TailoredCvDraft` (separate entity, per application) → `CvTemplateRenderer` → Playwright → PDF.
5. **Drafts:** cover letter / recruiter message / follow-up per job, attachment-aware context selection.

**Invariant that holds:** the master profile is **never** auto-overwritten. Tailored output lands in `TailoredCvDraft`, a separate entity. This is the most important documented rule and it is correctly implemented — do not break it.

**Degradation:** if the AI service or provider is down, core tracking still works (probe service; AI is not a deploy gate).

**CV templates are hardcoded** — `CvTemplateRenderer.Render` is a C# `switch` over 6 template IDs (`ats-minimal`, `harvard`, `auckland`, `edinburgh`, `monarch`, `fjord`), each a function interpolating HTML strings, with booleans like `roundedPhoto`/`curvedHeader`. **There is no theme model and nothing is user-customisable.** This is a structural dead end for the CV Builder — see `docs/application-discovery-report.md` §10.

---

## 10. Email

`SmtpEmailSender` + `EmailSettingsResolver`: config from env/appsettings **or** DB-stored `SystemEmailSettings` (admin-editable). Gmail SMTP + app password in prod. Flows: password reset, email verification, follow-up reminders. `App:PublicBaseUrl` is the canonical external origin for generated links, OAuth callbacks, billing redirects, secure cookies and production Host validation.

Inbound: `GmailOAuthService` (655), `MicrosoftGraphOAuthService` (507), `ImapService` (345, SSRF-guarded).

---

## 11. Configuration & secrets

- `.env` (git-ignored) → docker-compose env → ASP.NET config. `.env.example` documents the shape.
- `appsettings.Development.json` holds only `CHANGE_ME_*` placeholders.
- Key knobs: `Database:Provider`, `ConnectionStrings:JobTracker`, `Data:Root`, `Cors:Origins`, `Ai:BaseUrl`, `Summarizer:BaseUrl`, **`Ai:ServiceToken`**, `Auth:*` (incl. `Auth:AllowRegistration`), `Email:*`, `Exports:*`, `App:*`, `HttpsRedirection:*` (TLS terminated at the reverse proxy).
- AI service knobs (compose): `AI_PROVIDER`, **`AI_SERVICE_TOKEN`**, `OLLAMA_BASE_URL`, `OLLAMA_MODEL`, `GEMINI_API_KEY`, `GROQ_API_KEY`.
- **`AI_SERVICE_TOKEN` is mandatory.** Both `Ai__ServiceToken` (backend) and `AI_SERVICE_TOKEN` (ai-service) use `${AI_SERVICE_TOKEN:?...}`, so `docker compose up` fails loudly rather than booting an unauthenticated AI service. Generate with `python -c "import secrets; print(secrets.token_hex(32))"`. Rotating it requires recreating **both** containers together — they must agree.
- Note both those compose entries are **quoted**: the `:?` error message contains a colon-space, which YAML would otherwise parse as a map (`services.backend.environment.[20]: unexpected type map[string]interface{}`).
- `ProductionConfigTests.cs` guards prod config shape.
- Ollama is **intentionally not bundled by default** (`bundled-ollama` compose profile) so deploys reuse a shared instance. `AI_PROVIDER=gemini` exists specifically to offload a weak local GPU in prod.

---

## 12. Build, CI/CD, deployment

- **CI is Gitea, not GitHub** — `.gitea/workflows/ci-deploy.yml`. **There is no `.github/` directory.**
- On PR + push-to-main: build backend (Release) → run **all** backend tests → `npm ci` → run the **whole** frontend suite → build frontend.

  > Corrected 2026-07-17: the archived overview said CI runs "an explicit whitelist of 10 frontend test files". **The whitelist is gone.** The workflow now runs `npm test -- --watchAll=false --runInBand` and carries a comment forbidding its return: the previous whitelist "silently skipped new suites and let two regressions reach main."

- The workflow is heavily defended against a flaky self-hosted runner: dotnet install retry, `npm ci` SIGSEGV retry, frontend build OOM retry.
- **Deploy** (push to main only): SSH to prod → `git reset --hard <sha>` in `/opt/job-tracker/app` → `deploy/deploy.sh` (compose build/up with retry + cache-prune fallbacks) → verify containers. AI health is non-blocking.
- **No staging environment.** Deploys go straight to prod after CI.

---

## 13. Testing

- **Backend:** xUnit integration-style via `TestHostFactory`. 36 test files. Notable: `JobApplicationsAuthorizationTests`, `OwnershipGuardTests`, `ImapServiceSsrfGuardTests`, `ProductionConfigTests`, `AttachmentFlagsRecomputeTests`, `CvCorpusHarnessTests`, `SqliteMigrationHelperTests`, `JobPipelineTests`, plus a `tools/hostile-fixture-db` project.
- **Frontend:** ~20 Jest/RTL files — **all run in CI**.
- **AI service:** pytest (`tools/summarizer/tests/`).
- **Browser smoke:** Playwright drives login/session cookies, saved-job creation, Career Workspace routing, and anonymous public-CV rendering/PDF download against isolated API/SQLite and Next.js processes. CI installs Chromium and runs all four flows.
- **Gap:** no load/performance suite. CI reports NuGet transitive vulnerabilities and production npm audit findings.

---

## 14. Logging & error handling

Development uses simple console/debug logging; production emits structured JSON. Middleware records method, path, status, duration, trace ID, and subject. Unhandled errors return Problem Details with the same trace ID. Client errors POST to `/api/client-errors`; the frontend has an `ErrorBoundary` and route error page.
Compose bounds each container's local logs to three 10 MB files. There is still no external sink (Seq/OTLP) or cross-host aggregation.

---

## 15. Security posture

**Verified strong:**
- Multi-tenancy via deny-on-null global query filters, with a dedicated authorization test suite.
- CSRF double-submit on mutating requests; HttpOnly SameSite=Lax session cookie.
- Auth fails closed when required but unconfigured; subjectless-JWT rejected (M013-2).
- SSRF on job import and IMAP fixed and retested (DNS resolution check, private/loopback/link-local rejection, redirects disabled).
- Rate-limited login/email/2FA endpoints; Identity PBKDF2 hashing.
- OpenAPI dev-only. `.env` git-ignored; DP keys and runtime exports untracked (`519c32e`).
- 2FA + recovery codes + trusted devices + session revocation.
- **AI sidecar: backend-only.** Unpublished, on a private two-member network, and token-authenticated (§16). Verified against the running stack, not just configured.

**Findings status** (detail in `docs/application-discovery-report.md` §12 and `docs/phase-0-foundation-report.md`):

| Sev | Finding | Status |
|---|---|---|
| Medium | DataProtection keys recoverable from git history (`519c32e`, `955cae6`) | **Closed 2026-08-02 — production keys rotated by the operator** |
| Medium | Wildcard credentialed CORS configuration | **Closed — startup rejects it** |
| Medium | AI cost ceiling | **Closed — monthly generation/token limits are enforced by plan** |
| Low | Public-registration abuse control | Implemented with Turnstile; production keys/configuration still required |
| Low | Unbounded storage | **Closed — attachment quotas, extraction/artifact pruning, file-backed avatars, and PDF-export retention are implemented** |
| Low | Backup / DPAPI is Windows-oriented — verify behaviour on Linux prod | Unverified |
| Low | No dependency CVE scanning in CI | **Closed — NuGet and production npm audit reporting are in CI** |

---

## 16. Phase 0 changes (2026-07-17)

Full record: `docs/phase-0-foundation-report.md`. What changed architecturally:

- **AI sidecar secured — three layers, verified against the running stack (2026-07-17):**
  1. **No host port.** `ports: "8001:8001"` removed; `expose:` only.
  2. **Private network.** `ai-service` sits on a new `ai_internal` bridge and **nothing else**. It was removed from `default` (which the frontend shares) and from `shared_services` — the latter is `external: true` (`jobtracker_shared`), so any other compose stack on the host could join it and reach port 8001. `ai_internal` has exactly two members: `ai-service` and `backend`. It is **not** `internal: true`, because ai-service needs egress to Gemini/Groq.
  3. **Shared secret.** `X-Ai-Service-Token` required on every endpoint except `/health`, compared with `hmac.compare_digest`. Backend sends it via `Ai:ServiceToken`; sidecar reads `AI_SERVICE_TOKEN`. Unset = open (local dev/tests), but compose declares both with `:?` so the stack **refuses to start** without it.

  **Only the backend can reach the AI service.** Verified live: host → connection refused; frontend container → cannot even resolve `ai-service`; unauthenticated calls to `/summarize`, `/cv/rewrite`, `/extract-text` → 401; wrong token → 401; backend (172.23.0.3) with token → **200 OK**.

  > If you point `OLLAMA_BASE_URL` at an Ollama in **another** compose stack, address it by host IP (e.g. `http://<host-ip>:11435`) — `ai-service` can no longer resolve container names on `shared_services`, by design. The bundled `ollama` profile is on `ai_internal` and still works by name.
- **Pipeline expanded beyond `Applied`** — `JobPipeline` now models pre-application stages (`Saved`, `Interested`, `Preparing`) in a new `PipelineCategory.Prospect`, so a job can be tracked before it is applied to. `Saved` is the new default for wizard-created jobs; `Applied` remains the default for the legacy create path.
- **`DateApplied` is nullable** + `SavedAt` added — a saved job no longer carries a fabricated application date.
- **`Job` entity introduced** alongside `JobApplication`; linked rows are now created and backfilled, while reads remain on the legacy columns pending production cutover validation.

---

## 17. Known debt

The live, prioritized ledger is `docs/architecture/technical-debt.md`. The principal remaining items are the legacy `JobApplication` opportunity columns, single-replica worker coordination, and optional cross-host log aggregation. Large orchestration files are refactored only when a cohesive behavior change provides a safe seam.

---

## 18. Historical decisions worth knowing

Recorded nowhere else in active docs:

1. `Status` is free-text and canonicalized in the application layer **specifically so custom user values are never destroyed** (`JobPipeline.cs` docstring). Deliberate; do not "fix" it with a DB enum.
2. The CI frontend-test whitelist was removed after it "silently skipped new suites and let two regressions reach main."
3. Ollama is intentionally not bundled by default so deploys reuse a shared instance.
4. `AI_PROVIDER=gemini` exists to offload a weak local GPU in prod.
5. `TailoredCvDraft` is a separate entity specifically to guarantee the master CV is never auto-modified.
