Files
jobtrackingapp/docs/architecture/current.md
T
cesnimda ce76046a29 feat: complete release readiness work
- 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
2026-07-31 16:54:16 +02:00

31 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 so deep links reach the client router; production exports one 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). Introduced in Phase 0 as an additive step: JobApplication.JobId is a nullable FK and JobApplication still carries its original opportunity columns for backwards compatibility. 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 2313 38 endpoints. CRUD, paging/filter/sort, board, reminders, stats, analytics, history, timeline, status/follow-up PATCH, soft delete/restore, duplicate-check, plus the whole AI surface: match-score, candidate-fit, focus-plan, interview-prep, readiness, tailored-CV draft/preview/export/generate, application-drafts, application-package, follow-up drafts + send, ai-metrics.
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 245 Multipart upload to disk, download, rename, delete, purpose/AI-inclusion metadata.
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)

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 builds links.

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) Open — rotation required, needs an 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 (additive; JobApplication.JobId nullable FK). No behaviour moved yet — this only makes the split possible.


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.