TreeMap
무료 오픈소스 디스크 사용량 시각화 도구로, 사각형 트리맵·중복 파일 탐색·디스크 가득 예측·정리 시 파일을 휴지통으로만 이동한다
Local-first AI desktop assistant that scrapes job boards, matches roles to your resume, and auto-generates resumes & cover letters. Offline with Ollama, BYO API key, or route it through an AI CLI agent (Claude Code, Codex, Gemini CLI).
저장소 README에서 가져옴
README 전체를 불러오는 중입니다. 아래는 저장된 발췌입니다.
Your local-first, AI-native desktop assistant for intelligent job searching, résumé & cover-letter generation, and assisted applications. Run it fully offline with Ollama, plug in your own OpenAI, Anthropic, or Gemini key, or route it through an AI CLI agent (Claude Code, Codex, Gemini CLI). ⬇️ Download the latest release · 🌐 Live site · 🎬 Short film · 📦 Install · ✨ Features · 📚 Docs No API key required to start. Run fully offline with Ollama, or add a cloud key later in Settings → AI. New here? See ** Installation ** for prerequisites and per-OS notes.README 전문 보기 ↗
# 🚀 Try it → download a build for your OS:
# https://github.com/saeedkolivand/ai-job-hunter-app/releases
#
# 🛠️ Develop it → run the full desktop app from source:
git clone https://github.com/saeedkolivand/ai-job-hunter-app.git
cd ai-job-hunter-app && pnpm install
ollama pull mistral # optional: a local model for the offline Ollama provider
pnpm dev # launches the Tauri app with hot reload
No API key required to start. Run fully offline with Ollama, or add a cloud key later in Settings → AI. New here? See Installation for prerequisites and per-OS notes.
AI Job Hunter is a desktop application built with Tauri (a Rust core with a React renderer) that brings AI-driven job hunting to your local machine. It scrapes 24 job boards (including an Adzuna/JSearch aggregator that covers walled boards like Indeed, Glassdoor, and Xing, plus company-scoped ATS boards like Greenhouse and Lever), semantically matches postings to your résumé, generates tailored cover letters and résumés with your AI provider of choice, drafts grounded answers to application questions, and tracks everything you apply to, all while keeping your data and credentials on your device.
Your résumés, generations, applications, and tracked job data live in a local SQLite database on your device (credentials live in the OS keychain); there is no app-operated backend collecting them, and no behavioural analytics. Two things happen automatically, without you asking: the app checks for updates (GitHub, 10 s after launch then every 4 h, transmitting only your app version and OS/architecture), and sends crash reports to Sentry (error + stack trace, OS, app version) when the app breaks — the latter is on by default, asked during setup, switchable off in Settings → Privacy, and redacted of paths, links, hosts, e-mails and credentials before sending. The app's other outbound calls go to services you configure or invoke: your AI provider (which receives the résumé and job text you ask it to generate from), job boards you scrape (including the Adzuna, JSearch/RapidAPI, Jooble, Apify and freehire aggregator tiers — freehire being the one that needs no key, tried last), an optional web search you explicitly enable (your provider's own, or Exa if you add a key), location autocomplete (Photon, with offline fallback), opt-in company-logo enrichment (Clearbit, default off), opt-in email-confirmation watching via IMAP (credential user-supplied and OS-keychain-backed; email content never leaves the device), opt-in self-hosted sync between your own devices (default off; it sends the same data an app backup holds — applications, documents, generations, contact profile, job preferences and AI provider routing, never passwords or API keys — only to a sync server you configure, with an OS-keychain-backed token), and your optional profile import from GitHub or LinkedIn. Neither the update check nor the crash report ever includes your documents or job data, and the browser extension is excluded entirely from all reporting; see ADR 0005.
/api/show, then set the context window + max output tokens per model, with a hardware-lag warning so large prompts aren't silently truncated.Resumé generation uses a staged pipeline where the model decides how to present verified candidate evidence, never what the candidate has done. Three structural enforcements make this real:
Grounding rule, enforced in Rust. Every claim in the output is traced back to the source resumé or job ad; the evidence map is selected by Rust from the source résumé itself, with no model call, so a quote is always source text. See apps/desktop/src-tauri/src/pipeline/resume/ (quality pipeline orchestration) and stages::evidence (deterministic evidence selection).
Every Critical is deterministic, never a model opinion. The validators in apps/desktop/src-tauri/src/validate/content/ check factual, structural, and language alignment deterministically — dropped roles, unsourced metrics, unsupported dates, language misalignment. A model cannot emit a Critical; a Critical means something is provably wrong in the source material. Warnings (unsourced terms, keyword density) are advisory; only Criticals park a run. See CONTENT_ISSUE_CODES for the registry and ADR-032 for the design.
Fabrication-review gate: refuse to save rather than save with a flag. When validation finds Criticals, the run parks at needsReview. The UI shows each flagged claim; you decide Remove (delete the line) or Keep. Nothing is silently dropped, and nothing is saved with an unresolved flag — every claim awaiting your verdict blocks export until you act. See FabricationReview (the review UI) and (the gate).
What is measured and what is not. The offline eval harness (tests/eval.rs) measures the deterministic validators against planted-defect fixtures: every planted issue must be recalled at its claimed severity, and truthful documents must raise zero Criticals. This is in CI. What is not measured: generation quality needs a live model (a live corpus of A/B runs); retrieval quality has no labelled dataset; search's dense arm re-orders keyword hits and only retrieves when keyword search finds nothing (see ADR-039 for search's exact scope). This repo's posture is: state which is which, measure the deterministic layer, and keep improving where the data allows.
Switch providers at any time in Settings → AI:
| Provider | Models | Notes |
|---|---|---|
| Ollama (local) | mistral, llama3.2, deepseek-r1, any Ollama model | No API key needed; fully offline; per-model context/output limits |
| OpenAI | GPT-4o, o-series, GPT-4 Turbo | Requires API key |
| Anthropic | Claude (Sonnet / Opus), extended thinking | Requires API key; reasoning streamed to the thinking view |
| Google Gemini | Gemini 2.5 / 1.5 (Pro, Flash) | Requires API key; thinking models supported |
| OpenAI-compatible | Any (LM Studio, vLLM, remote Ollama, …) | Custom base URL |
| CLI agents (local) | Claude Code, Codex, Gemini CLI | Run headless via the installed CLI: no API key (uses the CLI's own login) |
API keys are stored encrypted in the OS keychain. CLI agents run as a headless subprocess and reuse whatever login that CLI already has, so they need no key in the app.
Grab the latest installer for your OS from the Releases page.
macOS: open the .dmg and drag the app into Applications. Because the app isn't notarized by Apple, Gatekeeper may refuse to open it the first time ("app is damaged and can't be opened"). Clear the quarantine attribute once:
xattr -cr "/Applications/AI Job Hunter.app"
Windows / Linux: run the installer / AppImage from the Releases page.
Releases ship signed macOS .dmgs, and the repo doubles as its own Homebrew tap (the Casks/ai-job-hunter.rb cask). Install with:
brew tap saeedkolivand/ai-job-hunter-app https://github.com/saeedkolivand/ai-job-hunter-app
brew install --cask ai-job-hunter
The cask clears the Gatekeeper quarantine flag for you (the app isn't notarized). Prefer a one-off download? Grab the .dmg straight from the Releases page.
The cask pins per-arch
sha256checksums for a verified install, tracking the latest release that ships macOS.dmgs. Since the installer build is manual, bump the caskversion+ both checksums when a newer build publishes dmgs (brew bump-cask-pr).
Prerequisites
| Requirement | Version | Notes |
|---|---|---|
| Node.js | 20+ | LTS recommended |
| pnpm | 11+ | npm install -g pnpm |
| Rust toolchain | stable | rustup install stable |
| Ollama | latest | ollama.com, for local AI |
git clone https://github.com/saeedkolivand/ai-job-hunter-app.git
cd ai-job-hunter-app
pnpm install
# Pull a local model (optional — only for the Ollama provider)
ollama pull mistral # or: ollama pull llama3.2
# Start the full Tauri desktop app with hot reload
pnpm dev
| Symptom | Fix |
|---|---|
| macOS: "app is damaged and can't be opened" | Not notarized. Clear quarantine once: xattr -cr "/Applications/AI Job Hunter.app" |
| No models in the picker / "select a model" | Start Ollama and ollama pull <model>, or add a cloud key in Settings → AI |
| Company research does nothing | It's opt-in and uses your AI provider's own web search; on Ollama add the free Ollama key (Settings → AI). For a provider with no search of its own, add an Exa key in the same place. Without a usable search the toggle reads off and generation proceeds without a brief |
| Scraping can't find a browser | The app auto-detects an installed Chromium browser (detect_system_chrome). If none is found, install Chrome or Edge, or set the CHROME env var to the browser executable path. |
pnpm dev fails to build the Rust core | Ensure the stable Rust toolchain is installed (rustup install stable) and re-run |
| Need to report a crash or bug | Open Support → Contact/Feedback → Export Bundle: saves ajh-diagnostics-<date>.zip (system info plus redacted crash/app logs; no résumés, API keys, job data, or database). Attach it to a new GitHub issue. For security vulnerabilities use SECURITY.md instead; do not post them publicly. |
1. Open the app → AI Generate
2. Paste your résumé text, or upload a PDF/DOCX/TXT file
3. Paste the job ad text, or upload a job description file
4. Click Continue → the app detects languages, role, company, top requirements
5. Choose a template + style; optionally enable "Research the company"
6. Generate → watch streaming output (with live reasoning) → export as DOCX / PDF / TXT
1. Autopilot → New → set board, query, location, schedule, filters
2. Run it → found jobs appear, scored and deduped (New badges on fresh results)
3. Open a found job → Tailor:
• generate a tailored résumé + cover letter (target: Both)
• pick application questions → get résumé-grounded answers
• the job flips to "Applied" and is saved in Documents → Activity
1. Jobs → Scrape → select boards (e.g. LinkedIn + Greenhouse + Aggregator)
2. Query + location; for company-scoped ATS boards (Greenhouse, Lever, etc.), enter company slugs
3. Click Start → results stream into the jobs table
4. Ranked search over the scraped postings — keyword by default, with vector similarity and LLM reranking *re-ordering* the results when semantic ranking is enabled
Note: Company-scoped ATS boards require company slugs instead of free-text keywords. The Aggregator (Adzuna + JSearch) replaces direct scraping of anti-bot boards (Indeed, Glassdoor, Xing); free Adzuna keys available at https://developer.adzuna.com. See docs/knowledge/scraping-domain.md for the full board list and configuration details.
Run commands from your shell or an LLM agent, with the desktop app already running:
# Find the best matches across all autopilots
ajh-tauri agent best-matches --limit 10
# Get full details for one job by URL
ajh-tauri agent job "https://example.com/job/123"
# Export your profile for autofill (requires opt-in consent)
ajh-tauri agent profile
# List all autopilots and their status
ajh-tauri agent automations
# Generic command dispatch (advanced: read/reversible commands, irreversible with proof)
ajh-tauri agent call "namespace:command" --input '{"key":"value"}'
# Show help
ajh-tauri agent --help
Platform-specific paths: The CLI is the same binary as the desktop app, no separate install needed. On Linux and Homebrew/macOS it is already on PATH. On macOS with dmg drag-install, add the path to your shell profile:
export PATH="/Applications/AI Job Hunter.app/Contents/MacOS:$PATH"
On Windows, the NSIS installer adds its per-user install directory to your PATH. If ajh-tauri still isn't found, invoke it by full path — ~/.ajh-agent/agent.json carries it as exePath (the app rewrites that file on every launch).
In the app: Settings → Developer shows this binary's path and ready-to-copy registration commands for Claude Code and Codex with that path already filled in.
Requirements: Bridge-backed calls need the app running; --help, MCP startup (initialize) and the local commands tool do not. The CLI communicates over a local loopback bridge with mutual HMAC-SHA256 authentication (the pairing token is used only as an HMAC key and is never sent on the wire). Run ajh-tauri agent --help to see all verbs, exit codes, and error sentinels. For the design rationale see ADR-037 and ADR-038; for the full policy table, use the MCP commands tool or read apps/desktop/src-tauri/src/extension_bridge/agent_cli/policy.rs (agent schema lists only the five curated resources).
LLM agents (MCP mode): The CLI can run as a standard MCP stdio server — any MCP client can run it, not just the two documented below:
Use claude mcp add --scope user to register the server (user scope = per-machine, runs only when you authorize):
claude mcp add --scope user ai-job-hunter -- "/path/to/ajh-tauri" agent mcp
On Linux/macOS with Homebrew, ajh-tauri is already on PATH. On Windows and macOS dmg, substitute the exePath from ~/.ajh-agent/agent.json — keep the double quotes, because the default install directory contains a space on both platforms and an unquoted path registers a server that never connects. The in-app Settings → Developer card generates the same command already quoted (shellDoubleQuoted in apps/desktop/src/renderer/features/settings/lib/agent-cli-snippets.ts).
By default the server is read-only: the model can search postings, read your profile and automations, and enumerate the command table. Two launch flags open the write tiers, each a superset of the last:
# undoable state changes — track an application, edit your profile, import a document
claude mcp add --scope user ai-job-hunter-write -- "/path/to/ajh-tauri" agent mcp --allow-reversible
# additionally: destructive actions and AI spend — delete documents, remove provider keys, run generation against your API budget
claude mcp add --scope user ai-job-hunter-unrestricted -- "/path/to/ajh-tauri" agent mcp --allow-irreversible
The flags only decide which tools the model can see; every write still goes through the app's own policy table and, for destructive commands, the confirm ceremony (ADR-038).
Add to your ~/.codex/config.toml (or create it):
[mcp_servers.ai-job-hunter]
command = 'ajh-tauri'
args = ["agent", "mcp"]
If ajh-tauri is not on your PATH, set command to the exePath from ~/.ajh-agent/agent.json — keep it single-quoted, because a TOML literal string has no escape sequences and a Windows path is full of backslashes (in a double-quoted basic string they read as invalid escapes and the whole file stops parsing) — unless the path contains an apostrophe, in which case copy the Settings card's output verbatim (it falls back to an escaped basic string). The Settings → Developer card emits the correctly-quoted form for you (tomlString in apps/desktop/src/renderer/features/settings/lib/agent-cli-snippets.ts). The same --allow-reversible / --allow-irreversible flags go in args.
Most other MCP clients (Claude Desktop, Cursor, Windsurf, Gemini CLI, LM Studio, Jan, etc.) read a mcpServers JSON block from their own config file rather than taking a CLI command:
{
"mcpServers": {
"ai-job-hunter": {
"command": "/path/to/ajh-tauri",
"args": ["agent", "mcp"]
}
}
}
The same tier flags as above go at the end of args — --allow-reversible (server name ai-job-hunter-write) or --allow-irreversible (server name ai-job-hunter-unrestricted). The Settings → Developer card copies this block with your real path already filled in — on Windows the backslashes must be doubled (C:\\Users\\…) — or just copy the block from the Settings card, which does it for you.
URL-only clients: agent mcp --http <port> runs the same server over MCP Streamable HTTP instead of stdio, for a client that only takes a URL rather than launching a subprocess. It binds 127.0.0.1 only — no flag can name a different host, so there is nothing to configure there — and prints one {"transport":"http","url":"...","token":"..."} line to stdout, once, before it starts serving; that token is never written to a file, so a client that loses it has to relaunch the server. Every POST /mcp needs Authorization: Bearer <token>, and any request carrying an Origin header is refused outright (this is for local scripts and agent frameworks, never a browser page's fetch). It answers one JSON-RPC request per POST — no streaming, no sessions, GET/DELETE both 405 — and shuts down the same way the stdio mode does, on Ctrl-C or stdin EOF.
Tool results are sent to the model and stored in the client's transcript. Queries like profile and documents_get_text return PII; the model can access job postings, your résumé, and application records. Use these tools only when you ask the agent to retrieve that data.
Important: The first argument to the MCP server must be agent (e.g., agent mcp), or the desktop app launches instead.
The app uses the OS keychain for secrets instead of .env files. Keys and credentials are set in the UI and encrypted via Tauri's keychain plugin.
| Setting | Location | Description |
|---|---|---|
| AI provider + key | Settings → AI | Ollama / OpenAI / Anthropic / Gemini / compatible |
| Local model limits | Settings → AI | Context window + max output, per Ollama model |
| Ollama account key | Settings → AI | Optional: Ollama Cloud models + company research |
| Adzuna/JSearch keys | Settings → Jobs | Optional: provider registry with Adzuna (primary, free) and JSearch (paid fallback). Covers anti-bot boards like Indeed, Glassdoor, Xing, and more. See docs/knowledge/scraping-domain.md for details. |
| Performance mode | Settings → Performance | Low / Balanced / Performance |
| Language | Settings → General | UI and generation locale |
| Layer | Technology |
|---|---|
| Desktop shell | Tauri 2.x: Rust core + React renderer |
| UI framework | React 19, TypeScript 7 |
| Routing | TanStack Router 1.x (file-based) |
| Server state | TanStack Query 5.x |
| Client state | Zustand 5 |
| Styling | Tailwind CSS v4 + CSS custom properties |
| Animations | motion/react |
| Build system | Vite 8 + Turborepo (monorepo) |
| Package manager | pnpm 11 (workspaces) |
| Local AI | Ollama |
| Relational DB | SQLite via rusqlite ( core) |
Architecture in one line: the React renderer never calls the OS directly; it talks to the Rust core over a typed IPC contract.
React renderer → service hook (React Query) → tauri-client → Rust #[tauri::command] → core (scrape · AI · export · DB)
apps/desktop/src/renderer/services apps/desktop/src-tauri/src
IPC request shapes have a single source of truth: Zod schemas in packages/shared, from which pnpm gen:ipc generates the matching Rust structs, so the TypeScript and Rust sides can't drift.
packages/shared/src/ipc/contracts/*.ts: add the method signature.apps/desktop/src-tauri/src/commands/*.rs: implement the #[tauri::command] and register it in shell/handler.rs.apps/desktop/src/tauri-client/namespaces/*: wire the invoke(...) call.apps/desktop/src/renderer/services/*: add the React Query service hook.pnpm gen:ipc.Completer / streaming contract). Reasoning, limits, and job state are normalized at the adapter boundary, so the renderer holds one contract and zero per-provider branching.scraping/boards/mod.rs SCRAPERS); discovery is registry-driven, so no caller changes.main; Conventional Commits; ESLint + commitlint + architecture tests gate every change.@ajh/ui primitives and service hooks, never window.api directly; design-system tokens (text-brand, motion tokens) over hardcoded values.This repo ships a knowledge base under docs/knowledge/: domain notes plus architecture decision records (ADRs), plus a Claude Code agent system under .claude/ (23 agents, a write-capable author + an independent critic per domain, plus commands). When in doubt about why something is built a certain way, the ADRs are the fastest answer.
For a visual walkthrough of the agent system (the fleet map, how a prompt is routed to an agent, the author→critic pipeline, and a with/without-agents comparison), open the interactive apps/landing/agent-system.html.
A graphify knowledge graph (graphify-out/) can also be queried directly or wired as an optional, opt-in local MCP server; see docs/DEVELOPMENT.md.
ai-job-hunter-app/
├── apps/
│ ├── desktop/ # Main desktop app (Tauri shell: Rust core + React renderer)
│ │ ├── src-tauri/ # Rust core (commands, scraping, AI, export, DB, extension bridge)
│ │ └── src/renderer/ # React frontend
│ │ ├── features/ # Feature-scoped components
│ │ ├── routes/ # TanStack Router pages
│ │ ├── services/ # React Query IPC hooks
│ │ ├── lib/ # Utilities (generate, motion, i18n, machines)
│ │ ├── store/ # Zustand stores
│ │ └── providers/ # React context providers
│ └── extension/ # Browser extension (MV3, Chrome + Firefox) — job import via loopback WS
├── packages/
│ ├── shared/ # IPC contracts, Zod schemas, shared types, extension protocol
│ ├── ui/ # @ajh/ui — React component library
│ ├── prompts/ # Provider-aware, locale-driven AI prompt templates
│ ├── translations/ # i18n config + locale strings (en, de, …)
│ └── test-ids/ # @ajh/test-ids — central TEST_IDS map
├── docs/ # Documentation + knowledge base (ADRs)
├── turbo.json # Turbo build configuration
├── pnpm-workspace.yaml # pnpm workspaces
└── package.json # Root scripts
pnpm dev # Start the Tauri dev app (full stack)
pnpm build # Build everything (Turbo)
pnpm build:packages # Build shared packages only (excludes Tauri)
pnpm build:chrome # Build the browser extension (Chrome / MV3)
pnpm build:firefox # Build the browser extension (Firefox / MV3)
pnpm package # Package the desktop installers
pnpm typecheck # TypeScript check across the monorepo
pnpm test # Run the Vitest suite (test:watch / test:coverage variants)
pnpm lint:strict # Lint with --max-warnings 0 (CI mode); lint:fix to autofix
pnpm format # Prettier format (format:check to verify)
pnpm gen:ipc # Regenerate Rust IPC structs from the shared Zod schemas
pnpm gen:workflows # Regenerate the CI workflow catalog + status badges
pnpm clean # Remove dist / out / .turbo + caches
pnpm storybook # Run the @ajh/ui Storybook
Live status of every GitHub Actions workflow. See
.github/workflows/README.md for the full catalog:
what each one does, its triggers, and whether it gates merges (only ✅ CI OK does).
Both are generated from the workflow files by pnpm gen:workflows.
📊 Mission Control: a verdict-first, full-repo dashboard (delivery, work, quality, community) updated nightly from a GitHub API snapshot (with live fallback when signed in with a fine-grained PAT).
See CONTRIBUTING.md for branching, commit conventions, code style, and PR guidelines. Quick rules:
main.feat:, fix:, chore:, …).pnpm lint:fix && pnpm typecheck before pushing; ESLint errors block commits.| Document | Description |
|---|---|
| apps/desktop/README.md | Desktop app architecture, directory map, Rust/React setup |
| apps/extension/README.md | Browser extension (MV3): job import, local dev pairing, permissions |
| docs/ARCHITECTURE.md | System design, data flow, diagrams |
| docs/PATTERNS.md | IPC, state machines, AI streaming, search patterns |
| docs/API.md | IPC namespaces + commands |
| docs/EXPORT_TEMPLATES.md | Templates, theming, PDF/DOCX export |
| docs/DESIGN_SYSTEM.md | Tokens, components, motion, theming |
| docs/DEVELOPMENT.md | Local dev environment setup |
| docs/DEPLOYMENT.md | Building and releasing installers |
| docs/ARCHITECTURE_STATUS.md | Implementation status tracker |
| docs/SCRAPING_ENDPOINTS.md | Job-board scraping endpoint reconnaissance snapshot (21 boards, 2026-07-01) |
| docs/knowledge/ | Knowledge base + architecture decision records (ADRs) |
| SECURITY.md | Security policy & vulnerability reporting |
| CONTRIBUTING.md | Code style, branching, PR process |
| How it Works | How the AI Job Hunter works end-to-end (interactive walkthrough) |
| Architecture Map | Interactive architecture map of the AI Job Hunter |
| Agent System | Interactive agent-fleet walkthrough: 23 paired author+critic agents, intake→delegation routing, per-task pipeline |
| THE CREATURE | THE CREATURE: a hand-drawn doodle about the recruiter you summon |
This project is built by a guy who is, himself, still unemployed, a cry for help made between rejections. If it's saving you time or sanity, consider a voluntary gift:
GitHub Sponsors · Ko-fi · PayPal
Found a vulnerability? Please report it privately: see SECURITY.md. Don't open a public issue for security reports.
Apache License 2.0 (SPDX: Apache-2.0): open source, free for any use including commercial, with an explicit patent grant. Effective 2026-09-07, replacing PolyForm Noncommercial 1.0.0; reason: the free code-signing programmes for open-source projects (SignPath Foundation, Certum) require an OSI-approved license, and PolyForm Noncommercial is not one.
One carve-out: the vendored ATS company datasets under apps/desktop/src-tauri/ats-slugs/ are third-party data under CC BY-NC 4.0, not Apache-2.0 — see their README before redistributing commercially.
SaveVerdict::RefusedUntrusted-text fencing: anything you did not type gets wrapped. The principle is: every blob entering a prompt without your fingerprint is fenced as untrusted — job ads (<job_posting>), prior-stage model outputs (strategy artifact, evidence map), web-sourced company research, and rerank candidates. Each fence instructs the model to ignore instructions within it and treat the text as reference only. The fencing mechanism and patterns are in prompt_fence.rs (the OWASP LLM01 chokepoint); implementations include buildJobAdBlock for job ads and pipeline prompts for artifacts. See ADR-010 for the design.
| Search & ranking | SQLite FTS5 (BM25) + optional vector similarity, fused with RRF |
| Browser automation | chromiumoxide (Rust); Playwright for e2e only |
| Document generation | Typst engine (export/typst_engine/) + docx-rs (Rust) |
| Validation | Zod (shared schemas → generated Rust structs) |