# Binding Architecture & Database Decisions > Produced in the Decide phase. Every document in this package must be consistent with > this file. If you believe a decision here is wrong, note it as a risk in your document > rather than silently diverging. ## Rulings Numbered, binding, one line each. A ruling settles a question that was previously answered differently in different documents. Where a ruling and body text below disagree, the ruling wins. > **RULING-02 onward live in `_open-items.md`**, which is the package's single arbitration register: > one row per deduplicated cross-document contradiction, each ending in a ruling > (RULING-01…RULING-09), a named non-engineering owner with a blocking gate (OPEN-01…OPEN-12), or a > closure (C-01…C-11). The `RULING-` series is continuous across both files and they rank equally. > `_open-items.md` §4 maps every row of every per-document reconciliation section to one register > id; §5 lists the gates in the order they bite. Do not settle a contradiction locally in a document. > `_glossary.md` holds the Part 1 ↔ Part 2 name mapping that RULING-03 makes binding. **RULING-01 — actor vocabulary (binding, package-wide).** The column is `actor_kind`; its value set is exactly `('user','system','integration','ai_agent')`; there is **no** `human` value and no `actor_type` column; the "AI must never auto-reject" guard is written, character for character, `actor_kind = 'user'` (and its negation `actor_kind <> 'user'`), and every subordinate vocabulary — including `pipeline_transition_rule.allowed_actor_kinds` and `outbound_message.created_by_actor_kind` — must draw its values from that same set. *Rationale: it is the only form backed by an actual `CHECK` constraint in `03-database-design.md` (§9.5, §28.1), and it distinguishes `integration` (a service principal such as the careers-form endpoint) from `system` (a timer or unattributed trigger write), which the `human/system/ai` triple cannot. A guard comparing against a value absent from the enum either fails closed on every legitimate human rejection or throws, so this could not be left to per-document judgement.* Authoritative statement of the vocabulary and its semantics: `04-integrations-and-processing.md` §1 actor-kind row. Recorded in the ADR that owns the AI boundary, `adr/0011-ai-provider-abstraction-and-versioning.md` §"Guard literal"; asserted as a literal string by test task A-26 in `07-implementation-plan.md`. ## Part 1 — Architecture & Modules Modular monolith, two processes from one image, one PostgreSQL. The repo has no backend at all (findings §B), so the backend is a free choice constrained only by two developers and the one-database rule. Microservices are rejected: 66 named seats (BRD §4), one master data model, two engineers. Backend is Python 3.12 / Django 5 / DRF, chosen from first principles — the two Phase 1 intake sources are CV files and Outlook mail, and PDF/DOCX/OCR extraction, fuzzy dedupe and pgvector are first-class in Python; Django over FastAPI because 26 modules with a junior need built-in migrations (none exist, §B), a free admin back-office for the controlled vocabularies (BRD §9.2), and a real permission framework, since today's RBAC gates nothing (js/rbac.js:78, js/settings.js:148-154). Module boundaries are enforced in-process by package isolation, one public service facade per module, and import-linter contracts in CI — not network hops. Frontend: progressive migration. css/styles.css (1269 lines, 93 design tokens across 159 custom-property declarations and 470 var(--…) references — measured, 01 §12, which supersedes the 424 figure originally stated here; WCAG AA verified across 23 routes × 2 themes) and js/charts.js (reads CSS vars, js/charts.js:9) are preserved verbatim; the rendering layer is replaced screen by screen with Vite + TypeScript + React, because 34 unescaped innerHTML sites (§E), ~115 form-control sites already spread across 15 files, and 22 ordered script tags with everything on window (§C, index.html:264-285) cannot carry versioned requisitions, scorecards and offer approvals. A 2-3 day escaping/CSP patch lands first so real CV and email data never meets an unescaped sink. One worker process, not a separate AI service, with named measurable split triggers. Queue is Postgres-backed (procrastinate) so enqueue is transactional with intake writes; Redis is cache only. 25 logical modules across five tiers, phased 0-4; six prototype routes dissolve into other modules rather than being built. Honest delivery: one month yields Phase 0 plus one vertical slice, not a platform. ### Architecture style **Decision:** Modular monolith. One Django codebase, one deployable image, two runtime processes (`web` ASGI, `worker` queue consumer), one PostgreSQL 16 database. Module boundaries are logical (Python packages with a public service facade), not network boundaries. **Why:** Team size decides this. Two developers cannot own 20+ deploy pipelines, 20+ sets of dashboards, and inter-service contract versioning. Load does not demand it either: 66 named seats (BRD §4 = 2+4+15+12+8+24+1), realistic peak concurrency ~25 (assumption). The hard constraints make it worse for microservices — one master data model and ONE relational database means services would share a database, which is the anti-pattern, or the model would be split against an explicit constraint. The core Phase 1 flow (intake -> parse -> candidate -> application -> score) needs transactional integrity across four modules; in-process that is one Postgres transaction, across services it is a saga plus compensations that two devs will get wrong. Failure isolation is the one genuine loss, bought back cheaply: the worker is already a separate process, so document parsing and AI calls cannot take the web tier down, and the AI boundary has a circuit breaker so TalentFlow degrades with AI absent (BRD NFR-7, §8.3). Security is better, not worse: one auth chokepoint (`iam.can()`) instead of 20 services that each must re-derive authorization — directly relevant to the constraint that the chatbot must never bypass access controls. **Rejected:** Microservices — malpractice at two devs and 66 users; forbidden infrastructure (Kubernetes) or per-service DBs (forbidden) would follow. Hybrid/'a few services' — the only defensible seam is the document/AI worker, and that is a process split inside the monolith, not a service. Serverless functions per module — cold starts on interactive AI, no shared connection pool, and a debugging story a junior cannot own. ### Module boundary enforcement and dependency rules **Decision:** Five tiers with a strict one-way dependency rule: `surfaces -> core domain -> platform`, and `intelligence -> core domain (read) + platform`. Rules: (1) every module is a Python package with `service.py` as its only public entry point; cross-module imports may only touch `.service` and `.dto`, never `models`, `views` or `selectors`; (2) no module reads or writes another module's tables — the sole exception is the `analytics` module, which owns read-only SQL views declared in migrations; (3) core domain modules must never import `intelligence` or `surfaces` — AI results are attached by the domain module accepting a suggestion, which is what makes 'AI never auto-rejects' structurally true rather than a policy; (4) `identity`, `audit` and `config` are ambient dependencies of every module; (5) enforced in CI by `import-linter` layered contracts plus a forbidden-import contract, so a violation is a failed build, not a review argument. ```mermaid graph TD subgraph SURF["Surfaces"] ANALYTICS["analytics"] ASSISTANT["assistant"] INBOUND["integrations_inbound"] OUTBOUND["integrations_outbound"] WORKLIST["worklist"] end subgraph INTEL["Intelligence"] AIORCH["ai_orchestration"] SCORING["scoring"] FAIRNESS["fairness_evaluation"] PARSING["document_parsing"] end subgraph DOMAIN["Core domain"] INTAKE["intake"] CAND["candidate"] DEDUPE["duplicate_review"] REQ["requisition"] APPL["application"] PIPE["pipeline"] ASSIGN["assignment"] INTV["interview"] ASMT["assessment"] OFFER["offer"] POOL["talent_pool"] end subgraph PLAT["Platform (ambient)"] IAM["identity"] AUDIT["audit"] FILES["files"] CONFIG["config"] NOTIF["notifications"] end INBOUND --> INTAKE INTAKE --> FILES INTAKE --> PARSING INTAKE --> CAND PARSING --> FILES DEDUPE --> CAND APPL --> CAND APPL --> REQ APPL --> PIPE ASSIGN --> APPL ASSIGN --> REQ SCORING --> APPL SCORING --> REQ SCORING --> PARSING SCORING --> AIORCH FAIRNESS --> SCORING ASSISTANT --> AIORCH ASSISTANT --> IAM ANALYTICS --> APPL OUTBOUND --> REQ INTV --> APPL ASMT --> APPL OFFER --> APPL POOL --> CAND WORKLIST --> IAM NOTIF --> IAM ``` **Why:** A modular monolith without mechanical enforcement becomes a big ball of mud in about six months, and the prototype already demonstrates the failure mode — every module is on `window` and any file can reach any other (§C, index.html:264-285). The rules are chosen to be checkable by a tool rather than by discipline, because with one senior reviewer discipline does not scale. Rule 3 is the important one: it means an AI module physically cannot call `application.transition()` with a rejection, so the governance requirement (BRD §7.1) is enforced by the dependency graph. **Rejected:** Ad-hoc conventions in a README (unenforceable). One shared `models.py` (the prototype's js/data.js pattern at repo scale — this is what produced the flat candidate array at js/data.js:117-127). Full hexagonal architecture with ports/adapters per module (ceremony a junior will fight; reserved for the two modules that face external systems). ### Backend language and framework **Decision:** Python 3.12 + Django 5 + Django REST Framework, PostgreSQL 16, psycopg3, drf-spectacular for the OpenAPI schema. Async only where it earns its keep: the model-streaming endpoint runs as a Django async view under uvicorn; everything else is sync. **Why:** Decided from the two Phase 1 workloads, since there is no incumbent stack (§B). (1) CV parsing: `pypdf`/`pdfplumber` and PyMuPDF for PDF text and layout, `python-docx`/mammoth for DOCX, `pytesseract`/OCRmyPDF for scanned CVs, `unstructured` as a fallback for odd formats. This ecosystem is decisively Python; in Node or .NET the same job means shelling out to Python or buying a SaaS parser. (2) AI/ATS work is Talha's and is Python-native — provider SDKs, `rapidfuzz` for dedupe scoring, `pgvector` for embeddings, `pandas`/`scipy` for the disparate-impact evaluation (BRD §7.2). (3) Microsoft Graph has a maintained Python SDK, and mail polling is I/O-light at our volume. Django over FastAPI for team-shape reasons, not taste: migrations are built in and there is no migration tooling to inherit (§B), the admin gives a free internal back-office for the 7 controlled vocabularies in BRD §9.2 (which today are hardcoded arrays in js/data.js) so no Settings UI has to be built in Phase 1, the auth/permission framework and session/CSRF hardening replace what is currently inert UI (js/settings.js:148-154), and DRF gives versioned JSON APIs with a generated schema, which BRD §8.3 requires. Stated tradeoff: Django's ORM is poor at the analytics queries in BRD §5.4, and its async story is weaker than FastAPI's. Both are accepted — analytics uses raw SQL behind read-model views, and all long work goes to the worker anyway, so the web tier is never waiting on a model call except in the one streaming endpoint. **Rejected:** FastAPI + SQLAlchemy + Alembic — better async and a nicer ORM, but the team then hand-builds auth, permissions, admin, and the migration workflow; that is a month of platform work a two-person team should not spend. Node/TypeScript (NestJS) to share one language with the frontend — genuinely attractive, but the PDF/DOCX/OCR and evaluation gap is decisive and Talha's leverage is Python-side; mitigated instead by generating the TS client from the OpenAPI schema so the contract is written once. .NET or Java/Spring — strong platforms, wrong ecosystem for parsing and AI, and heavier ceremony per feature than two devs can absorb. Django templates + HTMX instead of DRF+SPA — see the frontend decision. ### Frontend: retain, harden, migrate or rebuild **Decision:** Progressive migration (strangler), with the design system preserved verbatim. Three commitments: (1) `css/styles.css` is content-frozen and becomes the design contract — it moves to `web/src/styles/styles.css` by `git mv` with zero content edits, and new component CSS may only use existing `var(--…)` tokens, enforced by a stylelint rule; (2) `js/charts.js` is retained as-is behind one thin `` wrapper that passes a canvas ref — the canvas engine already re-themes itself by reading CSS custom properties (js/charts.js:9), so rewriting it would be pure loss; (3) the rendering layer is rebuilt in Vite + TypeScript + React 18, screen by screen, with TanStack Query for server state, React Hook Form + Zod for forms, and TanStack Table replacing `UI.dataTable`'s sort/paginate against server-side DRF pagination. The 10 primitives exported at js/ui.js:251 (icon, avatar, avatarStack, badge, scoreChip, pbar, modal, toast, dataTable, fieldError/clearErrors) are ported one-for-one to components keeping the same class names, so the CSS keeps matching. `dangerouslySetInnerHTML` is banned by an ESLint `react/no-danger` error in CI. Migration order: shell + login, then the untrusted-data screens (Inbox, CV Import, Candidates, candidate profile), then the heavy forms (Requisitions, Pipeline, Interviews, Offers), then read-mostly surfaces last. The 23-route IA (js/app.js:7-16) is preserved as the route table. Only the React app is ever wired to real data; the hardened prototype remains a demo and reference, never a second production frontend. **Why:** Both halves of the repository deserve different verdicts and the decision must say so. The CSS is the expensive, verified asset — 1269 lines, **93 design tokens across 159 custom-property declarations** and 470 `var(--…)` references (measured; `01` §12 supersedes the "424 custom-property declarations" figure originally stated here), dual themes, 320px to ultrawide, WCAG 2.1 AA verified across 23 routes × 2 themes, 44px targets (§G). Rebuilding that is weeks of work with a real chance of regressing accessibility, and it ports unchanged into any component framework because it uses semantic class names (`.card`, `.dt`, `.badge`) rather than utility classes. The rendering layer is the liability, and the numbers are not marginal: 34 `innerHTML` assignments with no escaping anywhere (§E), ~115 ``/`