# Architecture A high-level map of how career-ops is put together. For the precise system/user file boundary, see [DATA_CONTRACT.md](DATA_CONTRACT.md); for contribution mechanics, see [CONTRIBUTING.md](CONTRIBUTING.md); for runtime flow diagrams (evaluation steps, batch processing), see [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md). ## Principles Career-ops is built on three commitments that every design decision serves: - **Local-first.** Everything runs on your machine against your files. No account required, no server in the loop for the core tool. - **AI-agnostic.** The logic lives in Markdown prompt files under `modes/`, executed by whatever AI coding CLI you use (Claude Code, Codex, OpenCode, Gemini, Qwen, Grok, Antigravity) or by standalone Node scripts. No single model is hardcoded. - **Human-in-the-loop.** The tool prepares and evaluates; the human reviews and clicks. It never submits applications on your behalf. ## The two layers (the data contract) The single most important architectural rule: **system files** and **user files** are strictly separated. - **System layer** — the tool itself: `modes/`, scripts (`*.mjs`), templates, the dashboard. These are versioned and updated by `update-system.mjs`. Listed in `SYSTEM_PATHS`, in `lib/update-manifest.mjs`. - **User layer** — your data: `cv.md`, `config/profile.yml`, `modes/_profile.md`, `data/`, `reports/`, `jds/`, etc. The updater **never** touches these. Listed in `USER_PATHS`. `DATA_CONTRACT.md` is the source of truth for this boundary, and `updater-migration-tests.mjs` enforces that no system path ever overlaps a user path. ## Files are canonical — databases are derived Settled doctrine ([#918](https://github.com/career-ops-hq/career-ops/issues/918)): the human-readable, git-diffable files (`data/applications.md`, `reports/`, `data/pipeline.md`) are the **permanent source of truth**. SQLite exists only as a derived index (fast queries, reindex-on-delete) and will never become a primary store — not even opt-in. The reason is ecosystem-wide: the web UI, the Go dashboard, community plugins, and thousands of fork scripts all read the files; a second canonical store would force every reader to support two modes forever. Performance work is welcome **on the derived layer**; the files stay the brain. ## Why the flat root The repo keeps its scripts at the root deliberately ([#1386](https://github.com/career-ops-hq/career-ops/issues/1386)). Path stability is a feature here, not an accident: the updater's `SYSTEM_PATHS` allowlist, community plugins, docs, guides, and the muscle memory of thousands of users (`node scan.mjs`) all reference these paths. A cosmetic reorganization would break forks and plugins for no functional gain. The conventions that keep the flat root navigable: one script = one job, and every script is registered in `SYSTEM_PATHS` (enforced in CI by the coverage guard). Tests are the one thing the flat root does not hold. Suites live in `tests/`, and `test-all.mjs` discovers `tests/**/*.test.mjs` — no registration list, so a new suite runs the moment it is written and a typo cannot silently turn CI green ([#1440](https://github.com/career-ops-hq/career-ops/issues/1440)). Write new ones there; `*.test.mjs` at the repo root is not discovered and will not run. Name a suite `{module}.test.mjs` when it covers exactly one root script. Name it for the behaviour it pins when it spans several scripts, or when it is one of a group carving up a large module — `outcome-tracker-local-dates.test.mjs`, `cover-unresolved-placeholders.test.mjs`. Behaviour naming is the common case by a wide margin: roughly four in five suites directly under `tests/` are named that way, so module naming is the exception and a behaviour name is not a deviation to be flagged in review. Neither form is enforced, because discovery is by glob — the name buys legibility and nothing else. `tests/providers/` is the one subtree where module naming is the norm, one suite per provider, alongside cross-provider behaviour suites like `ats-ssrf-hardening.test.mjs`; see `tests/README.md`. Discovery imports a suite in-process and shares its counters; a `node:test` suite is child-processed instead, so its results survive ([#2828](https://github.com/career-ops-hq/career-ops/issues/2828)). A discovered suite therefore reports through `pass`/`fail` from `tests/helpers.mjs` and never calls `process.exit()` or `finish()`, both of which would forge the run's verdict. This was three arrangements until recently — `tests/`, a `test/` directory, and suites sitting beside the scripts they covered — with the latter two registered by hand in `test-all.mjs`. Hand registration meant three suites shipped in `SYSTEM_PATHS` to every install while running nowhere ([#3247](https://github.com/career-ops-hq/career-ops/issues/3247), [#3303](https://github.com/career-ops-hq/career-ops/issues/3303)), which is what consolidated them ([#3306](https://github.com/career-ops-hq/career-ops/issues/3306)). Almost no suite is registered by hand any more. The residual is the `*-tests.mjs` naming convention: a few suites remain at the root, each registered by name in `test-all.mjs`'s `scripts` list alongside the `--self-test` entry points, or invoked by a workflow. Most stay for a stated reason — a flag-driven CI harness (`upgrade-tests.mjs`, run from `.github/workflows/test.yml`) and one that asserts on its own filename (`updater-migration-tests.mjs`). A per-script timeout is not a permanent reason to stay: a suite that imports `node:test` runs in a child process under `test-all.mjs`'s 30s cap, so a slow suite can be made fast and moved, as `tests/tracker-writer-lock.test.mjs` and `tests/tracker-columns.test.mjs` were ([#2906](https://github.com/career-ops-hq/career-ops/issues/2906), [#4759](https://github.com/career-ops-hq/career-ops/issues/4759); [#4010](https://github.com/career-ops-hq/career-ops/issues/4010), [#4758](https://github.com/career-ops-hq/career-ops/issues/4758)). So the convention is permanent rather than migration debt, and `tests/root-tests-registration.test.mjs` guards it: every root `*-tests.mjs` must be registered in the `scripts` list or invoked by a workflow ([#3735](https://github.com/career-ops-hq/career-ops/issues/3735)). The list here is by name rather than by count on purpose; a count goes stale the first time a suite is added. `tests/no-root-suites.test.mjs` separately keeps the root empty of `*.test.mjs`, where discovery does not reach. Playwright specs are the deliberate exception: `tests/cv-visual/` is named `*.spec.mjs`, which discovery does not match, and runs from `playwright.cv.config.mjs`. ## Component map