HR-ATS-Portal/docs/architecture/adr/0007-job-and-scoring-versio...

29 KiB

ADR 0007 — Job, requirement and scoring-configuration versioning

Status: Accepted — 2026-07-29.

Deciders: Talha Ahmed (immutability triggers, weight-sum constraint trigger, rescore orchestration, input_fingerprint), Ahmed Mujtaba (version DDL and migrations, requirement editor UI that mints versions, the explainability panel that reads ats_result_criterion, the constraint test suite that asserts forbidden writes raise).


Context

The requirement is blunt: jobs, requirements and scoring configs must be versioned, and ATS scores are per application. The prototype violates every part of it.

Fact Evidence Consequence
Jobs are mutable single records with skills as a plain array js/data.js:85-108 A requirement edit today silently rewrites the basis of every score already computed. There is nothing to migrate and nothing to preserve
aiScore is a random integer, int(52,98) js/data.js:123 No components, no evidence, no model, no version, no algorithm. Zero reusable scoring logic exists — this is a greenfield build, not a refactor
The score hangs off the candidate, and the candidate carries a single jobId js/data.js:123, js/data.js:120 One candidate structurally cannot hold two scores for two jobs. The fix is the candidate/application split, and the score must attach to the application
A separate client-side "relevance" blend also exists, computed against a hardcoded today js/candidates.js:18, js/data.js:237 Two unattributable numbers currently describe the same candidate. Both are replaced
No versioning of anything, anywhere; no history tables _repo-findings.md §F Versioning is cheap to design in now and expensive to retrofit, because retrofitting means backfilling versions that were never captured
Two developers, one junior; the design leans heavily on plpgsql _repo-findings.md §I, _decisions.md risk list The immutability and weight-sum mechanisms must be written once by Talha and then be impossible for anyone to bypass, including by psql

Regulatory framing that makes this more than hygiene: an ATS score contributes to a hiring decision across six jurisdictions. If a candidate or a regulator asks "on what basis was this application ranked at 61.4 on 3 March", the only defensible answer is a stored row that names the requirements, the weights, the scorer, the CV and the parse of that CV as they were at that moment. "We can probably reproduce it" is not an answer. That is the requirement this ADR exists to satisfy.

Binding decisions this ADR restates and deepens: the versioning pattern (immutable version rows plus a current_version_id pointer), append-only scores, and the structural rule that AI output is a suggestion and never a domain write.


Options considered

# Option Pros (at their strongest) Cons
A Entity plus immutable version rows. job holds stable identity; job_version is immutable and carries the content; job_requirement hangs off the version; scoring_config_version is separately immutable; binding is temporal and orthogonal via job_scoring_assignment; ats_result pins five independent inputs Score drift becomes structurally impossible: editing a requirement cannot mutate the inputs of a computed score, because it necessarily mints a new version. Requirements are queryable rows, so "which requisitions require Python at level 3" and "diff v3 against v4" are ordinary SQL. change_reason and version_no give a citable artefact. Publishing pins the exact text an applicant read. Immutability is enforceable at the privilege layer, not by convention More tables and more joins; every read of "the job" resolves a pointer. Version rows accumulate. A weight-sum invariant needs a deferrable constraint trigger. Rescore orchestration becomes a real subsystem. Developers cannot fix a job with a quick UPDATE, which is friction by design and will be felt
B SCD2 on the job table itselfvalid_from / valid_to columns, one row per state, current row identified by valid_to IS NULL One table, a familiar pattern, no pointer indirection, and "current" is a trivially indexable predicate. Cheap to implement and easy for a junior to reason about Every foreign key to "the job" becomes ambiguous — does an application reference the requisition or one temporal slice of it? Ordinary list queries acquire a temporal predicate, and forgetting it silently returns duplicates. Requirements still need their own temporal table, so the simplicity is partly illusory. There is no stable identity row to hang a reference code, department or current recruiter pointer on
C Generic row-shadow or temporal auditing (django-simple-history, a temporal_tables-style extension, trigger-based shadow tables) Nearly free and uniform across every entity. No per-entity design work. Gives a complete change trail with almost no code, and it is genuinely the right tool for an admin change log Captures rows, not intent. There is no version_no a score row can cite, no change_reason, and — decisively — nowhere to hang requirements, because a shadow table mirrors columns rather than modelling a composite. "What did version 4 require" becomes diff inference across two tables. It also cannot express immutability: the shadow is a side effect of a mutable primary row
D Event-source the requisition, with current state as a projection Perfect history by construction, replayable, and every change carries intent naturally. Would answer every audit question this ADR cares about Far too much machinery for two developers and a Phase 1 delivery, and ordinary list queries become the hard case. Already rejected as a cross-cutting pattern in _decisions.md; re-adopting it for one aggregate would mean two persistence philosophies in one codebase
E Copy-on-score snapshot — no version tables at all; ats_result stores a JSONB snapshot of the whole job plus the whole scoring config as they were Genuinely tempting: dead simple, perfectly reproducible, no immutability triggers, no version numbering, no binding table. Every score is self-contained and can never drift. Fewer tables than any other option The snapshot is unqueryable and uncomparable: no "which requisitions require Python", no version diff, no shared identity for reporting across the versions of one requisition, and no way to answer "how many applications were scored against v3". It violates the binding rule that no invariant may depend on JSONB content, and the weight-sum check would have nothing to constrain. It also duplicates the full job and config on every score row, which at 20k-60k applications/year is real bloat for data that is identical across thousands of rows. Reproducibility is necessary but not sufficient
F Version only the job description text; leave requirements mutable Cheapest possible nod to the requirement; the narrative history recruiters ask about most often is the description Requirements are the part that drives scoring. Leaving them mutable defeats the entire purpose and would make every historical score indefensible while appearing to satisfy the constraint. Worse than doing nothing, because it looks compliant

