Files
jobtrackingapp/docs/architecture/current.md
T

32 KiB

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

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.tsxviews/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 defaultAuthController.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.

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 JobApplicationJob 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
RulesHostedServiceRulesEngine 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
DatabaseBackupHostedServiceDatabaseBackupRunner 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) → StructuredCvProfileProfileCvStructureJson on the user.
  4. Tailoring: job description + structured CV → /cv/rewriteTailoredCvDraft (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 hardcodedCvTemplateRenderer.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/appdeploy/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 AppliedJobPipeline 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.