137 KiB
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). TheRULING-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.mdholds 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 <module>.service and <module>.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.
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 <Chart/> 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 <input>/<select>/<textarea> sites already spread across 15 files, inline handlers with interpolated ids (js/candidates.js:121), 22 ordered <script> tags, no modules, no types, no tests (§C, §H). The forms this platform still needs — versioned requisitions with weighted requirements, interview scorecards, offer approval chains — are the most complex in the product and do not exist yet. Building them as template strings on window is how a two-person team stalls. React specifically (over Svelte, which would be less code) because the junior's task stream must be small, varied and independently demonstrable: React's ubiquity means both developers, and the AI tooling they use, can help; and Zod schemas give the junior a validation workstream that mirrors the DRF serializers conceptually. Costs stated honestly: this adds the build step the repo deliberately lacks (§B), roughly 20-30 developer-days spread across Phases 1-4 to port 23 screens, and a period where two frontends exist in the repo. That is bought back by JSX escaping making §E structurally impossible rather than a per-line discipline, by TypeScript catching the entity-shape churn that splitting candidate from application will cause, and by testable components (Vitest + Playwright) where today there are none.
Rejected: Retain as-is — rejected: the XSS exposure is P0 the moment CV and email data lands (§E), and 26 modules of string-template forms on window is unmaintainable for two devs. Rebuild everything including the CSS — rejected: discards the single most valuable verified asset and the 23-route IA, and blocks backend progress for weeks. Retain-and-harden as the end state — rejected as a destination but adopted as the immediate step (next decision); escaping fixes the security hole but not the absence of modules, types, tests or component reuse, and it is precisely the new complex forms that it fails on. Django templates + HTMX (keeps one language, autoescaping by default, no build step) — a real contender and cheaper for CRUD, rejected because the product's centre of gravity is interactive: a drag-and-drop kanban across 7 stages, a streaming chatbot dock available on every screen, canvas charts, and live score updates.
Immediate XSS hardening of the prototype (Phase 0, before the migration)
Decision: Patch the existing prototype in Phase 0, independently of the migration: add UI.esc() in js/ui.js and apply it at every interpolation of data-derived values across the 34 innerHTML sites; replace inline onclick="Views.x('${id}')" handlers (js/candidates.js:121) with delegated listeners reading data-* attributes; add a Content-Security-Policy meta/header with no unsafe-inline for scripts; and add a CI grep gate that fails on a new unescaped ${ inside an HTML template literal. Estimated 2-3 developer-days. This is a junior task with a Talha review checkpoint.
Why: The migration will take months and the prototype will keep being demoed to stakeholders. If any real CV or mailbox content is loaded into it before the React screens exist — which is exactly what a demo temptation looks like — a CV with <img src=x onerror=…> in its name field executes in a recruiter session (§E). Doing this now decouples the security deadline from the migration schedule, and the CSP plus the CI gate mean the guarantee survives the intervening months rather than decaying. It also produces the escaping habit and a delegated-event pattern the junior carries into React.
Rejected: Skipping the patch because the prototype is being replaced — rejected: it assumes the migration never slips and that nobody ever points the prototype at real data. Adding DOMPurify — rejected: sanitising output is the wrong layer here (these are text fields, not rich HTML), and it adds a dependency to a codebase with no package manager (§B).
Which processes are separate, and the triggers to split further
Decision: Exactly two application processes in Phases 0-4, from ONE codebase and ONE container image, differing only by entrypoint: web (uvicorn/ASGI, serves /api/v1/* and the built static frontend) and worker (queue consumer, includes periodic/scheduled tasks). Document parsing, all model invocations, batch rescoring, mail polling and the fairness evaluation are worker modules, not a separate service. In Phase 2 the worker splits by queue into worker-default and worker-untrusted — still the same image and codebase, but the untrusted process runs parsing under a restricted OS user with no outbound network, a hard per-document CPU/wall timeout, and a memory cap.
Split-out triggers, to be reviewed quarterly. Any single 'hard' trigger justifies a separate deployable; 'soft' triggers need two sustained for 2+ weeks:
| Trigger | Threshold | Hard? |
|---|---|---|
| Dependency conflict | An ML/OCR dependency cannot coexist in the web image or pushes it past ~2 GB | Hard |
| Hardware profile divergence | Parsing/inference needs a GPU, or >4 vCPU / >8 GB steady-state | Hard |
| Runtime isolation | Untrusted-file handling requires a sandbox the worker process cannot provide (beyond the Phase 2 restricted queue) | Hard |
| Non-Python runtime | A required model runtime is not Python | Hard |
| Queue starvation | p95 time-to-start for interactive AI tasks >10 s while web p95 <300 ms, after vertical scaling of the worker host is exhausted | Soft |
| Release cadence conflict | Prompt/model changes need >2 deploys/week while domain code is release-gated | Soft |
| Blast radius | Worker OOM/crash loops have taken the shared host down twice in a quarter | Soft |
Explicitly never a trigger: 'AI feels like a different concern', org-chart preference, or multi-tenant/regional residency (excluded by constraint).
Why: The worker split is justified on day one because it is a real property difference, not a taxonomy preference: parsing a scanned CV is CPU-bound and multi-second while a recruiter request is I/O-bound and sub-second, and they must not share a request thread. A third deployable is not justified because it would duplicate the data model, the auth layer and the deploy surface for two developers, and because BRD §8.3's demands — a documented versioned API, graceful degradation, async long-running jobs with retrievable status — are all satisfied by an internal module boundary plus a circuit breaker and a job-status endpoint. Volume supports this: assume (labelled assumption) 20k-60k applications/year and 200-600 documents/day at peak, which one 2-vCPU worker handles with headroom. The untrusted-parsing isolation is called out as the most likely early split because we are running parsers over attacker-supplied files by design, and that is a security argument rather than a scale one.
Rejected: A separate AI microservice in Phase 1 — forbidden by constraint and unjustified by scale; it would also fracture the audit trail, since AiRun rows must join to applications in the same database. Self-hosting model inference in Phase 1 — rejected: no GPU, no MLOps capacity, and no second engineer to cover Talha. In-process background threads instead of a worker (no extra process) — rejected: no retries, no visibility, work lost on deploy, and it puts OCR CPU load on the request path.
Queue technology
Decision: Postgres-backed durable queue via procrastinate (Django integration: migrations, admin, retries with backoff, periodic tasks, and per-key queueing locks). Redis is provisioned for cache, rate limiting and session storage ONLY — never as a broker or a store of record. Queue conventions: named queues (ingest, parse, score, ai, mail, maintenance), an explicit retry policy and max attempts per task, a failed terminal state that surfaces in the intake UI rather than a silent drop, and idempotent task bodies keyed on the domain row.
Why: Transactional enqueue is the deciding factor. The intake requirement is that no document is ever silently lost (BRD §6.3), and with the queue in the same database the row insert and the job enqueue commit or roll back together — the dual-write bug class (job enqueued for a row that rolled back; row committed with no job) simply cannot occur, with no outbox table to build. Second, it is one fewer durable component to back up, monitor and restore, which matters at two developers with no ops staff. Third, procrastinate's queueing locks let us serialise per-candidate dedupe and per-requisition rescore tasks declaratively, which is exactly where a naive queue produces race conditions. Volume is nowhere near the concern: LISTEN/NOTIFY-based Postgres queues comfortably handle thousands of jobs/hour, and we project hundreds of documents/day. Stated tradeoff: a smaller community than Celery, so fewer search results when the junior is stuck — mitigated by Talha owning the queue configuration while the junior only writes task function bodies. Migration trigger to Celery + Redis (with a transactional outbox to preserve the guarantee above): sustained >20 jobs/second, a need for chords/canvas-style fan-in, or worker count exceeding 4.
Rejected: Celery + Redis as the Phase 1 default — the safe, familiar answer, rejected because non-transactional enqueue is a real correctness risk in the one flow that must never lose data, and fixing it properly means building an outbox anyway. Celery with Postgres/SQLAlchemy as broker — unloved and slow path. RQ — simpler than Celery but Redis-based and weaker on periodic tasks and retries. Kafka or any log-based broker — forbidden by constraint and absurd at this volume. Azure Service Bus / SQS — adds a cloud dependency and loses transactional enqueue for no benefit at this scale. Cron-driven polling scripts — no retry semantics, no status, and BRD §8.3 requires retrievable job status.
AI boundary: how explainability, reviewability and 'never auto-reject' are enforced structurally
Decision: All model access is funnelled through one module, ai_orchestration, which is the only package permitted to hold a provider client. Four enforced rules: (1) every invocation writes an AiRun row (capability, model id + version, prompt template version, input reference, raw output, tokens, cost, latency, status) before its result is usable, so every AI output is addressable and versioned; (2) ai_orchestration.invoke() takes the human actor and calls iam.can() with that actor — there is no service account and no system principal for the chatbot, so a chatbot answer can never contain data the asking user cannot already see. Amended during data-model design (03 §27.2, 04 §4): the absolute form of this rule was unimplementable, because the Phase 1 intake pipeline invokes the model with no human present (a mail poller finds a CV, the worker parses it), so document_classification and cv_field_extraction could never have written an AiRun row. The rule is therefore narrowed to exactly what it was protecting: there is no system principal for any capability that returns data to a user. A third actor_kind = 'system' exists for unattended pipeline work, confined by database constraint to actor_user_id IS NULL, trigger_kind IN ('batch','scheduled','webhook'), and capabilities flagged ai_capability.allows_system_actor = true — which is false for every assistant, answering and ranking-for-display capability, so the chatbot boundary is unchanged. Where a human did cause the work (manual upload, publish-triggered rescore), that user is propagated rather than replaced by system; (3) AI output is a suggestion record referencing an AiRun, never a domain write — a domain module's accept_suggestion() applies it, and application.transition() rejects any terminal-negative transition whose actor_kind <> 'user' (RULING-01 — 'user' is the only "a real person did this" value; there is no human value), so 'AI must never auto-reject' is enforced by a database constraint and a service guard rather than by a prompt; (4) every AI-influenced decision writes an audit.AuditEvent carrying ai_run_id, model version, actor and timestamp (BRD §7.3). Model hosting for Phase 1: a contracted API provider under a data-processing agreement, accessed only from the worker plus one streaming endpoint — labelled an assumption pending BRD OQ-1, and the single decision most likely to change this design.
Why: The governance requirements (BRD §7) are the kind that erode if they live in documentation. Making the AI module structurally incapable of writing domain state converts three policies into invariants that a test can assert and a reviewer cannot forget. Rule 2 is the answer to the chatbot access-control constraint: the temptation in every assistant implementation is to give the retrieval layer broad database access and filter afterwards, which fails the first time the filter has a bug; propagating the human identity into the same can() used by the REST API means there is one authorization implementation, not two.
Rejected: Letting each module call the provider directly — loses the single audit point and makes model-version traceability (BRD §6.2) impossible. A service account for the chatbot with post-hoc filtering — the standard mistake, and a direct violation of the access-control constraint. Storing only the final AI output without the run metadata — cheaper, but then a score change cannot be attributed to a model version, failing BRD §6.2 and §11.
Cross-cutting persistence patterns every module must follow
Decision: Four patterns, decided once and reused so the junior learns them once (the data-model author owns the exact columns): (1) Versioning — immutable version rows plus a current_version_id pointer on the parent. Applied to RequisitionVersion, RequisitionRequirement, ScoringConfigVersion, PromptTemplateVersion, ModelConfigVersion, ScorecardTemplateVersion, AssessmentTemplateVersion. Every downstream row pins the version it used. (2) History — hand-written, purpose-built history tables per aggregate (ApplicationStageHistory as transition rows; Assignment as [from_ts, to_ts) intervals with to_ts IS NULL meaning current, guarded by a partial unique index on the primary role), plus the append-only audit log. Current state stays denormalised on the aggregate row for query speed. (3) Scores are append-only — ApplicationScore rows are never updated; a rescore inserts a new row and sets superseded_by on the old one. (4) Money and time — numeric(14,2) plus an ISO-4217 currency code on every monetary column; all timestamps stored UTC with an IANA timezone column and the original local wall time retained for anything user-scheduled.
Why: Each pattern closes a specific verified gap rather than being generic good practice: jobs are mutable single records today (js/data.js:85-108); there are no history tables at all (§F); aiScore: int(52,98) is a random integer with no components, evidence, model or version (js/data.js:123); salary is a bare integer with no currency anywhere in the dataset and offer validation only checks > 0 (js/data.js:126, js/offers.js:129); and there is no timezone discipline, with a hardcoded 'today' of 2026-07-09 (js/data.js:237). Doing versioning and history in Phase 1 rather than later is deliberate — both are cheap to design in and expensive to retrofit, because retrofitting means backfilling history that was never captured. The constraint that current state AND history must both exist is met by keeping them in different tables with different access patterns, rather than by deriving current state from an event stream on every read.
Rejected: django-simple-history or generic row-shadow audit tables as the domain history mechanism — fine for an admin change trail, wrong here: shadow tables mirror columns instead of modelling transitions, so 'who moved this candidate from Screening to Interview and why' becomes a diff-inference problem instead of a query. Event sourcing — two developers, no. Postgres temporal/system-versioned tables — not native. Mutating scores in place — destroys the reproducibility and traceability requirements in BRD §6.2. Storing money as integer minor units — workable, but a numeric + currency pair is harder to get wrong across 6 jurisdictions.
Logical module list — platform tier
Decision: All five live in the monolith. Column key: BG = background tasks.
| # | Module | Responsibility | Main entities | Key interfaces | Depends on | Permission boundary | BG | Phase |
|---|---|---|---|---|---|---|---|---|
| 1 | identity |
Users, roles, scoped role assignments, sessions, SSO, the single authorization decision point | User, Role, Permission, RoleAssignment (scope: brand/department/requisition), Session | authenticate(), can(actor, action, resource), DRF permission classes, scopes_for(user) |
— | Admin-only writes; everyone reads own profile | Session/token cleanup | 0-1 |
| 2 | audit |
Append-only record of every state change and every AI-influenced decision | AuditEvent(actor, actor_kind ∈ user/system/integration/ai_agent (RULING-01), on_behalf_of, action, resource_ref, before, after, request_id, ai_run_id, ts) | record(), query() |
identity | Read: admin + compliance. No update or delete path exists | Retention export | 1 |
| 3 | files |
Blob storage, checksums, malware scan hook, signed URLs, retention and deletion | StoredFile(sha256, mime, size, storage_key, scan_status, retention_class) | store(), signed_url(), delete_for_subject() |
identity | Access derived from the owning domain object, never public URLs | Scan, text-extract handoff, retention sweep | 1 |
| 4 | config |
Controlled vocabularies, workspace settings, email/letter templates, branding | RefValue, Setting, TemplateVersion | values_for(vocab), setting(), render_template() |
identity | Configure permission only; Django admin is the Phase 1 UI | Cache warm | 1 |
| 5 | notifications |
In-app notifications and transactional outbound recruiter/candidate email | Notification, NotificationPreference, OutboundMessage(status, provider_ref) | notify(), send_email() |
identity, config, files | Recipient sees own only | Digest send, delivery retry, bounce handling | 2 |
Why: These five are the modules everything else assumes, so they are built first and they own no business logic. identity and audit are Phase 0-1 non-negotiables because the repository has literally no authentication or authorization (§D: no login, no token, no session; the RBAC matrix at js/rbac.js:78 mutates an in-memory array that nothing reads; there is no can() anywhere) and because retrofitting authorization onto 20 modules is far more expensive than starting with it. config earns its place by removing UI work: the 7 controlled vocabularies in BRD §9.2 are hardcoded arrays in js/data.js today, and putting them behind the Django admin means the whole Settings screen (FR-22) can wait until Phase 4 without blocking anyone. files is separate from intake because retention and deletion obligations (BRD §7.4, including derived embeddings) attach to blobs and must be enforceable in one place.
Rejected: Folding audit into each module's own history tables — rejected: the compliance requirement is a single retrievable log across all modules (BRD §7.3, §11), and per-module logs cannot answer 'show every AI-influenced decision on this candidate'. Using a third-party audit SaaS — rejected: candidate data may not leave controlled infrastructure (BRD §7.4). Building a Settings UI in Phase 1 — rejected in favour of the admin.
Logical module list — core domain tier
Decision: All live in the monolith; intake's parse dispatch runs in the worker.
| # | Module | Responsibility | Main entities | Key interfaces | Depends on | Permission boundary | BG | Phase |
|---|---|---|---|---|---|---|---|---|
| 6 | intake |
The raw layer. Every inbound submission is recorded and triaged BEFORE any candidate exists | InboundSubmission(channel, external_ref, received_at, raw_payload jsonb, status), SubmissionAttachment→StoredFile, ProcessingAttempt(status ∈ received/parsing/parsed/failed/needs_review/discarded, error, attempt_no) | ingest(channel, payload) (idempotent on channel+external_ref), queue(), promote_to_candidate(submission, decision), retry() |
files, document_parsing, candidate, config | Recruiter triages; nobody can hard-delete a submission | Parse dispatch, backoff retry, stale-queue alert | 1 |
| 7 | candidate |
Person identity, independent of any application | Candidate, CandidateContact, CandidateProfileVersion (from a parsed doc), CandidateSkill, CandidateDocument, consent/retention fields | create_from_submission(), search() (Postgres FTS + trigram), apply_parsed_profile(), erase() |
files, config | Recruiter/HR read-write; interviewer sees only candidates on assigned interviews | Search index refresh, retention sweep | 1 |
| 8 | duplicate_review |
Detect duplicates, mark them, and support manual review with a reversible merge | DuplicateCandidateLink(a, b, score, signals jsonb, status ∈ suspected/confirmed/rejected), MergeOperation(surviving_id, merged_ids, field_map, performed_by, reversible_until, undone_at) | find_candidates(), confirm_merge(), undo_merge() |
candidate, audit | Merge/unmerge requires HR-admin; recruiters may only flag | Nightly rescan of new candidates | 1 detect / 2 merge UI |
| 9 | requisition |
Versioned job of record, with weighted requirements and an approval chain | Requisition(current_version_id, status), RequisitionVersion (immutable: title, dept, BU, location, employment type, grade, salary_range numeric+currency, description, effective_from, created_by), RequisitionRequirement (version-scoped, weighted), ApprovalStep | create_draft(), publish_version(), current_version(), version_at(ts) |
config, identity | Create/edit: recruiter+HR; publish/approve: hiring manager or department head | Deadline and stale-draft sweeps | 1 |
| 10 | application |
The candidate↔requisition-version join; owns stage and status, and is the unit ATS scores attach to | Application(candidate_id, requisition_version_id, source_channel, submission_id, current_stage, status, applied_at), ApplicationStageHistory(from, to, actor, actor_kind, reason, ts), SourceAttribution | apply(), transition(app, to_stage, actor) — refuses terminal-negative transitions unless actor_kind = 'user' (RULING-01), accept_suggestion(), history() |
candidate, requisition, pipeline, audit | Recruiter/owner writes; hiring manager approves; interviewer read-only | SLA breach detection | 1 |
| 11 | pipeline |
Stage definitions and per-requisition stage configuration; owns the rules, not the state | StageDefinition, PipelineConfig(requisition_version_id nullable = default), TransitionRule | stages_for(requisition_version), is_allowed(from, to) |
config | Configure permission only | — | 1 default / 3 custom |
| 12 | assignment |
Flexible, historical recruiter and manager ownership | Assignment(subject_type ∈ requisition/application, subject_id, user_id, role ∈ primary/supporting/coordinator, from_ts, to_ts NULL=current, assigned_by) | assign(), unassign(), owners_at(subject, ts), workload(user, window) |
identity, application, requisition | HR-admin reassigns; recruiters view own workload | Workload rollup | 1 |
| 13 | interview |
Scheduling and structured scorecards | Interview(application_id, type, mode, starts_at_utc, tz, local_time, status), InterviewParticipant, Scorecard(interviewer, criteria scores, recommendation, submitted_at, locked), ScorecardTemplateVersion | schedule(), reschedule(), submit_scorecard() |
application, identity, notifications, config | Interviewer sees and scores only own interviews; scorecards lock on submit | Reminders, calendar sync, no-show sweep | 2 |
| 14 | assessment |
Assign and record structured tests | AssessmentTemplateVersion, AssessmentAssignment, AssessmentResult | assign(), record_result() |
application, notifications | Recruiter assigns; results visible to recruiter + hiring manager | Invite send, expiry sweep | 3 |
| 15 | offer |
Offer lifecycle with mandatory human approval and issue | Offer(application_id, version chain, status, base numeric+currency, components, dates), OfferApprovalStep, OfferStatusHistory, OfferLetterDocument | draft(), submit_for_approval(), approve(), issue() (human confirmation required) |
application, config, files, notifications, identity | Draft: recruiter. Approve: dept head/HR-admin. Issue: never automated | Expiry sweep, reminder | 3 |
| 16 | talent_pool |
Re-surface previously sourced candidates | Pool, TalentPoolMembership(candidate, pool, reason, added_by), SavedSegment | add(), rematch(pool, requisition_version) |
candidate, application, scoring | Recruiter-managed | Periodic rematch | 3 |
Why: The shape of this tier is the main structural correction to the prototype, and each split is evidence-driven. Splitting candidate from application (§F: one flat array carries jobId, jobTitle, stage, aiScore and recruiter directly on the candidate at js/data.js:117-127, so one person cannot hold two applications) is the highest-value change in the whole design and unblocks talent pool, cross-brand matching (BRD AI-2) and dedupe. intake exists as a first-class module rather than an inbox view because the constraint requires raw intake before candidate creation and the prototype has no such layer at all — its inbox is already pre-joined to candidate and job (js/data.js:284-300), so there is no representation for a submission that failed to parse and can never become a candidate. assignment as an interval table replaces the single scalar recruiter/recruiterId (js/data.js:96, js/data.js:123) and gives both current state (to_ts IS NULL) and full history from one table, satisfying the flexible-and-historical constraint without a second store. pipeline is deliberately kept separate from application even though both concern stages: stage configuration is versioned reference data with a slow change cadence and a configure permission, while stage state changes constantly under an edit permission — merging them would put a config screen behind a recruiter permission. Phasing is driven by the intake-to-score critical path: modules 6, 7, 9, 10, 12 plus scoring are the Phase 1 vertical slice, and nothing else can be usefully demonstrated before they exist.
Rejected: Keeping stage and score on the candidate as the prototype does — rejected by the explicit constraint and by js/data.js:117-127. A generic workflow_engine module driving all state machines — rejected as speculative; each aggregate's transitions live in its own service until at least three modules demonstrably need the same engine. A separate search module or Elasticsearch — rejected: Postgres FTS plus pg_trgm inside candidate is sufficient at tens of thousands of records, and the constraint forbids adding a search cluster in Phase 1. A separate embeddings module — rejected: a pgvector column on the candidate profile version, not a service.
Logical module list — intelligence and surfaces tiers
Decision: All in the monolith; the BG column indicates work executed by the worker process.
| # | Module | Responsibility | Main entities | Key interfaces | Depends on | Permission boundary | BG | Phase |
|---|---|---|---|---|---|---|---|---|
| 17 | document_parsing |
Turn an attachment into structured, confidence-scored fields | ParsedDocument(file, parser_version, text, extracted jsonb, per-field confidence), ParseIssue | parse(file) -> ParsedDocument |
files, config | Internal; output visible wherever the source document is | All of it. Runs in worker (untrusted queue from Phase 2) | 1 |
| 18 | ai_orchestration |
The only holder of a model-provider client; capability registry, prompt/model versioning, run ledger, review | AiCapability, PromptTemplateVersion, ModelConfigVersion, AiRun(capability, model id+version, prompt version, input_ref, output jsonb, tokens, cost, latency, status), AiReview(run, reviewer, verdict, note), AiFeedback | invoke(capability, context, actor), run(id), status(job_id), review() |
identity, audit, config | Invocation is checked against the human actor's permissions; run ledger readable by admin + compliance | Batch invocation, evaluation runs, cost rollup | 1 framework + AI-1/2/3; 2-4 rest |
| 19 | scoring |
Per-application ATS match score, reproducible and explainable | ScoringConfigVersion(weights, feature set, excluded-attribute list, active_from), ApplicationScore(application_id, config_version, model_version, score 0-100, band, computed_at, superseded_by), ScoreComponent(feature, weight, contribution, evidence_ref), SkillMatch(matched[], missing[]) | score(application), explain(score_id), rescore_batch(requisition_version) |
application, requisition, document_parsing, ai_orchestration | Score visibility is a configurable role setting (BRD OQ-5); config changes are admin-only | Batch rescore on requisition-version publish or config activation | 1 |
| 20 | fairness_evaluation |
Disparate-impact evaluation per scoring/model version; gates activation | EvaluationRun, EvaluationMetric, EvaluationDataset | evaluate(config_version), results(config_version) |
scoring, audit | Results readable by business and legal, not just engineering | Evaluation batch | 3 (gate before ranking goes live) |
| 21 | assistant |
Conversational surface over permitted data; tool-calls only into existing module services | Conversation, Message, ToolInvocation(tool, args, result_ref, actor) | ask(actor, question, screen_context) (streaming) |
ai_orchestration, identity, all domain services | Every tool call carries the asking user's identity; no service account exists | None Phase 1; streaming served by web | 2 read-only / 4 full |
| 22 | integrations_inbound |
Channel adapters for the 11 inbound channels, all funnelling into intake.ingest() |
ChannelConnection(type, credential_ref, delta_cursor, health), IngestionRun(channel, started, items, failures), DeadLetter | one fetch() -> [RawSubmission] per adapter |
intake, files, config | Admin configures connections; credentials in the secret store only | Graph delta polling, webhook receipt, token refresh, dead-letter retry | 1: Outlook + career portal + manual upload. 3: 4 job boards. 4: referral/agency/campus/walk-in forms |
| 23 | integrations_outbound |
Publish requisition versions to the 8 external platforms | JobPosting(requisition_version, platform, external_id, state, cost_band), PublishAttempt | publish(), unpublish(), sync_state() |
requisition, config | Publish requires HR-admin (it has cost implications) | Publish, state reconciliation | 4 |
| 24 | analytics |
Read-only reporting and dashboard KPIs over materialised read models | SavedReport, ReportRun; owns read-only SQL views, no domain tables | kpis(), funnel(), time_to_hire(), source_performance(), recruiter_performance() |
read-model views over application, requisition, assignment, offer | Scoped by the viewer's role: recruiter sees own, CEO/dept head see aggregate | Nightly materialised-view refresh | 2 KPIs / 3 report library |
| 25 | worklist |
Recruiter tasks and SLA items, including AI next-best-action suggestions | Task(subject_ref, assignee, due_at, status, origin ∈ manual/rule/ai_suggestion) | create(), complete(), for_user() |
identity, application, notifications | Assignee and their manager | Rule evaluation, overdue sweep | 2 |
Plus one cross-cutting layer that is not a module: the API layer — DDRF under a versioned /api/v1/ namespace, OpenAPI schema via drf-spectacular, one auth middleware, one error envelope, one pagination/filtering convention, request-id propagation, and per-user rate limits. Owned by Talha, documented once, and required by BRD §8.3.
Why: scoring is separated from ai_orchestration because they version different things and fail differently: a match score must be reproducible from pinned inputs (config version, model version, requisition version, parsed-document version) even when the model provider changes underneath, and BRD §6.2 requires exactly that. There is nothing to port — the prototype's aiScore is int(52,98) (js/data.js:123) with a separate client-side relevance blend (js/candidates.js:18), i.e. no components, no evidence, no model, no version. fairness_evaluation is separate from scoring rather than a function inside it because it must act as a gate that a non-engineer can verify — no ScoringConfigVersion becomes active without a passing EvaluationRun reference — and because its readers are legal and the business (BRD §7.2, §11). integrations_inbound is one module with per-channel adapters rather than 11 modules because the channels differ only in transport; the normalisation target is a single RawSubmission, and Phase 1 deliberately connects only the three channels that carry real volume, since findings identify CV files and inbound email as the two real Phase 1 sources. analytics is the one module allowed to read across boundaries, and it does so through read-only SQL views declared in migrations, which keeps the exception auditable and reviewable in a diff. worklist and notifications stay separate because a task is an actionable item with an owner and a due date while a notification is a delivery event; conflating them produces a to-do list that cannot be cleared.
Rejected: Merging scoring into ai_orchestration — rejected: scores would inherit the AI module's lifecycle and lose independent reproducibility. Folding fairness_evaluation into scoring — rejected: the gate stops being enforceable and its audience is not engineering. One module per inbound channel (11 modules) — rejected as fake decomposition. A dedicated BFF service in front of the monolith — rejected: unnecessary hop for an internal SPA with one consumer. GraphQL — rejected: BRD §8.3 asks for a documented versioned JSON HTTP API, and DRF plus a generated client is less to maintain for two devs.
Modules consolidated or dropped, with the prototype route mapping
Decision: Six of the 23 prototype routes (js/app.js:7-16) become views, not modules, and none of them gets its own store:
| Prototype route / FR | Disposition | Why |
|---|---|---|
calendar (FR-16) |
Dropped as a module; a read view over interview + worklist |
Owns no entities. A Calendar table would duplicate interview state |
managers / Hiring Managers (FR-15) |
Dropped; managers are Users with a role plus assignment rows |
The prototype keeps a parallel Manager entity with its own name/title/department (js/data.js), duplicating identity. Two sources of truth for a person is how permissions drift |
recruiterhub (FR-9) |
Merged into assignment (workload, history) + analytics (efficiency, SLA) |
It is a dashboard over other modules' data, not a domain |
aistudio (FR-19) |
Merged into ai_orchestration |
It is the admin surface over AiCapability status; the BRD itself describes it as an activation surface |
settings (FR-22) |
Split: security/users/roles → identity; vocabularies/templates/branding → config |
The current screen is inert chrome (js/settings.js:148-154). Splitting it puts each setting behind the permission that actually governs it |
help (FR-23) |
Dropped from the platform; static docs + client-side search, Phase 4 | No entities, no backend, lowest value |
inbox (FR-2), import (FR-7) |
UI surfaces over intake + document_parsing |
Two screens onto one raw-intake domain |
jobboard (FR-8) |
UI surface over integrations_outbound |
— |
notifications (FR-20) |
Retained as a module, but Phase 1 scope is only writing an in-app row; email templating lives in config |
Avoids building a delivery pipeline before there is anything to deliver |
talentpool (FR-5) |
Retained but implemented as tags + saved segments over candidate/application, not a new store |
Re-surfacing is a query problem |
Not created at all, despite being tempting: a search module (Postgres FTS + pg_trgm inside candidate), a workflow_engine (state machines stay in the owning module), an embeddings service (pgvector column), a reporting_warehouse (read-model views), a tenant/region module (excluded by constraint).
Net: 23 prototype routes + the backend modules the constraints require, consolidated to 25 logical modules plus one API layer.
Why: Every module has a fixed overhead for a two-person team: a migration set, a service facade, permission wiring, tests, and a place in the dependency graph. Modules that own no entities pay that cost and return nothing, so the test applied here was 'does it own state or an invariant?'. Dropping managers also removes a real correctness hazard — the prototype's separate Manager entity means a person's identity and their approval authority live in different tables, and permission checks would eventually consult the wrong one.
Rejected: Building all 26 candidate modules one-to-one to match the assignment list — rejected: it optimises for looking complete rather than for two people shipping. Going further and collapsing interview, assessment and offer into one 'evaluation' module — rejected: they have genuinely different entities, permission boundaries (interviewers must see only their own scorecards) and phases.
Deployment topology, environments and hosting
Decision: Per environment: 1 managed container platform hosting 2 revisions from one image (web: 2 vCPU / 4 GB, 2 uvicorn workers × 4 threads; worker: 2 vCPU / 4 GB, concurrency 4) + 1 managed PostgreSQL 16 Flexible Server (2 vCPU / 8 GB, PITR, 14-day backups, pgvector and pg_trgm enabled) + object storage for CV blobs + managed Redis (cache/rate-limit only) + a secret store. No read replicas, no load balancer tier beyond the platform's own, no Kubernetes. The built React bundle is served as static files by the web process behind the platform CDN. Recommended cloud: Azure (Container Apps, Database for PostgreSQL Flexible Server, Blob Storage, Key Vault, Entra ID for SSO) — labelled an assumption: Utopia runs M365, since Outlook is inbound channel #1 (BRD §8.1), so Entra ID SSO and Graph app registration land in the same tenant and the identity problem disappears. Environments: local (docker compose), staging (real integrations pointed at a dedicated test mailbox and sandbox job-board accounts), production (manual promote). No per-developer cloud environment. One region for the single database, with a legal decision required on which — retention and deletion are implemented per record, not per region, since postings span six jurisdictions (BRD OQ-4) and the one-database constraint holds.
Why: Sized to the actual population: 66 named seats (BRD §4), of which 24 are interviewers who touch only assigned interviews, so peak concurrency is ~20-25 (assumption) and a single 2-vCPU web process is generously provisioned. Blob volume in year one is well under 1 TB at a projected 20k-60k applications/year (assumption). Container Apps gives two independently scalable revisions, rolling deploys and log aggregation without any cluster to operate, which respects the no-Kubernetes constraint while keeping the web/worker split. Choosing the same cloud as the identity provider and the mail source is the single largest reduction in integration risk available, and it is free. Accepted limitation stated plainly: one app host means no HA — a platform-level restart is a few minutes of downtime, which is acceptable for an internal recruiting tool used in business hours, though the six-jurisdiction spread narrows the maintenance window. The database is the only stateful component and it is managed, so recovery is PITR rather than a runbook the team has to write.
Rejected: Kubernetes/AKS — forbidden and unjustifiable at two devs. Multiple regional databases — forbidden by constraint; the residency question is answered by one region plus per-record retention, and escalated to legal rather than solved with topology. Self-managed Postgres on a VM — cheaper, but backup, patching and failover become the senior developer's unpaid second job. Serverless functions for the worker — cold starts and a 10-minute ceiling are wrong for OCR batches. A separate parsing VM in Phase 1 — see the split triggers; not yet earned.
Testing, CI and delivery process
Decision: CI on GitHub Actions (greenfield — .github/ is absent, §B) with one required pipeline: ruff + mypy, import-linter boundary contracts, pytest against a real Postgres service container (never SQLite — the design depends on jsonb, partial unique indexes, FTS, pg_trgm and LISTEN/NOTIFY), a makemigrations --check gate so a model change cannot merge without its migration, ESLint with react/no-danger as an error, stylelint enforcing design-token usage, tsc --noEmit, Vitest for components, and 5 Playwright smoke journeys (login; ingest a CV and see it parsed; promote a submission to candidate; create and publish a requisition version; move an application through two stages). Test layering: unit tests on scoring and parsing, service-level tests on each module facade (the facade is the test seam, which is what makes the boundary rule pay off), and no mocking of the database. Branch protection: no direct pushes to main; every PR needs Talha's review, which is the required review checkpoint for the junior's work. One deploy path, auto-deploy to staging on merge, manual promote to production.
Why: There are no tests, no test runner and no CI anywhere today (§B, §H), so this is entirely additive and can be shaped correctly from the start. Two constraints drive the specifics: the junior needs varied, independently demonstrable work, and testing is the ideal such workstream — each module facade is a self-contained test target with a visible pass/fail — while the senior needs the boundary rules enforced mechanically because he is the only reviewer. Insisting on a real Postgres in CI is a deliberate call: the moment tests run on SQLite, the team starts avoiding the Postgres features this design depends on, and the test suite stops telling the truth.
Rejected: Coverage-percentage targets as the quality gate — rejected: it rewards testing getters. SQLite in CI for speed — rejected as above. A separate QA phase before each release — rejected: two developers, so quality has to be in the pipeline. Contract tests between modules — unnecessary in-process; the facade signature plus mypy is the contract.
Phasing, honest delivery ranges and how work splits between the two developers
Decision: Five phases. Ranges are calendar time for the two-person team including review, not ideal engineering days.
| Phase | Scope | Range | Confidence |
|---|---|---|---|
| 0 | XSS/CSP hardening of the prototype; repo, CI, Docker, Postgres, migration skeleton; identity + audit + config foundations; ADRs; React shell scaffold |
2-3 weeks | High |
| 1 | The vertical slice that makes the product real: intake → document_parsing → candidate → versioned requisition → application → assignment → scoring v1 with explanations; Outlook + career portal + manual upload channels; enforced RBAC; React shell, Inbox, CV Import, Candidates, candidate profile, Jobs |
10-14 weeks | Medium |
| 2 | Pipeline board; interview + scorecards; duplicate review with reversible merge UI; dashboard KPIs; notifications; worklist; read-only assistant |
7-10 weeks | Medium-low |
| 3 | assessment; offer with approvals; report library; fairness_evaluation gate (must complete before ranking is released to recruiters); talent_pool; job-board inbound |
7-10 weeks | Low |
| 4 | integrations_outbound publishing; full tool-using assistant; remaining P2 AI capabilities; Settings and Help UIs |
5-8 weeks | Low |
One month delivers Phase 0 plus the first end-to-end thread of Phase 1 — one channel ingesting real mail, a CV parsed into a candidate, one application created against a versioned requisition, one score with visible components, on one hardened screen. It does not deliver a platform, and any plan that says otherwise is wrong. Confidence degrades after Phase 2 because Phase 3 depends on unresolved BRD open questions (OQ-1 model hosting, OQ-2 historic outcome data, OQ-4 jurisdictions).
Split: Talha owns architecture, the boundary contracts, identity/authorization, ai_orchestration, scoring, document_parsing, integrations_inbound (Graph auth), queue configuration, and deployment. Ahmed gets a deliberately varied stream, each item independently demonstrable with a Talha review: the Phase 0 escaping pass and CSP; porting the js/ui.js primitives to typed React components; Zod + DRF serializer validation pairs; the intake triage queue UI; worklist; the pipeline board; the score-explanation panel and AI-provenance badges (AI UX, not CRUD); the analytics read models and chart wiring using the retained js/charts.js; Playwright journeys; and the duplicate-review and merge-undo screens, which are genuinely interesting state-machine UI rather than forms.
Why: The phase boundaries are drawn so each phase ends with something demonstrable to Asfand Ahmed as Talent Lead, and so the highest-risk structural work (candidate/application split, versioning, history, authorization) lands in Phase 1 when changing it is still cheap. Versioning and history are pulled forward for exactly that reason even though nothing in Phase 1 visibly needs them. The junior's list is checked against the constraint that he must not get only CRUD: four of the ten items are UI/UX or data-visualisation work, two are testing, and two involve non-trivial state machines. Ranges are wide because two people with one reviewer have a low bus factor and no slack — narrow estimates here would be dishonest.
Risks flagged:
- XSS window during migration: the Phase 0 escaping pass covers 34 known innerHTML sites (findings §E), but if anyone points the prototype at a real mailbox or real CVs before the React screens exist, a missed interpolation is a stored-XSS execution in a recruiter session. Mitigation: CSP without unsafe-inline, a CI grep gate on new unescaped interpolation in template literals, and a written rule that real data is only ever wired to the React app.
- Bus factor of one on everything hard. Talha owns architecture, authorization, AI orchestration, scoring, parsing and deployment; there is no second reviewer for his own work and no cover during leave. Mitigation: ADRs in docs/architecture/adr for every decision in this set, pairing on the identity and scoring modules, and deliberately rotating one senior-owned module per phase to Ahmed with Talha reviewing.
- Model-hosting decision (BRD OQ-1) is unresolved and gates Section 7 compliance. This design assumes a contracted API provider under a DPA, accessed only from the worker. If legal requires self-hosting, Phase 1 gains GPU infrastructure, model serving and an MLOps burden that two developers cannot absorb, and the phase ranges break.
- No historic hiring outcome data confirmed (BRD OQ-2). Without it the fairness evaluation in Phase 3 cannot be run against real outcomes and scoring cannot be validated beyond face plausibility — which means the Phase 3 activation gate could block the ranking release with no engineering fix available.
- Single-region database against six jurisdictions of postings (BRD OQ-4) with a one-database constraint. Retention and deletion are implemented per record, including derived embeddings, but a legal requirement for in-jurisdiction storage would conflict directly with the no-per-region-databases constraint and require an explicit exception from the business rather than an architectural workaround.
- Microsoft Graph dependency on corporate IT. Inbound channel #1 needs an Entra ID app registration, admin consent for Mail.Read scopes, and a dedicated recruiting mailbox. This is outside the team's control and can block the Phase 1 critical path for weeks; start the request in Phase 0.
- Parsing accuracy expectation gap. BRD §11 asks for candidate records populated 'without recruiter re-keying'. Real-world CV parsing on mixed-quality PDFs and scanned documents will not reach that unqualified standard; the design mitigates by leaving low-confidence fields empty rather than guessing, but the business must accept a review step, and BRD OQ-6 (what happens on parse failure) is still open.
- Untrusted-file parsing runs in our own worker process. Parser libraries over attacker-supplied PDFs and DOCX files are a real RCE and resource-exhaustion surface. Phase 1 mitigations (timeouts, memory caps, restricted OS user, no outbound network from the parse step) are weaker than a sandbox; the Phase 2 worker-untrusted queue is the planned answer, and this is the split trigger most likely to fire early.
- procrastinate is less widely known than Celery. If Talha is unavailable while the junior is debugging a stuck queue, there are fewer answers to search. Mitigation: Talha owns the queue configuration, the junior only writes task bodies, and the documented fallback is Celery + Redis with a transactional outbox.
- Score reproducibility versus provider model deprecation. BRD §6.2 requires the same candidate and role to yield the same score absent a model or data change, but hosted models are deprecated on the provider's schedule. Mitigation: pin model version on every ApplicationScore row, treat a forced provider migration as a new ScoringConfigVersion with a rescore batch and an audit entry, and never silently rescore in place.
- Two frontends coexisting in the repository for 6-12 months invites drift — a fix applied to a prototype screen and not to its React replacement, or the reverse. Mitigation: the prototype is frozen after the Phase 0 patch except for security fixes, and each migrated screen deletes its prototype counterpart in the same PR.
- Single app host means no high availability, and the six-jurisdiction user spread narrows the maintenance window. Accepted deliberately for an internal tool, but it should be stated to the business rather than discovered during the first deploy that overlaps Singapore business hours.
- Scope creep from the 15 specified AI capabilities. All 15 are already visible in the UI as an interface preview, which creates stakeholder expectation that they are nearly done. Only AI-1, AI-2 and AI-3 are Phase 1; AI Studio must show true per-capability availability rather than a coming-soon grid, or the team will be judged against the mockup.
Part 2 — Database & Data Modelling
These are the binding database decisions for the greenfield Utopia Brands ATS backend. No _decisions.md existed, so this establishes the baseline every downstream schema and API document must follow. Engine: one managed PostgreSQL 16+ instance, one logical database, schemas app/ref/audit/ai/staging, migrations as ordered plain-SQL files (no ORM-generated schema) because the load-bearing invariants here are CHECKs, partial and expression unique indexes, DEFERRABLE constraint triggers, GiST EXCLUDE constraints, RANGE partitioning and column-level GRANTs -- none of which an ORM expresses. Identifiers: bigint identity PKs for all joins, plus a UUIDv7 public_id on every externally addressable entity, because candidate- and application-facing URLs and emails must not be enumerable; an internal-only reference_code preserves the prototype's CAN-/JOB-/APP- shape (js/data.js:118, js/data.js:95, js/data.js:298) for recruiter conversation. Intake is structural, not advisory: job_application.raw_intake_id and candidate.created_from_raw_intake_id are both NOT NULL against non-deferrable FKs, so no candidate or application can physically exist without a prior raw_intake row; a malformed email is stopped by a CHECK regex on candidate_email.address plus a DEFERRABLE constraint trigger requiring at least one contact channel at COMMIT, and a global partial unique index on the normalised address makes 'one email, one identity' a database fact rather than a code path. Jobs, requirements and scoring configs are immutable version rows with UPDATE revoked; ats_result pins job_version_id, scoring_config_version_id, candidate_document_id, parse_attempt_id, algorithm_code_version and per-criterion weight_applied/contribution, so a displayed historical score can never drift. Duplicate merge is additive re-pointing with a per-operation undo log (candidate_merge_operation) recording previous_value for every re-parent, field overwrite and suppression, reversible in reverse-seq order under strict stack discipline; nothing is ever deleted, and the losing candidate's public_id stays resolvable because it is already in sent emails. Current state lives as a denormalised column maintained by trigger alongside typed per-entity history tables carrying valid_from/valid_to intervals with overlap-preventing EXCLUDE constraints; actor and reason reach the trigger via transaction-local SET LOCAL settings. Money is always an amount+currency_code column pair bound by a CHECK, with conversions stored alongside the pinned fx_rate_id and never overwriting the original. All instants are timestamptz in UTC; interviews store both the resolved instant and the organiser's wall-clock intent plus IANA zone so a tzdata change is a re-resolution job rather than data loss. Retention purge is pseudonymisation, not row deletion, which is what lets erasure coexist with the mandatory history. Search is Postgres FTS plus trigram over a trigger-maintained candidate_search_index table in Phase 1, with pgvector as the Phase 2 semantic path in the same database and explicit numeric triggers before a separate search service is ever considered.
Database engine
Decision: PostgreSQL 16+ (target 17). ONE managed instance, ONE logical database, schemas: app, ref, audit, ai, staging. Phase 1 extensions: pg_trgm, unaccent, btree_gist, pgcrypto. Phase 2 only: pgvector, and pg_partman (or a small scheduled SQL function) for audit partitions. Server and application role set timezone = 'UTC'. Managed hosting with PITR and automated backups; no self-managed Postgres.
Why: The repo dictates nothing: no driver, ORM, migration directory, Dockerfile or env file exists (_repo-findings.md section B), so this is a free capability-fit choice. Every hard requirement maps to a concrete Postgres feature in one engine: JSONB + GIN for raw email/webhook payloads and parser output; tsvector/ts_rank_cd plus pg_trgm for both Phase 1 candidate search and duplicate detection off the same index type; pgvector later INSIDE the same database, which is what makes 'no separate AI service in Phase 1' achievable rather than aspirational; numeric for money; timestamptz plus tstzrange plus GiST EXCLUDE for interview double-booking; declarative RANGE partitioning for audit growth; DEFERRABLE constraint triggers for cross-table invariants; partial and expression unique indexes for soft-delete-tolerant uniqueness; column-level GRANTs to make ats_result and audit_event append-only; row-level security available for later chatbot isolation with no new infrastructure. Two developers get one engine, one backup story, and psql for ad-hoc work.
Rejected: MySQL 8: no trigram similarity, no vector type, no partial indexes, no exclusion constraints, no deferrable constraint triggers -- duplicate detection and scheduling invariants would all move into application code. MongoDB or any document store: the entire brief is relational invariants (raw-intake-before-candidate, per-application scores, reversible merge, version pinning); those become application conventions, which is exactly the failure mode the prototype already demonstrates. SQL Server: licensing cost with no capability gain. SQLite: no partitioning, weak concurrency. Multiple databases or per-region databases: explicitly forbidden and unjustified. Elasticsearch or a separate vector DB in Phase 1: forbidden and premature at prototype scale of 100 rows (js/data.js:112).
Schema tooling and migration strategy
Decision: Ordered, up-only plain-SQL migration files under db/migrations, applied by a thin runner (Flyway, golang-migrate, sqlx, or Alembic in SQL-only mode -- whichever matches the backend language chosen elsewhere). The ORM, if any, maps to the schema; it never generates it. Every migration is reviewed by Talha. Down-migrations are not written; recovery is forward-fix plus PITR.
Why: The invariants in these decisions are expressed almost entirely in objects no ORM models: partial unique indexes with WHERE clauses, expression indexes on lower(), CHECK constraints with regexes, DEFERRABLE INITIALLY DEFERRED constraint triggers, GiST EXCLUDE constraints, generated columns, RANGE partitions, and column-level GRANTs. If the schema is ORM-declared, all of these live in raw-SQL escape hatches anyway, and the ORM's model of truth silently diverges. Plain SQL also makes the schema reviewable as a diff, which matters when one of two developers is junior.
Rejected: ORM-first migrations (Django/Prisma/TypeORM autogenerate): would silently drop or fail to express the constraints that ARE the design. Hand-applied DDL: no reproducibility, and the repo has no migration tooling to build on (section B).
Identifier strategy
Decision: Dual identifier on every table. (1) Primary key: bigint GENERATED ALWAYS AS IDENTITY. All foreign keys use bigint. (2) public_id uuid NOT NULL UNIQUE, UUIDv7, on every externally addressable entity: candidate, job, job_version, job_posting, job_application, raw_intake, ats_result, interview, offer, app_user, candidate_document, candidate_merge. Every HTTP path, API response, email link and export uses public_id ONLY; bigint PKs are never serialised outside the database. UUIDv7 generated in the application (or a small plpgsql uuidv7() shim as the column DEFAULT; swap to native uuidv7() on PG18). (3) reference_code text UNIQUE on candidate, job and job_application only -- human-readable, per-entity sequence, preserving the prototype's CAN-5001 / JOB-1001 / APP-30001 shape (js/data.js:118, js/data.js:95, js/data.js:298). reference_code is INTERNAL-ONLY: it is enumerable by construction and must never appear in a candidate-facing URL or email.
Why: The two requirements pull opposite ways and both must be satisfied. Internally, bigint wins on every axis that matters at join time: 8 bytes vs 16 in every index and every FK, monotonic insertion so B-tree pages stay dense and WAL churn stays low, readable EXPLAIN output and trivially typed ad-hoc queries for a two-person team. Externally, a sequential id in a candidate-facing URL such as /applications/30042/status is a trivially enumerable disclosure of the entire candidate base, and application ids WILL appear in status emails to candidates. UUIDv7 supplies ~74 random bits, which defeats enumeration, while keeping time-ordering -- but since it is only ever an indexed lookup column and not an FK, its clustering benefit is a minor bonus rather than the reason for choosing it. Accepted leak: UUIDv7 embeds a millisecond creation timestamp, so a candidate-facing id reveals when their record was created; that is low severity for an HR system and is the price of not paying for a second index-wide 16-byte FK everywhere.
Rejected: UUID (v4) as primary key everywhere: doubles the size of every index and FK and destroys insert locality on high-volume tables (audit_event, ats_result_criterion, history tables). UUIDv7 as primary key everywhere: fixes locality but still doubles index width, and buys nothing that the public_id column does not already buy. bigint alone with no public_id: the enumeration exposure above. Natural keys (email as candidate PK): emails change, are merged, and are the thing duplicate detection is trying to reconcile.
URL-guessability is not authorization
Decision: public_id is an identifier, never a capability. Any candidate-facing surface (application status page, document upload link, interview confirmation) is gated by a separate candidate_access_token table: token_hash bytea (store the hash, never the token), subject reference, scope, issued_at, expires_at, consumed_at, revoked_at, issued_by. Internal recruiter access is gated by the application authorization layer against app_user, never by knowledge of a public_id.
Why: Unguessable ids drift into being treated as secrets the moment a link is emailed. Emailed links leak via forwarding, mail archives and support tickets, so they need expiry and revocation, which an entity id can never have. Hashing the token means a database read does not hand over live access. This also keeps candidate-facing access completely outside the recruiter permission model, which is relevant because the prototype has no authorization of any kind -- the RBAC matrix is a display widget with no can() function anywhere (js/rbac.js:78, js/rbac.js:111-112).
Raw intake to candidate to application resolution model
Decision: Six tables, in this order of creation. (1) intake_channel -- configured source (email mailbox, job-board webhook, careers form, referral form, manual recruiter entry), mirroring the prototype's inboxSources concept (js/data.js:284-300). (2) raw_intake -- immutable landing row: id, public_id, channel_id, external_message_id, received_at timestamptz, payload jsonb (full envelope/headers/form body), payload_sha256 bytea, state, resolved_at. UNIQUE (channel_id, external_message_id) and UNIQUE (channel_id, payload_sha256) make redelivery idempotent. (3) raw_intake_attachment -- one row per file: filename, mime_type, byte_size, sha256, object_store_key, virus_scan_status. (4) intake_parse_attempt -- APPEND-ONLY, many per intake: parser_name, parser_version, ai_model_version, status (succeeded/partial/failed), parsed jsonb, error jsonb, confidence numeric, started_at, finished_at. (5) intake_resolution -- the decision record: intake_id, resolution_kind (create_candidate / attach_to_existing_candidate / mark_duplicate / reject_unusable / quarantine), decision_mode (human/automatic), decided_by_user_id, decided_at, candidate_id, job_application_id, duplicate_of_candidate_id, reject_reason_id, auto_create_evidence jsonb. (6) candidate and (7) job_application. raw_intake.state enum: received, parsing, parsed, needs_review, resolved_new_candidate, resolved_existing_candidate, rejected_unusable, quarantined -- the last two are terminal states with NO candidate.
Why: The prototype's inbox is already pre-resolved: each row carries name, email, jobId, atsScore and recruiter directly (js/data.js:284-300), so there is no representable state for 'arrived but cannot become a candidate'. That is precisely the state that must exist. Separating parse attempts from the intake means a failed parse is retryable and re-auditable without mutating the arrival record, and a parser version bump can be replayed over historical intake. Separating resolution from both means the decision has its own actor, timestamp and reason, which is what makes the pipeline reviewable. Every application also records source_raw_intake_id, so 'where did this candidate come from' is a single FK hop, not a guess.
Rejected: A single inbox table with a status column (the prototype's shape): cannot hold multiple parse attempts, cannot distinguish 'parse failed' from 'human rejected', and loses the raw payload the moment a parser writes over it. Storing attachments as bytea in-row: bloats the table and TOAST, and blocks virus scanning as a separate stage -- object storage with sha256 and key in the row instead.
Invariants that stop a malformed email creating a candidate
Decision: Five layers, all in the database. (1) ORDERING: candidate.created_from_raw_intake_id bigint NOT NULL REFERENCES raw_intake(id), non-deferrable. A candidate row physically cannot be inserted before its intake row exists. Manual recruiter entry is not an exception -- the UI creates a raw_intake row on channel 'manual_ui' first. Likewise job_application.raw_intake_id bigint NOT NULL. (2) SHAPE: candidate has NO email or phone column. candidate_email.address_normalised text NOT NULL CHECK (address_normalised ~ '^[^@[:space:],;<>]+@[^@[:space:].,;<>]+([.][^@[:space:].,;<>]+)+' AND length(address_normalised) BETWEEN 6 AND 254 AND address_normalised = lower(address_normalised)), alongside address_original text NOT NULL preserving exactly what arrived. candidate_phone.e164 text CHECK (e164 ~ '^[+][1-9][0-9]{6,14}'). (3) CONTACTABILITY: a CONSTRAINT TRIGGER on candidate, DEFERRABLE INITIALLY DEFERRED, that raises unless at least one candidate_email or candidate_phone row exists for that candidate at COMMIT. (4) IDENTITY UNIQUENESS: CREATE UNIQUE INDEX uq_candidate_email ON candidate_email (address_normalised) WHERE deleted_at IS NULL AND suppressed_by_merge_id IS NULL. (5) NO SILENT AUTO-CREATE: intake_resolution CHECK (decision_mode = 'human' OR resolution_kind <> 'create_candidate' OR auto_create_evidence IS NOT NULL).
Why: Layer 2 is where a malformed email dies: an unparseable or truncated address fails the CHECK, the candidate_email insert aborts, and layer 3 then guarantees the transaction cannot commit a contactless candidate either -- so the intake stays in needs_review instead of producing a ghost record. A plain CHECK cannot express layer 3 because CHECK constraints cannot span tables, and a NOT NULL primary_email_id on candidate would create a circular FK and would wrongly forbid the legitimate phone-only referral; a DEFERRABLE constraint trigger is the only correct mechanism, and firing at COMMIT is what allows candidate and candidate_email to be inserted in either order within one transaction. Layer 4 turns 'one email, one identity' into a database fact, so even if application-side matching misses, the insert fails and the intake is forced into duplicate review -- the safety net under decision 8. Layer 5 keeps the guard THRESHOLDS out of the schema (they belong in a versioned matching config, because they will be tuned) while still guaranteeing that any automatically created identity carries the evidence that justified it. Regex validation is deliberately conservative and syntactic only; deliverability is a separate verified_at column set by an actual send or a verification service, never inferred.
Rejected: Email column directly on candidate: cannot hold the work/personal pair that real CVs carry, cannot record per-address provenance or verification, and makes merge unable to keep both addresses. citext for the address: adds an extension for a case-fold that lower() already gives, and citext is not a Unicode-correct case fold; storing a normalised column plus the original is better and matches the same preserve-the-original rule used for money. Application-only validation: bypassed by imports, integrations and psql fixes, all of which will happen in the first month.
Candidate identity model -- the promotion rule
Decision: A field is a first-class candidate column only if the system must FILTER, SORT, JOIN, or ENFORCE UNIQUENESS on it, or an invariant/report depends on it. It becomes a CHILD TABLE if a candidate can legitimately have more than one, or if each instance needs its own provenance, verification state or date range. It stays in JSONB only if it is parser-derived, shape-unstable, read as a whole for display or re-parse, and never a query predicate, never an FK target, and never referenced by any constraint. Two corollaries, both binding: promotion out of JSONB is ONE-WAY and happens on write, with the JSONB retaining the original untouched; and NO INVARIANT MAY DEPEND ON JSONB CONTENT.
Why: This is the rule that keeps the schema from either of the two failure modes. Without the first clause everything becomes a column and the parser's long tail (publications, references, language proficiency detail, section offsets) forces monthly migrations. Without the last clause JSONB becomes a shadow schema whose shape nothing enforces, which is where 'we thought that field was always present' incidents come from. The one-way promotion rule matters specifically because CVs get re-parsed with better parsers: keeping the original payload means a re-parse is a recomputation, not a data-recovery exercise.
Rejected: Wide-table candidate with 60 nullable columns: the prototype's shape (js/data.js:117-127) and it cannot hold two emails, two employers, or a skill's provenance. Everything-in-JSONB candidate document: no uniqueness enforcement on email, no FK from application, no efficient filtering for the list screens the prototype already has.
Candidate first-class columns, child tables, and legitimate JSONB
Decision: FIRST-CLASS on candidate: id, public_id, reference_code, full_name_original, display_name, name_normalised (generated: unaccent+lower, trigram-indexed), country_code, location_text, location_id (ref), current_title, current_employer_name, total_experience_months integer, highest_education_level_id (ref), source_channel_id, created_from_raw_intake_id, status_id, merged_into_candidate_id, retention_due_on, created_at, updated_at, deleted_at, deleted_by_user_id. CHILD TABLES: candidate_email, candidate_phone, candidate_skill, candidate_employment (employer_name, title, start_date, end_date NULL=current, is_current generated, CHECK end_date >= start_date), candidate_education, candidate_link (type + url, UNIQUE on normalised linkedin url), candidate_document (CV revisions; sha256, object_store_key, source_raw_intake_id, extracted_text, layout_metadata jsonb), candidate_consent (purpose, lawful_basis, granted_at, withdrawn_at -- append-only), candidate_tag, candidate_note. Skills are a controlled vocabulary: ref.skill plus ref.skill_alias, and candidate_skill (candidate_id, skill_id NULL, raw_label text NULL, proficiency, years_months, source, confidence, is_confirmed_by_recruiter, CHECK (skill_id IS NOT NULL OR raw_label IS NOT NULL)). LEGITIMATE JSONB, and nothing else: raw_intake.payload, intake_parse_attempt.parsed and .error, candidate_document.layout_metadata, ats_result.evidence, ats_result_criterion.matched_evidence, ai_model_invocation.request/response, audit_event.before/after, job_version.custom_fields, integration_webhook.payload.
Why: total_experience_months rather than the prototype's integer years (js/data.js:121) because months is what CV date arithmetic actually produces and rounding to years loses ordering; the parser's raw experience string stays in parse_attempt.parsed. Skills get a canonical table plus aliases because the prototype already uses a fixed skillsPool, so the taxonomy is nearly free -- and a taxonomy is what makes requirement matching, faceting and scoring reproducible, whereas free-text skills make every score depend on spelling. The nullable skill_id plus raw_label pair is deliberate: parser output that maps to nothing must still be storable and reviewable rather than dropped. candidate_document is a child table, not a column, because CV revisions are exactly what an ats_result must pin. Consent is append-only because a withdrawal that overwrites a grant destroys the lawful-basis audit trail. GIN indexes are added only on the JSONB columns actually queried (intake_parse_attempt.parsed, ats_result.evidence); indexing every raw payload is pure write cost.
Rejected: Skills as text[] on candidate: cannot carry proficiency, provenance or confidence, and GIN on a text array still leaves spelling variants unmatched. Employment history in JSONB: employer and date-range overlap are duplicate-detection signals and requirement inputs, so they must be queryable columns. A single generic candidate_attribute (EAV) table: unqueryable without pivots and untypable.
Versioning model for jobs and requirements
Decision: Entity plus immutable version rows. job holds stable identity only (id, public_id, reference_code, department_id, business_unit_id, created_at, current_version_id denormalised, current_primary_recruiter_id denormalised, status_id). job_version is immutable: id, job_id, version_no int, title, description, employment_type_id, location_id, grade_id, vacancies, salary_min_amount, salary_max_amount, salary_currency_code, custom_fields jsonb, content_hash bytea, effective_from, created_by_user_id, change_reason, superseded_at, UNIQUE (job_id, version_no). Requirements hang off the VERSION, never the job: job_requirement (id, job_version_id, kind, skill_id, operator, threshold_value numeric, unit, is_mandatory boolean, weight numeric(6,4) CHECK (weight >= 0 AND weight <= 1), display_order). job_posting is where a version is published (careers page, LinkedIn, Indeed, referral) and references exactly one job_version. Immutability is enforced twice: the application role receives only INSERT and SELECT on job_version and job_requirement, AND a BEFORE UPDATE OR DELETE trigger raises an exception. A DEFERRABLE constraint trigger validates that the weights within a job_version sum to 1.0 +/- 0.0001.
Why: Requirements on the version rather than the job is the single mechanism that makes score drift impossible: editing a requirement cannot mutate the inputs of an already-computed score because it necessarily mints a new job_version. The prototype has mutable single job records (js/data.js:85-108) with skills as a plain array, so a requirement edit today silently rewrites history. Two enforcement mechanisms for immutability rather than one because a GRANT can be misconfigured during environment setup while a trigger travels with the schema and documents intent in place. The weight-sum trigger is deferrable because requirements are inserted row by row within one transaction, and it exists because a half-weighted version silently distorts every score computed against it -- a defect that is invisible in the UI and expensive to discover later. job_posting exists so an application can pin the exact text the applicant actually read, which is the difference between a defensible and an indefensible rejection.
Rejected: SCD2 columns (valid_from/valid_to) on the job table itself: makes every FK to 'the job' ambiguous and forces temporal joins into ordinary list queries. A generic temporal_tables extension or trigger-based row shadowing: captures rows but not intent -- no change_reason, no version_no to cite, no natural place to hang requirements. Versioning only the job description text: requirements are the part that drives scoring, so leaving them mutable defeats the purpose.
Scoring configuration versioning and job binding
Decision: scoring_config holds identity (key, name, owner); scoring_config_version is immutable: id, scoring_config_id, version_no, algorithm_key, algorithm_code_version, aggregation_method, band_thresholds, hyperparameters jsonb, published_at, created_by_user_id, config_hash bytea. Enumerable weights are rows, not JSON: scoring_config_criterion (scoring_config_version_id, criterion_key, weight numeric(6,4), scale_min, scale_max, transform, is_mandatory_gate boolean). Binding to jobs is ORTHOGONAL to job versioning and historical: job_scoring_assignment (job_id, scoring_config_version_id, valid_from timestamptz, valid_to timestamptz, assigned_by_user_id, reason) with an EXCLUDE constraint preventing overlapping active bindings per job. Same INSERT/SELECT-only grants plus BEFORE UPDATE/DELETE trigger as job_version.
Why: Keeping the config binding out of job_version avoids polluting a job's edit history with rows where the job text did not change -- otherwise every scoring tweak fabricates a fake job revision, and 'what changed in this requisition' becomes unanswerable. Orthogonality is safe precisely because ats_result pins BOTH versions independently (next decision), so provenance never depends on the binding table being queried temporally. Criteria as rows rather than JSON because they are enumerable, joined to job_requirement, displayed in the explainability UI, and aggregated in reports; hyperparameters stay JSONB because they are algorithm-specific and not enumerable across algorithms. Storing algorithm_code_version on the config version records which scorer implementation the config was authored against, which is how a config/code mismatch becomes detectable rather than mysterious.
Rejected: job_version.scoring_config_version_id (a column on the job version): simpler to query but forces a new job version on every config change, as above. Weights as a jsonb blob: unqueryable for the explainability screen and unconstrainable by a sum-to-one check.
ATS result snapshot -- exact version pinning
Decision: ats_result (id, public_id, job_application_id NOT NULL, job_version_id NOT NULL, scoring_config_version_id NOT NULL, candidate_document_id, parse_attempt_id, algorithm_code_version text NOT NULL, ai_model_id, ai_model_version text, prompt_template_version text, overall_score numeric(6,3) NOT NULL CHECK (overall_score BETWEEN 0 AND 100), band text NOT NULL, input_fingerprint bytea NOT NULL, computed_at timestamptz NOT NULL, is_current boolean NOT NULL, superseded_by_id, reviewed_by_user_id, reviewed_at, review_outcome, review_note). Child: ats_result_criterion (ats_result_id, criterion_key, job_requirement_id, raw_value, normalised_score, weight_applied numeric(6,4) NOT NULL, contribution numeric(8,4) NOT NULL, matched_evidence jsonb). NO candidate_id column -- the score is reachable only through job_application. Append-only with a narrow exception implemented as a column-level grant: GRANT INSERT, SELECT ON ats_result plus GRANT UPDATE (is_current, superseded_by_id, reviewed_by_user_id, reviewed_at, review_outcome, review_note) ON ats_result. Rescoring inserts a new row, flips the prior row's is_current to false and sets superseded_by_id. CREATE UNIQUE INDEX ON ats_result (job_application_id) WHERE is_current. No status column may ever be set to 'rejected' by a scoring job: review_outcome is writable only by a request carrying an authenticated app_user, enforced by the app layer plus a CHECK (review_outcome IS NULL OR reviewed_by_user_id IS NOT NULL).
Why: Five independent things can change the number on screen -- the job's requirements, the scoring weights, the scorer code, the CV that was read, and the parse of that CV -- so all five are pinned as FKs or version strings on the row. weight_applied and contribution are STORED per criterion rather than recomputed on read, so the arithmetic that produced the displayed total is on disk even if a config row were somehow altered; this is the difference between 'we can probably reproduce it' and 'here is what we computed'. input_fingerprint (sha256 over the candidate feature vector, job_version.content_hash and config_hash) makes 'has anything actually changed since the last score' an index lookup, which is what keeps rescoring cheap and prevents pointless score churn. The FK to job_application with no candidate_id is the structural fix for the prototype, where aiScore is an integer hanging off the candidate (js/data.js:123) and a candidate therefore cannot hold two different scores for two jobs. Column-level UPDATE grants are used rather than a trigger with a column allowlist because Postgres enforces them at the privilege layer, before any trigger logic can be wrong. The CHECK on review_outcome plus reviewed_by_user_id is the schema-level expression of 'AI must never auto-reject'.
Rejected: Mutating overall_score in place on rescore: destroys the historical score, which is the exact requirement. Score on the candidate: the prototype's error. Recomputing contributions on read from the config version: correct in theory, but any code change to the aggregation silently rewrites historical reports.
Duplicate detection model
Decision: duplicate_candidate_pair (id, candidate_a_id, candidate_b_id, CHECK (candidate_a_id < candidate_b_id), UNIQUE (candidate_a_id, candidate_b_id), match_score numeric(5,4), signals jsonb, detector_name, detector_version, matching_config_version_id, detected_at, state (open / confirmed_duplicate / confirmed_distinct / merged), reviewed_by_user_id, reviewed_at, note). Signals, each stored with its own value for explainability: exact normalised email; exact E.164 phone; trigram similarity on candidate.name_normalised; trigram similarity on current_employer_name plus title plus employment date overlap; identical candidate_document.sha256; identical normalised LinkedIn URL. Indexes: GIN (name_normalised gin_trgm_ops), GIN (employer_name_normalised gin_trgm_ops), btree on phone e164 and document sha256. The detector MUST consult and skip pairs in state confirmed_distinct. Nothing merges automatically, ever: candidate_merge.performed_by_user_id is NOT NULL.
Why: The canonical-pair CHECK (a < b) with the composite UNIQUE is what stops the same pair being re-flagged in the opposite order every time the detector runs, which is the most common source of reviewer fatigue in duplicate queues. confirmed_distinct is load-bearing rather than cosmetic: without a persistent 'not a duplicate' memory, two genuinely different people with the same common name resurface in the queue on every detector run forever. Storing matching_config_version_id and detector_version means a threshold change is visible as a change in what was flagged, not an unexplained shift in queue volume. Trigram indexes serve both duplicate detection and Phase 1 fuzzy candidate search, so one mechanism is tuned and understood rather than two. Human-only merge because merge re-points history across two identities: a false-positive auto-merge of two real people is a data-protection incident, and the reversal machinery below exists to recover from human error, not to make automation safe.
Rejected: Auto-merge above a similarity threshold: the failure mode is silent and cross-contaminates two people's application history and compensation data. Storing only a composite score without per-signal values: the reviewer cannot see WHY, so review quality collapses. Levenshtein/fuzzystrmatch alone: no index support at scale, unlike trigram GIN.
Merge model -- additive re-pointing with an undo log
Decision: Merge is a reversible, additive re-pointing. It NEVER deletes and NEVER copies rows. Tables: candidate_merge (id, public_id, surviving_candidate_id, merged_candidate_id, CHECK (surviving <> merged), duplicate_pair_id, performed_by_user_id NOT NULL, performed_at, reason text NOT NULL, reversed_at, reversed_by_user_id, reversal_reason, reversal_blocked_reason, CHECK ((reversed_at IS NULL) = (reversed_by_user_id IS NULL))) and candidate_merge_operation (id, merge_id, seq int, op_kind (reparent_row / set_field / suppress_row / renumber_attempt / supersede_application), target_table text, target_row_pk bigint, column_name text, previous_value jsonb, new_value jsonb, UNIQUE (merge_id, seq)). The six mechanics: (1) the losing candidate row is retained with status 'merged' and merged_into_candidate_id set -- its public_id and reference_code stay resolvable and GET /candidates/{loser_public_id} 301-redirects to the survivor. (2) Child rows are RE-PARENTED by UPDATE ... SET candidate_id = survivor, one merge_operation row per row with previous_value {"candidate_id": loser}. (3) Colliding applications to the same job: the earlier-created application stays live, the other gets state 'superseded_by_merge' and superseded_by_application_id set; attempt_no collisions are renumbered, both recorded as operations. (4) Scalar survivor fields are overwritten only where the survivor is NULL, or per-field by explicit recruiter choice in the merge UI; each overwrite is an op_kind 'set_field' with previous_value. (5) Duplicate email/phone/link rows that would violate the global unique index get suppressed_by_merge_id set (op_kind 'suppress_row'), never deleted -- the unique index is partial on WHERE suppressed_by_merge_id IS NULL AND deleted_at IS NULL specifically to allow this. (6) The merge also writes audit_event rows, but the undo log is a SEPARATE, application-readable table.
Why: Re-parenting rather than copying is the decision that preserves all history without double-counting: copied application, interview and score rows would appear twice in every funnel report and would leave the originals orphaned under a dead identity. Retaining the loser row is not sentimentality -- its public_id is already inside sent candidate emails and recruiter bookmarks, so deleting it breaks live links, and its reference_code may be written on a paper interview note. The undo log records previous_value per affected row because that is the only representation from which reversal is a mechanical replay rather than a reconstruction; a merge diff summary is not reversible. The partial unique index on suppressed_by_merge_id is the specific detail that breaks naive merges: two duplicates by definition may share an email address, so re-parenting both violates a naive UNIQUE (address) and the tempting fix -- deleting one -- is exactly the data loss the requirement forbids. The undo log is kept out of audit_event because audit must remain append-only and hash-chained for forensics, while the undo log is operational data the merge feature reads and writes; conflating them would make the audit table mutable.
Rejected: Copy-then-soft-delete: double counting and orphaned history. Hard delete of the loser: breaks emailed links and destroys the reference. Storing only a jsonb snapshot of the loser: restores fields but cannot re-partition the child rows, so reversal is impossible. Reconstructing reversal from audit_event diffs: audit rows are partitioned, may be archived off-box, and may have PII redacted under retention -- an unreliable base for an operational undo.
Unmerge / reversal semantics
Decision: Reversal replays candidate_merge_operation for the merge_id in DESCENDING seq, restoring previous_value for each op, then sets reversed_at, reversed_by_user_id, reversal_reason and clears candidate.merged_into_candidate_id and status on the loser. Three hard rules. (1) STACK DISCIPLINE: a merge may be reversed only if no LATER unreversed merge touched any row this merge touched. Enforced in-database by a BEFORE UPDATE trigger on candidate_merge (WHEN reversed_at transitions from NULL) that queries candidate_merge_operation for later unreversed operations on the same (target_table, target_row_pk); out-of-order unmerge is REFUSED with an explicit error, never attempted. (2) POST-MERGE ROWS STAY: rows created after candidate_merge.performed_at remain with the survivor. This is mechanically checkable via created_at > performed_at, and the unmerge confirmation screen MUST list exactly which rows will stay before the recruiter confirms. (3) RETENTION INTERLOCK: if a retention purge has pseudonymised or blob-deleted either candidate, the purge sets candidate_merge.reversal_blocked_reason = 'retention_purge' and the trigger refuses reversal. There is no time limit on reversal otherwise.
Why: Descending replay is required because operations within a merge can be order-dependent (a suppress_row that only became necessary after a re-parent). Stack discipline is the honest boundary: if a second merge re-parented a row that the first merge had already moved, the first merge's previous_value is stale and blindly restoring it would move the row to a candidate it never belonged to -- silent corruption that no one would notice for months. Refusing is strictly better than attempting, and the fix path (reverse the later merge first) is obvious to the recruiter. Rule 2 is a deliberate, documented semantic rather than a limitation: a note written while the identities were merged has no defensible pre-merge owner, and guessing one would fabricate provenance; showing the recruiter the list before confirming turns an invisible surprise into an informed decision. Rule 3 exists because reversal without the loser's PII would produce a shell identity that looks like data loss -- blocking with a stated reason is more honest than half-reversing.
Rejected: Unlimited out-of-order unmerge: silent cross-contamination as above. A fixed reversal window (e.g. 30 days): arbitrary, and duplicate errors are often discovered when the candidate reapplies a year later. Re-splitting post-merge rows by heuristic: fabricated provenance.
Current state plus history -- mechanism
Decision: Denormalised current column on the entity PLUS a typed per-entity append-only history table, with a DATABASE TRIGGER as the single writer of history rows. Actor and reason reach the trigger through transaction-local settings: middleware issues SET LOCAL app.actor_user_id / app.actor_kind / app.change_reason / app.request_id at transaction start, and the trigger reads them with current_setting('app.actor_user_id', true). If unset, the trigger writes actor_kind 'system' and sets actor_unknown = true so gaps are VISIBLE rather than silent. History rows carry interval form: valid_from timestamptz NOT NULL, valid_to timestamptz NULL (NULL = current), and the trigger closes the previous row. Overlap is impossible by construction: EXCLUDE USING gist (subject_id WITH =, tstzrange(valid_from, valid_to) WITH &&) -- hence btree_gist. Separate typed history tables per dimension (job_application_stage_history, job_application_status_history, candidate_status_history, job_status_history, offer_status_history, interview_status_history), never one generic history table.
Why: The tradeoff is real and worth stating. Application-enforced history is more testable, visible in code, and needs no plpgsql from the junior developer; but any path that forgets -- a psql data fix, a migration, a bulk import, the merge routine -- loses history silently, and with two developers doing early ad-hoc data work, that will happen. Triggers cannot be bypassed, including by manual UPDATE, which is exactly the property the requirement needs. The SET LOCAL bridge resolves the usual objection that triggers cannot see who or why: they can, if the transaction tells them, and the actor_unknown flag converts a forgotten SET LOCAL into a visible data-quality signal instead of a wrong attribution. Storing valid_from/valid_to rather than only changed_at is a deliberate denormalisation: time-in-stage is the most-queried recruiting metric and becomes a subtraction rather than a window function over the whole history. Typed per-entity tables rather than one generic table because generic history needs text columns for entity and field names, loses FK enforcement, and makes the most common query (stage funnel) a filtered scan of every change in the system.
Rejected: Application-only dual writes: silent gaps, as above. One generic history/event table for everything: no FKs, no typed columns, no per-dimension EXCLUDE constraint, and it becomes the largest table in the database with the worst selectivity. Event-sourcing with current state as a projection: correct but far too much machinery for two developers and a Phase 1 delivery, and it makes ordinary list queries hard. Postgres system-versioned/temporal extensions: capture rows but not actor or reason.
Which entities get current-plus-history, and how recruiter assignment works
Decision: Current column plus history table: job_application (current stage_id and status_id, plus stage and status history), candidate (status_id), job (status_id), offer (status_id -- amounts are handled by immutable offer_version rows because an amount change is a new offer revision), interview (status_id, plus interview_slot rows for reschedules). Immutable versions only, no history table needed: job_version, job_requirement, scoring_config_version, offer_version, ats_result, candidate_consent, intake_parse_attempt. Recruiter assignment is history-shaped by nature and gets no separate history table: job_assignment (job_id, user_id, role_id, valid_from, valid_to, assigned_by_user_id, reason) and job_application_assignment (same shape, job_application_id) -- TWO concrete tables, not one polymorphic table. Roles include primary_recruiter, supporting_recruiter, sourcer, coordinator, hiring_manager, interviewer. Constraints: EXCLUDE USING gist (job_id WITH =, user_id WITH =, role_id WITH =, tstzrange(valid_from, valid_to) WITH &&) and CREATE UNIQUE INDEX ON job_assignment (job_id) WHERE role_id = <primary_recruiter> AND valid_to IS NULL. Denormalised job.current_primary_recruiter_id maintained by trigger. Pipeline stages are a reference table (ref.pipeline_stage: key, label, order_index, is_terminal, job_family_id), not an enum.
Why: Two concrete assignment tables rather than one polymorphic subject_type/subject_id table because a polymorphic FK cannot be enforced by the database at all, and unenforceable references to candidates and jobs are exactly what this design is trying to eliminate; the cost is one duplicated table shape, which is cheap and obvious. The partial unique index on the current primary recruiter preserves something the prototype accidentally had -- a single scalar recruiter on the job (js/data.js:96) -- while adding the flexibility and history it lacked; without that index, 'flexible assignment' degrades into two people each believing they own the requisition. The denormalised current_primary_recruiter_id exists because every list screen in the prototype's 23 routes (js/app.js:7-16) filters or displays by recruiter, and a temporal join on every row of every list is the wrong default. Offer amounts get immutable versions rather than a history table because a revised offer is a distinct document with its own approval, not a field edit. Stages as a reference table because the prototype hardcodes six stage strings (js/data.js:111) and the business will add and reorder them; an enum makes reordering and per-job-family variation painful and cannot carry order_index or is_terminal.
Rejected: Scalar recruiter_id on job with no history: the prototype's model (js/data.js:96, js/data.js:123), which cannot answer 'who owned this in March'. A single polymorphic assignment table: unenforceable FK. Native Postgres enums for stages and statuses: cannot carry display metadata or ordering, and value removal requires a type rewrite.
Money and currency
Decision: Every monetary value is an adjacent COLUMN PAIR: _amount numeric(14,2) and _currency_code char(3) REFERENCES ref.currency(code), with CHECK ((_amount IS NULL) = (_currency_code IS NULL)). ref.currency carries code, minor_unit, name, is_active. Ranges add CHECK (max_amount >= min_amount) and CHECK (min_currency_code = max_currency_code). A DEFERRABLE constraint trigger validates amount = round(amount, currency.minor_unit) so JPY 500000.50 cannot be stored. The ORIGINAL amount and currency are immutable and authoritative; conversions live in fx_rate (base_currency_code, quote_currency_code, rate numeric(18,10), as_of_date date, source, UNIQUE (base, quote, as_of_date, source)) and are recorded on the row as a denormalised reporting pair PLUS the pinned fx_rate_id -- e.g. base_salary_reporting_amount, base_salary_reporting_currency_code, fx_rate_id -- with CHECK ((fx_rate_id IS NULL) = (base_salary_reporting_amount IS NULL)).
Why: The column-pair CHECK is the single highest-value money constraint: an amount without its currency is unusable and, worse, gets silently assumed to be the local currency by whoever reads it next. The prototype has bare integers and no currency field anywhere (js/data.js:126, js/data.js:99), and offer validation checks only salary > 0 (js/offers.js:129), so this is a genuine gap rather than a formality. numeric rather than integer minor units because it keeps every query, report and CSV export readable without a division everyone must remember, and Postgres numeric arithmetic is exact; the minor_unit rounding trigger recovers the one guarantee integer minor units give for free. Storing the converted value WITH fx_rate_id, rather than recomputing at read time, is the same philosophy as ats_result version pinning: a board report run today and re-run next quarter must show the same number, which is impossible if conversion happens at read time against a moving rate table. The original is never overwritten, so a wrong rate is corrected by inserting a new fx_rate and recomputing the reporting columns, and the offer as agreed is still on disk. Compensation is classified sensitive_personal (see the PII decision), not ordinary personal data.
Rejected: Postgres money type: locale-dependent output and an implicit single-currency assumption -- never appropriate. float or double precision: rounding errors in compensation are indefensible. Integer minor units: correct but every ad-hoc query needs a mental division, and this team will write many ad-hoc queries. A single generic money table joined everywhere: turns every offer read into an extra join for no invariant gain. Converting at read time: report values would drift, violating the same rule the ATS snapshot exists to enforce.
Timestamps and timezone-aware interview scheduling
Decision: Every instant is timestamptz stored in UTC; timestamp without time zone is BANNED except for the one documented case below. Naming convention: *_at for timestamptz instants, *_on or *_date for date columns holding genuinely calendar-only values (retention_due_on, fx_rate.as_of_date, employment start_date/end_date where only month precision exists). Interview scheduling stores BOTH the resolved instant and the intent: interview (starts_at timestamptz NOT NULL, ends_at timestamptz NOT NULL, CHECK (ends_at > starts_at), scheduling_timezone text NOT NULL validated against pg_timezone_names, local_start_wall timestamp NOT NULL -- the only permitted naked timestamp, being the wall-clock time the organiser chose -- and slot tstzrange GENERATED ALWAYS AS (tstzrange(starts_at, ends_at, '[)')) STORED). Interviewer availability and working hours are stored as wall-clock rules, never instants: (user_id, timezone, weekday, local_start_time time, local_end_time time). Double-booking is prevented in the database: on interview_participant, EXCLUDE USING gist (user_id WITH =, slot WITH &&) WHERE (status IN ('scheduled','confirmed')). IANA zone NAMES only -- a UTC offset such as +05:00 is never stored as a timezone.
Why: For a single one-off interview, starts_at alone would suffice; both columns are stored anyway because the moment there are recurring panel slots, interviewer availability windows, or a reschedule across a DST boundary, the intent ('09:00 in Asia/Karachi') and the instant diverge, and recomputing intent from UTC after a tzdata release can give a different wall-clock answer. Storing local_start_wall plus the zone makes a tzdata update a re-resolution job over a queryable set of rows rather than silent data loss, and the mild redundancy for simple interviews is worth the uniformity. Availability as wall-clock rules is not optional: 'available 09:00-17:00 local' converted to UTC becomes wrong twice a year. Validating the zone against pg_timezone_names matters because integrations will send 'PST', 'IST' and 'Asia/Calcutta'-style values that silently resolve to the wrong or a deprecated zone. The GiST EXCLUDE on participant slots is the strongest available form of the double-booking rule and costs one index; the prototype has no timezone discipline at all -- plain JS Dates, toLocaleDateString for display, and a hardcoded today of 2026-07-09 (js/data.js:54, js/data.js:237, js/candidates.js:18).
Rejected: timestamp without time zone plus a convention that everything is UTC: one forgotten cast and the data is wrong with no way to detect it. Storing only local time plus offset: offsets do not survive DST or zone-rule changes. Storing only UTC for interviews: loses the organiser's intent, which is what reschedule and recurrence need. Application-only conflict checking: races under concurrent scheduling; the EXCLUDE constraint does not.
Soft delete, PII classification and retention
Decision: SOFT DELETE: deleted_at timestamptz plus deleted_by_user_id on recruiter-removable entities only -- candidate, job_application, candidate_note, candidate_document, candidate_tag, task, saved view. NEVER on append-only tables: audit_event, ats_result, all *_history, all *_version, raw_intake, candidate_merge, candidate_consent. Every uniqueness rule that must tolerate re-creation is a PARTIAL index with WHERE deleted_at IS NULL. Default application reads go through per-entity views (v_candidate_live, v_job_application_live), so seeing deleted rows requires deliberately querying the base table. PII: a machine-readable registry, pii_classification (table_name, column_name, class in (internal, personal, sensitive_personal, special_category), lawful_basis, retention_policy_id, masking_strategy in (none, hash, truncate, tokenise, null_out, pseudonymise), PRIMARY KEY (table_name, column_name)), with a CI check asserting every column on a candidate-touching table has a row. Classes: personal = name, emails, phones, location, employment and education history; sensitive_personal = compensation, CV files and extracted text, interview scorecards, AI evidence; special_category = NONE IN PHASE 1 (no diversity, health or accommodation data is stored). RETENTION: retention_policy (key, subject, basis, retain_for interval, trigger_event), candidate.retention_due_on date maintained by trigger from last meaningful activity, and retention_hold (subject_type, subject_id, reason, placed_by, placed_at, released_at) which the purge must skip. The purge ACTION is PSEUDONYMISATION, not row deletion: personal columns are replaced with deterministic tokens, CV blobs are deleted from object storage, and skeleton rows (ids, timestamps, job_application, ats_result scores, stage history) are retained. Each run writes retention_action (subject, policy_id, executed_at, columns_affected, blob_keys_deleted).
Why: Pseudonymisation rather than row deletion is the central reconciliation in this whole design: the brief requires that history exist permanently, and data-protection law requires erasure -- deleting rows would tear holes in funnel metrics, break FKs from audit and history, and make the merge undo log unreplayable, while pseudonymisation satisfies erasure of identifying data and leaves the statistical shape intact. A machine-readable classification table rather than only SQL column comments because three separate jobs must READ it -- the purge job, the subject-access export, and the non-production anonymisation script -- and a comment is not queryable; the CI completeness check is also a well-scoped, independently demonstrable task for the junior developer. retention_due_on is stored rather than computed so the nightly purge is an index range scan instead of a full-table computation. Soft delete is explicitly NOT erasure and must never be presented as such. Excluding special_category data entirely in Phase 1 is a decision, not an omission: it requires separate access control, aggregate-only reads and a distinct lawful basis, none of which is in scope.
Rejected: Hard delete on retention: breaks FKs, history and reporting, and makes merge reversal impossible. is_deleted boolean instead of deleted_at: loses when and therefore cannot drive retention. RLS-based soft-delete filtering in Phase 1: disproportionate for two developers; views plus code review is the right weight. PII classification in column comments only: not queryable by the jobs that need it.
Audit table strategy
Decision: ONE table, audit.audit_event, PARTITION BY RANGE (occurred_at), monthly partitions, PRIMARY KEY (id, occurred_at) with id from a single shared sequence (a partitioned table's PK must include the partition key -- a real gotcha to encode once). Columns: occurred_at, actor_user_id, actor_kind (user / system / integration / ai_agent), on_behalf_of_user_id, request_id uuid, session_id, ip inet, user_agent, action, entity_table, entity_pk, entity_public_id, before jsonb, after jsonb, changed_columns text[], outcome (success / denied / error), denial_reason, prev_hash bytea, row_hash bytea. WRITERS: a generic trigger on every classified table for data-change events, PLUS explicit application writes for ACCESS events (profile viewed, export run, chatbot query answered) which no trigger can observe. TAMPER RESISTANCE, in ascending order of strength and stated honestly: (1) the application role holds only INSERT and SELECT; UPDATE, DELETE and TRUNCATE are revoked, and DDL lives in a separate migration role. (2) A BEFORE UPDATE OR DELETE trigger raises an exception. (3) row_hash = sha256(prev_hash || canonical row), chained per partition, with a nightly verifier that recomputes and alerts. (4) WAL archiving/PITR plus a daily export of the closed partition to write-once object storage (S3 Object Lock or immutable blob) with that partition's final row_hash recorded. PII RULE: for columns classified sensitive_personal or above, before/after store a hash and the fact of change, not the value; raw values for those columns live only in the entity and its history tables, where the retention purge can reach them. For merely personal columns raw values are stored, and a narrow, logged redaction path (audit_event_redaction) exists for erasure requests. Partitions older than 13 months are detached, compressed and archived; audit retention is set independently of candidate retention.
Why: Monthly RANGE partitioning is chosen for growth, not fashion: with data-change plus access auditing over candidates, applications, scores, interviews and offers, tens of millions of rows over a few years is the realistic order of magnitude (assumption), and partitioning makes archival a DETACH rather than a multi-hour DELETE that bloats the table. Layers 1 and 2 are duplicated deliberately -- grants can be misconfigured during environment setup, and the trigger travels with the schema. Layer 3 is stated as tamper EVIDENCE, not prevention: hash chaining detects modification by anyone who bypasses the first two layers, including a DBA, but cannot stop it; claiming otherwise would be dishonest, and only layer 4 (an off-box immutable copy) provides a genuine independent check. The PII rule resolves an otherwise unresolvable conflict: append-only audit versus the right to erasure. Keeping sensitive values out of audit payloads means the append-only guarantee survives erasure for the data that matters most, and the narrow redaction path handles the remainder as a logged exception rather than an unlogged UPDATE.
Rejected: A separate audit database or SIEM stream in Phase 1: new infrastructure for no Phase 1 requirement. Blockchain or external notarisation: cost and complexity far beyond an internal HR system. Per-entity audit tables: fragments the forensic query that matters ('everything actor X did in this window'). One unpartitioned audit table: archival becomes a mass DELETE and autovacuum problem. Trigger-only auditing: cannot capture reads, which is the compliance requirement that matters most for candidate PII.
Reapplication rule
Decision: RULE: at most ONE application per (candidate, job) in a non-terminal state; unlimited applications over time, each a distinct numbered attempt, and a new attempt may be created only when the previous attempt is terminal AND now() >= previous.terminal_at + cooling_off, where cooling_off comes from a versioned config (default 90 days; 0 for withdrawn_by_candidate; shorter for a rejection reason of role_filled). Uniqueness is per JOB (the requisition identity), not per job_version and not per job_posting. ENFORCEMENT: (1) state text GENERATED ALWAYS AS (CASE WHEN status_key IN ('hired','rejected','withdrawn','expired') THEN 'terminal' ELSE 'active' END) STORED -- so the index predicate can never disagree with the status. (2) CREATE UNIQUE INDEX uq_application_live ON job_application (candidate_id, job_id) WHERE state = 'active' AND deleted_at IS NULL AND superseded_by_application_id IS NULL. (3) attempt_no int NOT NULL DEFAULT 1 with UNIQUE (candidate_id, job_id, attempt_no). (4) CHECK ((state = 'terminal') = (terminal_at IS NOT NULL)). (5) Cooling-off is a BEFORE INSERT constraint trigger reading the config, with an explicit audited override: cooling_off_override_by_user_id plus override_reason.
Why: Per-job rather than per-posting because the classic recruiter complaint is the same person arriving twice for one requisition through LinkedIn and the careers page (both are real sources in the prototype's inbox, js/data.js:284-300); per-posting uniqueness would permit exactly that. The generated state column exists so the partial index predicate is derived from status rather than maintained in parallel with it, which removes an entire class of bug where a status transition forgets to update the flag the index depends on. attempt_no makes reapplication history explicit and orderable instead of something inferred from timestamps, and it is what the merge routine renumbers. The cooling-off override is deliberately present: a hard block would be circumvented by recruiters creating a duplicate candidate record to get around it, which is strictly worse than an audited override -- the constraint should shape behaviour, not invite evasion. Excluding superseded_by_application_id from the index predicate is not incidental: it is the exact escape hatch merge step 3 requires when both merged identities applied to the same job, and without it merge would be blocked by this constraint.
Rejected: Hard ban on reapplication ever: wrong for a real talent pool, where a candidate rejected for seniority two years ago is a strong hire now. No constraint at all (the prototype, where a candidate carries a single jobId, js/data.js:120): permits unlimited duplicate live applications and inflates every funnel metric. Uniqueness on (candidate, job_version): a requirement edit would silently permit a second live application. Enforcing cooling-off as a CHECK: impossible, since it needs another row.
Phase 1 search strategy
Decision: Entirely inside Postgres. (1) Structured filters (stage, job, recruiter, department, location, experience range, score band) on btree and composite indexes, with partial indexes for the hot list screens -- this is the large majority of what the prototype's screens actually filter on (js/candidates.js). (2) Free text via a dedicated candidate_search_index table (candidate_id PK, document tsvector, refreshed_at), trigger-maintained from candidate AND its child tables, with GIN on document. Weighting via setweight: name A; current title and employer B; skills B; education and location C; CV extracted text D. Text config: simple for names, english for CV text, unaccent in the normalisation pipeline. (3) Fuzzy and typo-tolerant name/employer search via pg_trgm GIN on name_normalised and employer_name_normalised using similarity() and the % operator -- the same indexes duplicate detection uses. (4) Ranking = ts_rank_cd blended with recency and ATS band, with the blend weights held in a versioned config row rather than hardcoded, replacing the prototype's ad-hoc client-side relevance blend (js/candidates.js:18). (5) Facet counts by plain GROUP BY over the filtered set. PHASE 2 SEMANTIC PATH: pgvector in the SAME database -- candidate_embedding (candidate_id, model_id, model_version, source_document_id, embedding vector(N), generated_at) with an HNSW index, used as hybrid retrieval (FTS/trigram candidate generation, vector rerank). Embeddings are versioned per model so a model swap cannot silently change matching.
Why: A separate search_index table is the honest correction to the usual advice: a GENERATED tsvector column can only reference its own row, so it breaks the moment skills, education and CV text are child tables -- which they must be. One row per candidate, maintained by triggers on the contributing tables, keeps the GIN index small and lets a reindex be a targeted UPDATE. Reusing the trigram indexes for both search and duplicate detection means one mechanism is tuned, understood and tested rather than two. Holding the ranking blend in versioned config follows the same rule as scoring configs: a relevance change should be attributable, not a mystery. Keeping vectors in Postgres rather than a vector database is what makes the constraint against a separate AI service in Phase 1 practically achievable, and hybrid retrieval is also cheaper and more explainable than pure vector search for recruiter queries, which are usually part keyword and part concept.
Rejected: Elasticsearch or OpenSearch in Phase 1: forbidden, and unjustifiable when the prototype holds 100 generated rows (js/data.js:112). LIKE '%term%' scans: no index usage and no ranking. A generated tsvector column on candidate: cannot see child tables. A separate vector database: a second datastore, a second consistency problem, and no Phase 1 requirement.
Threshold for a separate search service
Decision: Revisit only when ANY of these actually fires: (a) candidate rows exceed roughly 2,000,000 or indexed searchable text exceeds roughly 50 GB; (b) p95 search latency exceeds 500 ms on the tuned FTS plus trigram path AFTER index tuning and after moving search to a read replica; (c) sustained search throughput exceeds roughly 50 queries/second and search measurably degrades transactional write latency; (d) a product requirement appears that Postgres genuinely cannot serve -- live per-field BM25 relevance experimentation, learning-to-rank, sub-second facets across more than about ten dimensions, or cross-entity typo-tolerant autocomplete under 50 ms. The intermediate steps to exhaust FIRST, in order: index and query tuning; a read replica dedicated to search; a materialised search table; and a BM25 extension (pg_search / ParadeDB) if ranking quality alone is the gap.
Why: Numeric triggers rather than a vague 'when we outgrow it' because otherwise the decision gets made by enthusiasm rather than evidence, and the second datastore brings a permanent dual-write and reindex-drift cost that two developers will feel every week. ASSUMPTION, labelled as such: an internal Utopia Brands recruiting platform will hold on the order of 10^4 to 10^5 candidates accumulated over several years, roughly three to four orders of magnitude below trigger (a). On that basis the honest conclusion is that Postgres full-text search plus trigram will very likely never be outgrown for this system, and the read replica in step (b) is the realistic ceiling of what will ever be needed. Naming the escalation ladder before the trigger matters because in practice most 'we need Elasticsearch' moments are an unindexed query or an untuned ranking function.
Chatbot and AI query isolation
Decision: Phase 1: the chatbot has NO SQL access. It calls the same authorization-checked application service layer as the UI, through a whitelisted set of parameterised, typed query intents; text-to-SQL against any application role is prohibited. Every chatbot answer writes an access audit_event with actor_kind 'ai_agent' and on_behalf_of_user_id set to the asking user. Phase 2, only if ad-hoc querying is genuinely required: a DEDICATED PostgreSQL role for the AI path with row-level security policies keyed to current_setting('app.actor_user_id') plus column-level privileges that exclude sensitive_personal columns, so the access boundary is enforced by the database rather than by prompt engineering.
Why: 'The chatbot must never bypass access controls' is only a real guarantee if something other than the prompt enforces it. In Phase 1 the cheapest correct enforcement is to give the chatbot no capability the UI does not have -- a shared service layer means there is exactly one authorization implementation to review, which matters when the repository currently has none at all (the RBAC matrix is a display widget with no can() function anywhere, js/rbac.js:78, js/rbac.js:111-112). RLS is deferred rather than dismissed because it demands disciplined SET LOCAL usage on every pooled connection and is easy to get subtly wrong; introducing it in Phase 1 alongside a brand-new authorization layer doubles the risk. Recording on_behalf_of_user_id is what makes AI-mediated access auditable as a delegated action rather than an anonymous system read.
Rejected: Text-to-SQL over the primary application role: an unbounded read capability that no prompt-level guard reliably constrains. RLS everywhere in Phase 1: disproportionate risk and cost for two developers building the authorization layer at the same time. A separate read replica for the chatbot with no RLS: solves load, not authorization.
Enum and reference-data strategy
Decision: Three tiers, applied consistently. (1) REFERENCE TABLES in schema ref for anything the business will edit or that needs display metadata: pipeline_stage (with order_index, is_terminal, job_family_id), rejection_reason, source_channel, currency, department, business_unit, grade, employment_type, skill, skill_alias, education_level, location, assignment_role, application_status. Each has a stable text key plus a surrogate bigint id, and FKs point at the id. (2) text plus CHECK for small closed technical sets that only engineers change: actor_kind, decision_mode, op_kind, outcome, raw_intake.state. (3) Native Postgres enum types: NOT USED. Generated columns derived from reference keys (such as job_application.state) join through the key, not the id.
Why: The prototype hardcodes stage lists, source lists, statuses and a skills pool as JavaScript arrays (js/data.js), and every one of those is something a recruiting lead will want to change without a deployment -- which is the definition of reference data rather than a type. Reference tables also carry the display metadata (label, order_index, is_terminal, colour) that the existing UI already needs, so there is nowhere else for it to live. Native enums are avoided because adding a value is easy but reordering or removing one requires a type rewrite, they cannot carry metadata, and they force a migration for what should be a row insert. text plus CHECK is kept for the technical sets because those genuinely are code-coupled and a reference table for them would be ceremony with a join cost.
Rejected: Native enum types throughout: migration pain and no metadata. text with no constraint anywhere: the prototype's model, where typos become new statuses silently. Reference tables for the technical sets too: needless joins on the hottest append paths (audit_event, merge operations).
Risks flagged:
- Trigger-maintained history depends on middleware always issuing SET LOCAL app.actor_user_id. If the middleware is missing on a code path (background jobs, integrations, imports, psql fixes), history rows are still written but attributed to 'system' with actor_unknown = true. Mitigation is a dashboard query on actor_unknown counts, treated as a data-quality alarm rather than noise; without that alarm the attribution gap is invisible.
- The volume of plpgsql in this design (history triggers, deferrable contactability and weight-sum constraint triggers, immutability triggers, audit hash chaining, search index maintenance) is a real skills and maintenance risk for a two-person team where one developer is junior. Recommended split: Talha owns all trigger and constraint code; Ahmed Mujtaba owns migrations, reference data, the pii_classification CI completeness check, search index tuning and the constraint test suite. Every trigger needs a test that attempts the forbidden write and asserts the exception.
- Merge reversal correctness is the highest-risk logic in the schema. Its guarantees rest on the completeness of candidate_merge_operation: any table re-parented by merge but not recorded silently becomes unreversible, and the failure only surfaces the first time someone unmerges months later. Mitigation: enumerate every table carrying candidate_id in one place, and add a test asserting that the merge routine records an operation for each of them.
- Stack-discipline enforcement (refusing out-of-order unmerge) will occasionally block a legitimate reversal that a human could reason about, producing a support escalation with no in-product resolution path. Accepted deliberately over the alternative of silent cross-contamination, but the error message must name the blocking merge so the recruiter knows what to reverse first.
- The global unique index on candidate_email.address_normalised makes 'one email, one candidity' a hard rule. Real cases will violate it: shared family email addresses, agency-submitted candidates all using the agency's mailbox, and generic info@ addresses on referral forms. Those intakes will fail resolution and pile up in needs_review. Mitigation: allow agency and generic addresses to be flagged non-identifying in a reference list and excluded from the unique index predicate -- decide this before go-live, not after the queue backs up.
- Storing both starts_at and local_start_wall for interviews permits divergence if any write path updates one without the other. A CHECK cannot verify the relationship because it requires timezone resolution. Mitigation: a single scheduling service function is the only writer, plus a nightly reconciliation job reporting rows where local_start_wall AT TIME ZONE scheduling_timezone does not equal starts_at.
- Keeping sensitive_personal values out of audit before/after payloads weakens forensic reconstruction: an investigation can prove a compensation field changed and when, but not to what value, unless the offer_version history is intact. This is an accepted trade against erasure obligations, but it must be documented so no one later assumes audit alone is sufficient evidence.
- Retention pseudonymisation permanently blocks merge reversal for affected candidates (reversal_blocked_reason). If purge cadence is aggressive, reversibility silently erodes over time. Mitigation: exclude candidates involved in an unreversed merge from purge for a defined window, or require explicit acknowledgement that reversibility is being given up.
- The 2,000,000-row search threshold rests on a labelled ASSUMPTION about Utopia Brands recruiting volume (order 10^4 to 10^5 candidates). If the platform is later pointed at bulk job-board feeds or high-volume seasonal hiring, candidate and raw_intake growth could be one to two orders of magnitude higher than assumed and both the search and audit sizing should be re-derived rather than inherited.
- No backend language, framework or hosting decision has been made here, and the migration-runner choice is deliberately left to follow it. If the backend agent selects an ORM-first stack (Django, Prisma, TypeORM with autogenerated migrations), the plain-SQL migration decision will collide with that stack's idioms. Flag this as a cross-document dependency to reconcile explicitly rather than discovering it in implementation.
- Deferring row-level security to Phase 2 means Phase 1 candidate PII protection rests entirely on a brand-new application authorization layer, in a codebase that currently has no authorization whatsoever (_repo-findings.md section D). If that layer slips, there is no database-level backstop. Concrete mitigation: one centralised authorization module, no direct repository access from controllers, and a test asserting that every candidate-reading endpoint passes through it.
- The systemic XSS exposure identified in _repo-findings.md section E interacts directly with this schema: candidate_email.address_original, full_name_original, raw_intake.payload and intake_parse_attempt.parsed are all deliberately preserved unsanitised so the original is recoverable. That is correct for storage, but it guarantees attacker-controlled strings reach the rendering layer. Storage-side preservation must be paired with output-side escaping; sanitising on write would violate the preserve-the-original rule and is the wrong fix.
- Audit partitioning introduces the composite primary key (id, occurred_at). Any ORM or tooling assuming a single-column integer PK on audit_event will misbehave, and developers will hit it during the first attempt to reference an audit row. Cheap to handle if documented in the schema up front; confusing if discovered at runtime.
- No PostgreSQL version has been pinned to a specific major yet, and UUIDv7 generation differs by version (native uuidv7() from PG18, application-side or a plpgsql shim before that). Choosing the managed-hosting version and the UUIDv7 source should be a single explicit decision at provisioning time so the column DEFAULT does not differ between environments.