Decision

Option A. Five mechanisms, each with a named enforcement point.

1. Job identity is separate from job content

Table Role Key columns
job stable identity only id, public_id, reference_code, department_id, business_unit_id, status_id, current_version_id (denormalised), current_primary_recruiter_id (denormalised, trigger-maintained), created_at
job_version immutable content id, job_id, version_no, 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)
job_requirement immutable, hangs off the version id, job_version_id, kind, skill_id, operator, threshold_value numeric, unit, is_mandatory, weight numeric(6,4) CHECK (weight BETWEEN 0 AND 1), display_order
job_posting where a version is published references exactly one job_version; one row per channel (careers page, LinkedIn, Indeed, referral)

Requirements on the version rather than the job is the single mechanism that makes score drift impossible. Everything else in this ADR is support for that sentence.

2. Immutability is enforced twice, deliberately

  1. The application role receives only INSERT and SELECT on job_version and job_requirement.
  2. A BEFORE UPDATE OR DELETE trigger raises an exception.

Two mechanisms rather than one because a GRANT can be misconfigured during environment setup, while a trigger travels with the schema and documents intent in place. Every trigger gets a test that attempts the forbidden write and asserts the exception — that test suite is a junior workstream.

3. Draft editing does not mint versions — the boundary this ADR adds

The summary decision does not say when a version is created, and the naive reading ("on every save") would mint fifty versions while a recruiter drafts a requisition, making version_no meaningless and the audit trail unreadable. Therefore:

  • A requisition under composition lives in a mutable job_draft row (one live draft per job, holding the same shape as job_version plus a draft_requirement child table).
  • A job_version row is minted only at a publish or approve transition, materialised from the draft in one transaction along with its job_requirement rows, its content_hash, and a mandatory change_reason.
  • Once minted, it is immutable forever. Drafts are freely editable and are never referenced by any ats_result, job_posting or job_application.
  • content_hash suppresses no-op versions: if the hash equals the current version's hash, the publish is refused with an explicit message rather than creating a duplicate version. This keeps version_no a meaningful thing to cite.

This is additive depth, consistent with immutability; it is called out explicitly so it is not re-derived differently in each module.

4. Scoring configuration is versioned separately, and bound temporally

Table Role
scoring_config identity: key, name, owner
scoring_config_version immutable: version_no, algorithm_key, algorithm_code_version, aggregation_method, band_thresholds, hyperparameters jsonb, published_at, created_by_user_id, config_hash bytea
scoring_config_criterion enumerable weights as rows, not JSON: criterion_key, weight numeric(6,4), scale_min, scale_max, transform, is_mandatory_gate
job_scoring_assignment binding, historical and orthogonal to job versioning: job_id, scoring_config_version_id, valid_from, valid_to, assigned_by_user_id, reason, with an EXCLUDE constraint preventing overlapping active bindings per job

Binding stays out of job_version so a scoring tweak does not fabricate a fake job revision. If it lived on the version, "what changed in this requisition" would become unanswerable — it would be full of rows where the job text did not change. Orthogonality is safe only because ats_result pins both versions independently, so provenance never depends on querying the binding table temporally.

A deferrable constraint trigger validates that requirement weights within a job_version sum to 1.0 ± 0.0001. Deferrable because requirements are inserted row by row inside one transaction. 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 months later.

