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 itself — valid_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
- The application role receives only
INSERTandSELECTonjob_versionandjob_requirement. - A
BEFORE UPDATE OR DELETEtrigger 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_draftrow (one live draft per job, holding the same shape asjob_versionplus adraft_requirementchild table). - A
job_versionrow is minted only at a publish or approve transition, materialised from the draft in one transaction along with itsjob_requirementrows, itscontent_hash, and a mandatorychange_reason. - Once minted, it is immutable forever. Drafts are freely editable and are never referenced by any
ats_result,job_postingorjob_application. content_hashsuppresses 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 keepsversion_noa 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,contributionandmatched_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
CHECKconstraint plus a dependency rule, not a policy sentence. - A requirement change is a citable artefact with an author and a
change_reason, sojob_versiondiffs become a legitimate audit answer. - A candidate can hold different scores for different jobs, which the prototype cannot represent at all.
input_fingerprintmakes "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, keepingversion_nomeaningful 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 achange_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_idmaintenance 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.mdselects 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 in02-system-architecture.md§12.4 — plain SQL is the authority, Django runs it viaSeparateDatabaseAndState,managed = Trueon every model, and drift is caught by two gates (makemigrations --checkfor models-vs-state; apg_dumpplus 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 immutabilityREVOKEs and the deferrable weight-sum constraint trigger are ignore-listed for the ORM gate and covered by the SQL gate, while thejob_version/scoring_config_versionunique indexes and theats_resultpinningCHECKs are declarable inMeta.constraintsand 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 |
Related
_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_versionandprompt_template_versionpinned onats_resultcome from there.