5. ats_result pins every input that can change the number

graph LR
  RES["ats_result (append-only)"]
  APP["job_application"]
  JV["job_version"]
  SCV["scoring_config_version"]
  DOC["candidate_document (CV revision)"]
  PA["intake_parse_attempt"]
  CODE["algorithm_code_version (text)"]
  AI["ai_model_version and prompt_template_version"]
  RES --> APP
  RES --> JV
  RES --> SCV
  RES --> DOC
  RES --> PA
  RES --> CODE
  RES --> AI
  CRIT["ats_result_criterion: weight_applied, contribution, matched_evidence"]
  RES --> CRIT

Five independent things can change the number on screen — the requirements, the weights, the scorer code, the CV that was read, and the parse of that CV — so all five are pinned. weight_applied and contribution are stored per criterion rather than recomputed on read, so the arithmetic that produced the displayed total is on disk. There is no candidate_id column: the score is reachable only through job_application, which is the structural fix for js/data.js:123.

Append-only with a narrow, privilege-enforced exception: GRANT INSERT, SELECT ON ats_result plus GRANT UPDATE (is_current, superseded_by_id, reviewed_by_user_id, reviewed_at, review_outcome, review_note). A rescore 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 keeps exactly one current score per application. Column-level grants rather than a trigger allowlist because Postgres enforces privileges before any trigger logic can be wrong.

CHECK (review_outcome IS NULL OR reviewed_by_user_id IS NOT NULL) is the schema-level expression of AI must never auto-reject, reinforced by the dependency rule that intelligence modules cannot import domain modules and by application.transition() refusing any terminal-negative transition whose actor_kind <> 'user'.

The column and the value are spelled exactly that way on purpose. actor_kind is one closed set repository-wide — text CHECK in ('user', 'system', 'integration', 'ai_agent') (Part 2 §9.5/§28.1, _decisions.md audit and enum-strategy decisions) — and 'user' is its only member that carries an actor_user_id identifying a real person. There is no 'human' member. A guard written against 'human' would test a value the CHECK cannot hold and would silently fail open or throw, which is why the guard and the constraint must be written from the single enum listing rather than from prose. intake_resolution.decision_mode does legitimately take 'human' — it labels a decision mode, not an actor identity, and the two must not be conflated.

6. Which version an application is scored against — a judgement call, stated

Two defensible answers exist and they conflict.

  • The version the applicant read (via job_posting) — fairest to the individual.
  • The current version of the requisition — the only way a ranked shortlist is coherent.

Decision: score against the current job_version and the currently bound scoring_config_version, and pin both on the result row. Separately, retain the job_posting the applicant actually read on the application. Ranking requires a common yardstick: if ten live applicants were scored against four different requirement sets, the ordering is meaningless and a recruiter comparing them is being misled. So rescoring on version change is not optional. The posting pin is retained because "what did the advert say when they applied" is the question that makes a rejection defensible, and it is a different question from "how do these ten compare today".

7. Rescore policy — what triggers recomputation, and what does not

Event Rescores Notes
New job_version published Active applications on that job Queued with a per-requisition lock; batched
New job_scoring_assignment becomes active Active applications on that job Same path
New intake_parse_attempt succeeds for a pinned document That one application Cheap, targeted
New CV revision uploaded That one application Cheap, targeted
algorithm_code_version changes at deploy Nothing automatically Requires an explicit, audited backfill job; a deploy must never silently move every score
Application in a terminal state Never A rejected application's score is a historical record
Nothing has actually changed Never input_fingerprint (sha256 over the candidate feature vector, job_version.content_hash and config_hash) makes this an index lookup, which is what keeps rescoring cheap and prevents pointless score churn

Historical rows never change under any of these. A rescore is always an insert.


Justification

Requirements on the version is the whole decision. Every other mechanism here is either enforcement of that (grants, triggers) or exploitation of it (pinning, fingerprinting). Option F demonstrates why: versioning the description while leaving requirements mutable produces something that passes a checklist and fails the only question that matters.

Why two enforcement layers for immutability. The failure mode being defended against is not a malicious developer, it is an ordinary Tuesday: a staging environment provisioned with the wrong grants, or a data fix applied in psql under time pressure. Privileges catch the second; the trigger catches the first. Neither alone is sufficient, and both together cost one migration.

Why criteria are rows and hyperparameters are JSONB. Criteria are enumerable, join to job_requirement, are displayed in the explainability panel, and are aggregated in reports — so they must be queryable and constrainable by a sum-to-one check. Hyperparameters are algorithm-specific and not enumerable across algorithms, which is exactly the profile the binding rules permit for JSONB. The line is drawn by the promotion rule, not by convenience.

Why algorithm_code_version sits on the config version as well as the result. It records which scorer implementation the config was authored against, which turns a config/code mismatch into a detectable condition rather than a mysterious shift in scores. It must be derived from a build artefact — a git SHA or release tag injected at build time — never hand-edited, or it becomes a comment.

Why store weight_applied and contribution rather than recompute. Recomputing from the pinned config version is correct in theory. In practice any change to the aggregation code silently rewrites historical reports, and the whole point of this ADR is that the number on a March screenshot and the number in a September query are the same number. Storing the arithmetic is the difference between "we can probably reproduce it" and "here is what we computed".

Cost accepted honestly. This is more schema than any alternative, and it lands plpgsql on a two-person team. The mitigation is ownership: Talha writes every trigger and constraint; the junior owns the migrations, the version-minting UI, the explainability panel and the test suite that asserts forbidden writes raise. That split gives the junior varied, independently demonstrable work across frontend, API and testing rather than CRUD, with a review checkpoint per migration.


Consequences

Positive

  • A displayed historical score cannot drift. That is a structural property, not a process.
  • "Why 61.4?" is answerable from stored rows: per-criterion raw_value, normalised_score, weight_applied, contribution and matched_evidence, plus the model and prompt versions. The explainability requirement is satisfied by the schema rather than by an AI narrative.
  • "AI never auto-rejects" is a CHECK constraint plus a dependency rule, not a policy sentence.
  • A requirement change is a citable artefact with an author and a change_reason, so job_version diffs become a legitimate audit answer.
  • A candidate can hold different scores for different jobs, which the prototype cannot represent at all.
  • input_fingerprint makes "has anything actually changed" an index lookup, so rescoring is cheap and score churn is bounded.
  • Publishing pins what the applicant read, which is the difference between a defensible and an indefensible rejection.
  • No-op versions are refused by content_hash, keeping version_no meaningful to cite.

Negative — costs we are accepting

  • More tables and more joins. Eight tables where the prototype has one array. Every "show me the job" read resolves current_version_id; mitigated by the denormalised pointer, but the pointer is itself trigger-maintained state that can in principle be wrong.
  • Version rows accumulate forever. Requirements are duplicated per version, so a requisition edited twenty times carries twenty requirement sets. At our volume this is negligible storage, and it is the price of immutability.
  • Rescore orchestration is a real subsystem, not a function: per-requisition locks, batching, fingerprint short-circuiting, and a decision about whether recruiters are notified when a band changes underneath them. That last part is a product question this ADR surfaces and does not answer.
  • Developer friction is deliberate. Nobody can fix a published requisition with an UPDATE, in any environment, including during an incident. The escape hatch is publishing a new version with a change_reason — which is the correct behaviour and will still feel slow at 2am.
  • plpgsql burden. Two immutability triggers, one deferrable weight-sum trigger, the current_version_id maintenance trigger. Real skills and maintenance risk on a two-person team, mitigated by ownership and by a test per trigger.
  • The deferrable weight-sum trigger fires at COMMIT, so a bad requirement set fails late in the transaction rather than at the offending insert. Error messages must name the version and the computed sum, or the junior loses an afternoon.
  • Scoring against the current version means scores move. A recruiter can watch a shortlist reorder after a requirement edit. That is correct behaviour and it must be surfaced in the UI ("rescored against v4 on 12 August"), or it reads as a bug.
  • Cross-document dependency. Part 1 of _decisions.md selects Django partly for its built-in migrations; Part 2 mandates plain-SQL migrations with the ORM mapping to, never generating, the schema. Every constraint in this ADR lives on Part 2's side of that line. The resolution is the canonical migration authority ruling in 02-system-architecture.md §12.4 — plain SQL is the authority, Django runs it via SeparateDatabaseAndState, managed = True on every model, and drift is caught by two gates (makemigrations --check for models-vs-state; a pg_dump plus trigger/column-privilege catalogue diff for everything Django cannot model). Do not paraphrase it; quote it. For this ADR the split falls out as: the immutability REVOKEs and the deferrable weight-sum constraint trigger are ignore-listed for the ORM gate and covered by the SQL gate, while the job_version/scoring_config_version unique indexes and the ats_result pinning CHECKs are declarable in Meta.constraints and stay inside the ORM gate. It must be signed off as ADR 0017 before the first version migration is written, or this schema will be fought every sprint.

Risks

# Risk Severity Mitigation
R1 Version churn from trivial edits makes version_no meaningless Medium Draft boundary (mechanism 3) plus content_hash no-op refusal; monitor versions-per-job-per-month
R2 Rescore storm on a large requisition — thousands of applications rescored at once, saturating the worker Medium Per-requisition queueing lock, batched inserts, low-priority queue, and a measured cap; the fingerprint check skips unchanged inputs entirely
R3 algorithm_code_version hand-maintained and therefore wrong Medium Injected from the build (git SHA or release tag); a startup assertion fails if it is a placeholder
R4 Config authored against a scorer version that no longer exists, producing a silent semantic mismatch Medium algorithm_code_version on the config version compared against the running scorer at invocation; mismatch is a logged refusal, not a best-effort run
R5 Deferrable weight-sum trigger blocks a legitimate transaction in a confusing way Low Error message names job_version_id, the computed sum and the offending rows; a fixture test covers the failure path so the message is exercised
R6 Denormalised current_version_id or current_primary_recruiter_id drifts from truth Medium Trigger-maintained with a nightly reconciliation query reporting mismatches as a data-quality alarm, the same pattern used for the interview wall-clock reconciliation
R7 Business pushes back: "let us just edit the requirement" Medium Answer with the draft boundary and a fast publish flow, not with a mutable escape hatch. If the business insists, that is a renegotiation of the constraint and must be recorded as such, not quietly implemented
R8 Recruiters lose trust because scores move after a requirement edit Medium Surface the pinned version on every score display and show "rescored on against v"; an unexplained change is indistinguishable from a bug
R9 The migration-authority collision (Part 1 vs Part 2) is discovered during implementation High Sign off the plain-SQL-authority resolution before the first version migration; it changes daily workflow and who owns the schema
R10 A second scoring algorithm needs criterion shapes the current scoring_config_criterion columns cannot express Medium algorithm_key already selects the interpretation, and hyperparameters absorbs algorithm-specific settings; if criterion structure must change, that is a revisit trigger below, not a schema hack

Revisit conditions

Trigger Threshold Reopens
Version churn Median job_version rows per job exceeds 10 per month across active requisitions The draft/publish boundary — versions are being minted for edits that should be draft saves
Rescore cost A single-requisition rescore exceeds 10 minutes wall time, or a version publish generates more than 5,000 rescore jobs Batching strategy, priority queues, and whether rescore should be incremental rather than whole-requisition
No-op rescore rate More than 30% of rescore jobs terminate on an unchanged input_fingerprint for a month The rescore triggers are too broad; narrow the events
Requirement volume More than 50 job_requirement rows on a typical version Whether requirements need their own grouping or hierarchy, which would change the weight-sum invariant
Algorithm divergence A second scoring algorithm requires criterion columns the current shape cannot express scoring_config_criterion structure, possibly per-algorithm criterion tables
Weight invariant friction The deferrable weight-sum trigger blocks legitimate work more than twice a month Whether weights should be normalised on publish rather than validated — a real alternative, deliberately not chosen now because silent normalisation hides authoring mistakes
Immutability breached Any successful UPDATE or DELETE on job_version, job_requirement, scoring_config_version or ats_result outside the granted column set, in any environment Treat as a P1 incident; both enforcement layers failed, and the grants and trigger coverage must be re-audited before anything else ships
Store-vs-recompute pressure ats_result_criterion exceeds roughly 200,000,000 rows, making stored contributions a genuine storage or vacuum concern Partitioning ats_result_criterion by computed_at, before ever reconsidering recompute-on-read
Fairness evaluation needs more The disparate-impact evaluation requires inputs not currently pinned on ats_result Which columns are pinned; add, never replace
Legal or business change A regulator or counsel requires scoring against the applied-to version rather than the current version Mechanism 6, which is the one deliberate judgement call in this ADR and the most likely to be overruled from outside engineering

  • _decisions.md — versioning model for jobs and requirements; scoring configuration versioning and job binding; ATS result snapshot; cross-cutting persistence patterns; AI boundary.
  • 03-database-design.md — full DDL, grants, trigger bodies and index inventory.
  • ADR 0002 — the engine whose column-level grants, deferrable constraint triggers and partial indexes this decision depends on; none of it is expressible in an ORM.
  • ADR 0004 — rescore fan-out runs as queued jobs with per-requisition locks.
  • ADR 0006 — the search ranking blend is versioned by this same rule.
  • ADR 0008 — merge renumbers application attempts and supersedes colliding applications; those operations must not disturb pinned score rows.
  • ADR 0011 — AI provider abstraction and versioning; ai_model_version and prompt_template_version pinned on ats_result come from there.