117 KiB
08 — Requirements Traceability
Status / Scope of this document
This is the closure check on the package. Documents 00–07 and ADRs 0001–0012 each argued a slice of the design; this document asks one question of all of them together: is every confirmed requirement actually attached to a module, a table, an endpoint group, a phase and a piece of evidence that would demonstrate it — and is every designed thing attached to a requirement?
It contains no new design. Where a cell here disagrees with the document that owns the subject, the owning document wins and the disagreement is recorded in §5 as a gap rather than resolved silently. Finding real gaps is the point; a matrix with no gaps in a package this size would mean the matrix was not checked.
Two companion files this document depends on and does not duplicate. _open-items.md is the
package's single arbitration register: every cross-document contradiction resolves there to a
numbered ruling, a named non-engineering owner with a blocking gate, or a closure. _glossary.md is
the published Part 1 ↔ Part 2 name mapping this matrix applies in §1.2. A gap in §5 below that is a
contradiction between documents rather than a hole in coverage carries its register id, so there
is exactly one place to look for the answer.
Sources. 00-scope-classification.md (the REQ- set — authoritative for ids, sources and
phase), 01-repository-assessment.md, 02-system-architecture.md (modules), 03-database-design.md
(tables), 04-integrations-and-processing.md, 05-security-rbac-ai-governance.md,
06-api-boundaries.md (API groups), 07-implementation-plan.md (acceptance criteria and
demonstration milestones), adr/0001–adr/0018 (indexed in 02 §13, which is authoritative for
ADR numbers), and _repo-findings.md for repository facts.
No meeting transcript exists (findings §B); the assignment prompt is the requirements source.
1. Conventions used in the matrix
1.1 Requirement ids
The REQ- ids are taken unchanged from 00-scope-classification.md §2. That document
established them as stable and never reused, and this matrix adopts them verbatim so the two
interlock — 158 confirmed requirements across 22 families. No id was renamed, merged or
invented here.
1.2 Module names versus table names
00-scope-classification.md §10 row 5 recorded a naming split inside _decisions.md and
resolved it: Part 2's table names are the database vocabulary, Part 1's module names are the
code vocabulary. This matrix applies that resolution literally, which is why the Module
column says requisition while the Database Entity column says app.job / app.job_version.
They are the same aggregate under two vocabularies, not two things.
1.3 The five columns
| Column | Means | Source of truth |
|---|---|---|
| Module | The owning logical module from the 25-module catalogue. A second module means a genuine shared responsibility, not a hand-off. | 02 §4 |
| Database Entity | The table(s) that carry the state the requirement is about. — means the requirement is a behaviour, a policy or a rendering property with no state of its own; that is legitimate but it is also where verification gets hard, so every — was checked against §5. |
03 |
| API Group | One of the 25 groups in 06 §2, or one of the five groups in 06 §4 that assignment §26.7 omits (audit, async jobs, reference data, health, worklist). internal means no HTTP surface — the requirement is enforced below the API. |
06 §2, §4 |
| Phase | First phase in which the requirement is satisfied, per 00 §2. A range means genuinely staged delivery. |
00 §2 |
| Acceptance Evidence | The specific artefact that would demonstrate it. Citation form: AC0.5 = 07 §3.5 item 5; AC1.n = 07 §4.5 item n; AC2.n = 07 §5.5 item n; AC3 = 07 §6.4 acceptance prose; AC4 = 07 §7 acceptance prose. D0–D4 are the demonstration milestones. Test layers are 07 §11.1–§11.6. |
07, 05 |
1.4 The traceability spine
Every row in §2 is an instance of one chain. When a row cannot complete the chain, it is a gap.
flowchart LR
A["Assignment §3 / §5<br/>confirmed requirement"] --> B["REQ- id<br/>doc 00 §2"]
B --> C["Module<br/>doc 02 §4"]
C --> D["Table(s)<br/>doc 03"]
D --> E["API group<br/>doc 06 §2"]
E --> F["Phase<br/>doc 00 §2 / doc 07"]
F --> G["Acceptance criterion<br/>doc 07 §3.5-§7"]
G --> H{"Chain complete?"}
H -- yes --> I["Traceable - 129 of 158"]
H -- no --> J["GAP - §5, 29 of 158"]
2. The traceability matrix
2.1 Raw intake and inbound channels (12)
| Requirement | Module | Database Entity | API Group | Phase | Acceptance Evidence |
|---|---|---|---|---|---|
| REQ-INT-01 | intake |
app.raw_intake (append-only); app.candidate.created_from_raw_intake_id NOT NULL, non-deferrable FK |
recruitment intake | 1 | AC1.3 — inserting a candidate with no raw_intake fails at the database; 06 §2.11 has no POST /candidates at all; D1 |
| REQ-INT-02 | integrations_inbound, intake |
app.intake_channel, ref.source_channel (11 seed rows), app.ingestion_run, app.ingestion_dead_letter |
recruitment intake | 1 (3 channels) / 3 (4 boards) / 4 (4 forms) | AC1.1 for the Outlook path; 04 §2.10 diagram; D1. Partial — GAP-08 |
| REQ-INT-03 | document_parsing |
app.intake_parse_attempt (append-only), app.raw_intake_attachment |
recruitment intake, documents | 1 | 07 §11.4 malformed-CV pipeline tests; 04 §4.2 17-step pipeline; D1 |
| REQ-INT-04 | intake |
app.raw_intake.state, app.intake_parse_attempt, app.parse_issue |
recruitment inbox | 1 | AC1.2 — a document that cannot be parsed reaches a terminal state, is visible and retryable, nothing dropped; PROP-02; OBD-06 |
| REQ-INT-05 | intake |
app.intake_parse_attempt.status, app.raw_intake.state |
recruitment intake | 1 | 07 §11.4; the parse-review panel in 07 §4.1 sub-slice 1A; D1 |
| REQ-INT-06 | intake |
app.raw_intake_read, app.raw_intake.source_channel_id |
recruitment inbox | 1 | 06 §2.10 inbox endpoints incl. claim/release/snooze; D1 |
| REQ-INT-07 | document_parsing |
app.intake_parse_attempt.parsed per-field confidence; app.candidate_field_provenance |
recruitment intake | 1 | D1 — "one field deliberately left empty because confidence was low"; PROP-20; ASM-08 |
| REQ-INT-08 | intake |
app.raw_intake unique on (channel, external_message_id) and on (channel, payload_sha256) |
recruitment intake | 1 | AC1.1 — redelivery produces no second row; 04 §2.6 |
| REQ-INT-09 | intake |
app.raw_intake.state in (rejected_unusable, quarantined); app.intake_resolution |
recruitment inbox | 1 | AC1.2 — terminal state with no candidate and no job_application row |
| REQ-INT-10 | intake, integrations_inbound |
app.intake_channel mandatory manual_ui row (migration 008) |
recruitment intake | 1 | 03 §33 migration 008 seeds manual_ui; Playwright journey (manual upload); D1 |
| REQ-INT-11 | files |
app.stored_file (sha256, scan_status, retention_class), app.raw_intake_attachment |
documents | 1 | 04 §6.5 malware gating; 05 §10 Phase 1 "file validation and malware gating; signed URLs" |
| REQ-INT-12 | intake, document_parsing |
app.intake_parse_attempt (append-only, parser_name/parser_version) |
recruitment intake | 1 | AC1.2 retry path; 03 §18.1 — a replay appends, the arrival row is never mutated |
2.2 Candidate identity (11)
| Requirement | Module | Database Entity | API Group | Phase | Acceptance Evidence |
|---|---|---|---|---|---|
| REQ-CAN-01 | candidate |
app.candidate (no job_id, no stage, no aiScore) + app.job_application |
candidates, applications | 1 | AC1.4 — two applications from one person produce one candidate and two independent ats_result rows; D1 |
| REQ-CAN-02 | candidate |
app.candidate + 13 child tables, app.candidate_search_index |
candidates | 1 | 06 §2.11 filter/sort/-relevance params; GET/POST /candidates/search. Partial — GAP-06 (no bulk-action endpoint) |
| REQ-CAN-03 | candidate, intake |
app.candidate.created_from_raw_intake_id NOT NULL; app.intake_resolution |
recruitment intake | 1 | AC1.3; 06 §2.11 "POST /candidates Does not exist"; 03 §12.5 five invariant layers |
| REQ-CAN-04 | candidate |
app.candidate_email, app.candidate_phone; deferrable contactability trigger |
candidates | 1 | AC1.3 — no email and no phone fails at COMMIT; a malformed address fails the CHECK |
| REQ-CAN-05 | candidate |
app.candidate_email global partial unique index on normalised address |
candidates | 1 | 06 §2.11 → 409 email_already_identifies_candidate; PROP-10 / OBD-09 non-identifying address list |
| REQ-CAN-06 | candidate |
app.candidate, _email, _phone, _employment, _education, _skill, _link, _tag, _consent |
candidates | 1 | 06 §2.11 response field list; D1 candidate profile screen |
| REQ-CAN-07 | candidate, files |
app.candidate_document → app.stored_file, with raw_intake_id provenance |
documents, candidates | 1 | 03 §14.1; 06 §2.11 GET /candidates/{id}/documents; D1 |
| REQ-CAN-08 | candidate, config |
ref.skill, ref.skill_alias, app.candidate_skill (raw_label retained) |
candidates, reference data | 1 | 06 §2.11 "unmapped parser output is stored and reviewable, never dropped". Partial — GAP-17 |
| REQ-CAN-09 | candidate, identity |
app.candidate.public_id (UUIDv7) + reference_code; app.candidate_access_token |
candidates | 1 | 03 §3.2 "URL-guessability is not authorization"; 05 §2.11 permission tests |
| REQ-CAN-10 | talent_pool |
app.talent_pool, app.talent_pool_member, app.candidate_job_match |
talent pool | 3 | AC3 / D3 scope; DEF-06 entry condition (candidate/application split live, scoring stable) |
| REQ-CAN-11 | candidate |
app.candidate.total_experience_months |
candidates | 1 | 06 §2.11 "months, not years — the prototype stores integer years (js/data.js:121)" |
2.3 Duplicate detection and merge (8)
| Requirement | Module | Database Entity | API Group | Phase | Acceptance Evidence |
|---|---|---|---|---|---|
| REQ-DUP-01 | duplicate_review |
app.duplicate_candidate_pair, app.matching_config_version |
duplicate review | 1 | 07 §4.1 sub-slice 1C "duplicate detection with the six signals and the canonical-pair constraint"; ADR-0008 |
| REQ-DUP-02 | duplicate_review |
app.duplicate_candidate_pair.state in (open, confirmed_duplicate, confirmed_distinct, merged) |
duplicate review | 1 | AC2.1 — nothing is deleted; 03 §19.2 state CHECK plus ck_pair_reviewed; 03 §31.3 forbids soft delete on the merge tables |
| REQ-DUP-03 | duplicate_review |
app.duplicate_candidate_pair.signals, app.candidate_link.url_normalised partial unique |
duplicate review | 1 | 03 §19.2 six-signal table (incl. identical normalised LinkedIn URL); D2 |
| REQ-DUP-04 | duplicate_review |
app.candidate_merge (performed_by_user_id NOT NULL, reason NOT NULL) |
duplicate review | 2 | AC2.1; OBD-10 (HR Administrator and above); 00 §7 rejects automatic merge permanently |
| REQ-DUP-05 | duplicate_review |
app.candidate_merge_operation (op_kind, table, previous_value, seq) |
duplicate review | 2 | AC2.2 — unmerge restores every re-parented row; AC2.3 — out-of-order unmerge refused, naming the blocking merge; D2 live in front of the audience |
| REQ-DUP-06 | duplicate_review |
app.duplicate_candidate_pair.state = 'confirmed_distinct' on the canonical pair |
duplicate review | 1 | 06 §2.15 POST /duplicate-pairs/{id}/confirm-distinct; 03 §19.2 canonical-pair constraint plus ix_pair_distinct as the detector's skip list; suppression asserted by the detection rescan test |
| REQ-DUP-07 | duplicate_review |
app.duplicate_candidate_pair.signals (per-signal, not only composite) |
duplicate review | 2 | PROP-11 unmerge confirmation screen; 03 §19.2; D2 |
| REQ-DUP-08 | duplicate_review, analytics |
suppressed_by_merge_id on candidate_email/_phone/_link; app.v_candidate_live |
duplicate review, analytics | 2 | AC2.1 — "the funnel report count is unchanged (no double counting)"; loser public_id still resolves via 301 |
2.4 Requisitions, requirements and versioning (8)
| Requirement | Module | Database Entity | API Group | Phase | Acceptance Evidence |
|---|---|---|---|---|---|
| REQ-JOB-01 | requisition |
app.job, app.job_version; ref.department, ref.business_unit, ref.location, ref.employment_type, ref.grade |
requisitions, jobs | 1 | 06 §2.6/§2.7; D1 requisition screens |
| REQ-JOB-02 | requisition |
app.job_version — INSERT+SELECT-only grants plus immutability trigger |
jobs | 1 | AC1.7 — UPDATE against job_version raises an exception, asserted by tests; AC1.6; ADR-0007 |
| REQ-JOB-03 | requisition |
app.job_requirement (version-scoped, weighted, deferrable weight-sum trigger) |
jobs | 1 | AC1.6 — editing a requirement mints a new job_version; AC1.7; D1 |
| REQ-JOB-04 | scoring, requisition |
app.scoring_config, app.scoring_config_version, app.job_scoring_assignment (the binding is itself historical) |
ATS scoring, jobs | 1 | 03 §20.3 — config binding orthogonal to job versioning, so a weight tweak does not fabricate a fake requisition revision; AC1.7 |
| REQ-JOB-05 | requisition, config |
ref.lifecycle_status domain job; app.job_status_history |
jobs, reference data | 1 | 03 §4.7 consolidation; 03 §9.5 |
| REQ-JOB-06 | requisition, integrations_outbound |
app.job_posting → job_version_id (03 migration 006a, Phase 1) |
job publications | 1 (record) / 4 (external) | 07 §4.1 sub-slice 1B includes job_posting; 03 §11 states the Phase 1 / Phase 4 split. GAP-25 CLOSED |
| REQ-JOB-07 | requisition |
app.approval_request, app.approval_decision (Phase 1); app.approval_route, _route_step (Phase 2) |
requisitions | 1 — single-approver, per 00 §2.4 |
GAP-01 CLOSED. Implementing tasks 07 T-17b (schema + publish_version() gate) and 07 A-16b (approve/reject action); acceptance criterion 07 §4.5 #13, asserted as a forbidden-write test in A-26; 03 §5 and migration 017a/017b split to match. OBD-12 still sets the multi-step chain deferred to Phase 2 |
| REQ-JOB-08 | config, pipeline |
ref.pipeline_stage, ref.lifecycle_status, ref.vocabulary_value, app.pipeline_config_stage |
reference data, pipeline | 1 | 03 §4.7 enum-vs-lookup policy; 00 §7 rejects native Postgres enums; PROP-06 admin surface |
2.5 Applications and pipeline (7)
| Requirement | Module | Database Entity | API Group | Phase | Acceptance Evidence |
|---|---|---|---|---|---|
| REQ-APP-01 | application |
app.job_application (candidate_id, job_version_id, raw_intake_id NOT NULL); app.ats_result has no candidate_id |
applications | 1 | AC1.4; 05 §5.2 — "a score is reachable only through job_application, so scores are per-application by construction" |
| REQ-APP-02 | pipeline, application |
ref.pipeline_stage, app.job_application.current_stage_id, app.job_application_stage_history |
pipeline, applications | 2 | AC2 board demo; 07 §4.2 defers the board deliberately — "a stage transition is demonstrable from a list plus a drawer" |
| REQ-APP-03 | application |
app.job_application_stage_history, app.job_application_status_history (actor, actor_kind, reason, non-overlapping intervals) |
applications | 1 | AC1.11 — two stage moves show actor, reason and non-overlapping intervals; D1 |
| REQ-APP-04 | application, intake |
app.job_application.raw_intake_id NOT NULL, source_channel_id, job_posting_id |
applications | 1 | 03 §15.2 source attribution; 03 §30.3 (multi-touch attribution deliberately not built) |
| REQ-APP-05 | application |
app.job_application partial unique on live application + attempt_no |
applications | 1 | AC1.5 — a second live application is refused by the partial unique index; reapplication after terminal outcome plus cooling-off succeeds with attempt_no = 2; PROP-07 / OBD-08 |
| REQ-APP-06 | application |
app.job_application_status_history.actor_kind; transition() service guard |
applications | 1 | AC1.9 — no code path can reach a terminal-negative status without a human actor, asserted by tests; 05 §5.3 three independent layers |
| REQ-APP-07 | application, analytics |
app.job_application_stage_history interval columns; analytics read views |
applications, analytics | 1 (queryable) / 2 (reported) | AC1.11 intervals; AC2.7 — dashboard KPIs match a hand-written SQL query over the same window |
2.6 Recruiter assignment (4)
| Requirement | Module | Database Entity | API Group | Phase | Acceptance Evidence |
|---|---|---|---|---|---|
| REQ-ASG-01 | assignment |
app.job_assignment, app.job_application_assignment; ref.assignment_role (6 roles) |
recruiter assignments | 1 | 00 §10 row 2 — two concrete tables, not a polymorphic one (unenforceable FK); 06 §2.16 |
| REQ-ASG-02 | assignment |
Interval columns (valid_from, valid_to NULL) on app.job_assignment and app.job_application_assignment, with ck_assignment_interval and the GiST EXCLUDE on tstzrange(valid_from, valid_to) (03 §16) |
recruiter assignments | 1 | 07 §4.1 sub-slice 1B "assignment interval tables with EXCLUDE constraints"; owners_at(subject, ts) answers "who owned this in March". Corrected — an earlier revision of this cell named from_ts / to_ts, which are not the column names 03 §16 uses |
| REQ-ASG-03 | assignment |
Partial unique index on the current primary recruiter (ref.assignment_role.is_exclusive) |
recruiter assignments | 1 | 07 §4.1 sub-slice 1B — the partial unique index on the current primary recruiter |
| REQ-ASG-04 | assignment, analytics |
Assignment intervals + analytics read views | analytics, recruiter assignments | 2 | AC2.7; 02 §4.4 module 24 recruiter_performance(); 06 §2.24 |
2.7 ATS scoring and explainability (12)
| Requirement | Module | Database Entity | API Group | Phase | Acceptance Evidence |
|---|---|---|---|---|---|
| REQ-SCR-01 | scoring |
app.ats_result keyed on job_application_id, no candidate_id column |
ATS scoring | 1 | AC1.4 — two independent ats_result rows; 05 §5.2 |
| REQ-SCR-02 | scoring |
app.ats_result.overall_score numeric(6,3) CHECK BETWEEN 0 AND 100 |
ATS scoring | 1 | 05 §5.2 explainability table row 1 |
| REQ-SCR-03 | scoring |
app.ats_result_skill; app.ats_result_criterion one row per job_requirement_id (03 §20.5) + app.ats_result_criterion.match_state text NOT NULL with ck_ats_criterion_match_state CHECK (match_state IN ('matched','partial','missing','not_assessed')), ck_ats_criterion_unassessed and ix_ats_criterion_match — 03 §20.5, migration 012. GAP-27 CLOSED |
ATS scoring | 1 | AC1.8 score panel; 05 §5.2 — missing requirements are a queryable fact, not an inference from a null, which now holds: the column exists, has no DEFAULT, and ck_ats_criterion_unassessed ties not_assessed to a null job_requirement_id |
| REQ-SCR-04 | scoring, config |
app.ats_result.band text NOT NULL (03 §20.4); thresholds on app.scoring_config_version.band_thresholds jsonb (03 §20.1) |
ATS scoring | 1 | OBD-19 — four BRD bands as versioned reference data with configurable thresholds; band pinned per score row. Corrected — an earlier revision of this cell named app.ats_result.band_id, a column 03 does not have; the band is a config-driven text label, which is why ats_result_override.overridden_band carries no CHECK either (03 §20.7) |
| REQ-SCR-05 | scoring |
app.ats_result.input_fingerprint |
ATS scoring | 1 | AC1.6 — the previously computed score is unchanged after a requirement edit; 07 §4.1 sub-slice 1C names input_fingerprint |
| REQ-SCR-06 | scoring, ai_orchestration |
app.ats_result.ai_model_id, ai_model_version, algorithm_code_version; ai.ai_model_config_version |
ATS scoring | 1 | AC1.8 — the panel shows the pinned versions; ADR-0011 |
| REQ-SCR-07 | scoring |
app.ats_result_criterion with stored weight_applied and contribution, matched_evidence with character offsets |
ATS scoring | 1 | AC1.8; 05 §5.2 — "an explanation is never generated by a second model call"; PROP-13 panel; D1 |
| REQ-SCR-08 | scoring |
app.ats_result.is_current + superseded_by_id, append-only grants |
ATS scoring | 1 | AC1.7 — UPDATE against ats_result outside the granted column list raises; AC1.6 |
| REQ-SCR-09 | candidate, scoring |
app.candidate_search_index + the versioned relevance-weight config (03 §29.4) |
candidates | 1 | 03 §29.4 deterministic formula, now backed by app.relevance_config_version's four numeric(6,4) weight columns. GAP-04 CLOSED |
| REQ-SCR-10 | scoring, ai_orchestration |
scoring_config_version excluded-attribute list; no special_category columns exist |
ATS scoring | 1 | 05 §5.4 four enforcement mechanisms — input redaction, output schema constraint, excluded-attribute list, proxy audit as a release gate |
| REQ-SCR-11 | scoring, candidate |
app.candidate_search_index GIN/trigram indexes; app.ats_result is_current index |
candidates, ATS scoring | 1 | 07 §11.6 puts performance checks in Phase 3 — GAP-09 |
| REQ-SCR-12 | scoring |
app.ats_result pins job_version_id, scoring_config_version_id, algorithm_code_version, candidate_document_id, parse_attempt_id |
ATS scoring | 1 | AC1.8 — all five pinned inputs shown; 05 §5.2 "five independent things can change the number on screen" |
2.8 AI capabilities (15)
ai.ai_capability.availability is the single row per capability that makes honest per-capability
status a query rather than a claim. All fifteen are already visible in the prototype's AI Studio
(js/data.js:448-464), which is exactly why REQ-GOV-10 and PROP-18 exist.
| Requirement | Module | Database Entity | API Group | Phase | Acceptance Evidence |
|---|---|---|---|---|---|
| REQ-AIC-01 | scoring |
app.ats_result, _criterion, _skill |
ATS scoring | 1 | AC1.8; D1. Ranking as ranking is gated on the Phase 3 fairness pass (07 §4.2) |
| REQ-AIC-02 | scoring, talent_pool |
app.candidate_job_match (Phase 1); app.talent_pool* (Phase 3) |
ATS scoring, talent pool | 1 | 03 §20.6 — matching without an application. Pool half is Phase 3 — GAP-15 |
| REQ-AIC-03 | ai_orchestration |
ai.ai_model_invocation, ai.ai_suggestion |
chatbot, candidates | 1 | 07 §4.1 sub-slice 1C ai_orchestration framework; 06 §2.25 |
| REQ-AIC-04 | scoring |
app.ats_result_criterion.match_state (03 §20.5, migration 012 — GAP-27 CLOSED), app.ats_result_skill |
ATS scoring | 2 | Same rows as REQ-SCR-03 aggregated per pipeline; AC2 dashboard parity. A skill-gap aggregate over missing / partial is not expressible without match_state, and ix_ats_criterion_match (match_state, job_requirement_id) is the index it reads |
| REQ-AIC-05 | assistant, candidate |
ai.query_intent, ai.conversation_tool_invocation |
chatbot, candidates | 2 | AC2.6 — the assistant answers a recruiter and refuses an interviewer, both in the access audit log; ADR-0010 |
| REQ-AIC-06 | ai_orchestration, requisition |
ai.prompt_template_version, ai.ai_suggestion |
requisitions, chatbot | 2 | REQ-GOV-12 — output is a suggestion a human accepts, never a direct write |
| REQ-AIC-07 | ai_orchestration, notifications |
app.message_template_version, app.outbound_message (immutable snapshot) |
communications | 3 | 03 §30.1 — a template must change, a sent message must not. Phase 3 despite the send path existing from Phase 1 (DEF-07 minimal slice): AI drafting additionally requires 03 §22.1 ck_outbound_ai_human, so no AI-drafted message leaves without a named human sender or reviewer |
| REQ-AIC-08 | ai_orchestration, interview |
app.scorecard_template_version, ai.ai_suggestion |
interviews, feedback | 3 | AC3 scope |
| REQ-AIC-09 | assistant, analytics |
ai.query_intent (analytics intents), analytics read views |
analytics, chatbot | 3 | AC2.7 parity rule extended to NL answers; 05 §6.4 per-tool specification |
| REQ-AIC-10 | assistant |
ai.conversation, ai.conversation_message, ai.conversation_tool_invocation |
chatbot | 2 (read) / 4 (full) | AC2.6 for read; AC4 — a write action requires confirmation and produces an audit event with actor_kind='ai_agent' and on_behalf_of_user_id set; DEF-05 entry conditions |
| REQ-AIC-11 | scoring |
app.ats_result_criterion across applications on one job_version |
ATS scoring | 3 | Comparison reads the stored criterion rows; no new entity |
| REQ-AIC-12 | talent_pool, worklist |
app.task.origin='ai_suggestion', app.candidate_job_match, ai.ai_suggestion |
talent pool, analytics | 4 | AC4; see GAP-14 — the column lands in Phase 2, the producer in Phase 4 |
| REQ-AIC-13 | ai_orchestration, offer |
app.offer_version, ai.ai_suggestion, app.stored_file (letter) |
offers | 3 | AC3 — "an offer cannot be issued by a background job"; D3 letter produced |
| REQ-AIC-14 | analytics |
Analytics read views over job_application_stage_history |
analytics | 4 | AC4 scope. Depends on OBD-02 (historic outcome data) for any validated forecast |
| REQ-AIC-15 | analytics, notifications |
app.notification, analytics read views |
analytics | 4 | AC4 scope; DEF-13 |
2.9 AI governance, fairness and audit (12)
| Requirement | Module | Database Entity | API Group | Phase | Acceptance Evidence |
|---|---|---|---|---|---|
| REQ-GOV-01 | application, scoring |
app.job_application_status_history.actor_kind; app.ats_result CHECK (review_outcome IS NULL OR reviewed_by_user_id IS NOT NULL) |
applications | 1 | AC1.9; 05 §5.3 three independent layers — dependency graph, service guard, database CHECK; plus two absences (no bulk-reject endpoint; no rule may emit a terminal-negative transition) |
| REQ-GOV-02 | offer |
app.offer_version, app.approval_decision; issue() requires human confirmation |
offers | 3 | AC3 — an offer cannot be issued by a background job; D3 |
| REQ-GOV-03 | frontend, ai_orchestration |
ai.ai_model_invocation id surfaced as provenance on every AI-derived value |
all AI-bearing groups | 1 | PROP-13 provenance badges (Ahmed's workstream, 05 §5.2 last row); 05 §10 Phase 1 |
| REQ-GOV-04 | fairness_evaluation |
app.evaluation_run, app.evaluation_metric, app.evaluation_dataset |
ATS scoring | 3 | AC3 — activating a scoring config without a passing evaluation is refused; PROP-04 gate; OBD-20 threshold |
| REQ-GOV-05 | fairness_evaluation |
app.evaluation_run, app.evaluation_metric |
ATS scoring | 3 | D3 — a fairness evaluation run shown to a non-engineering audience with a pass/fail verdict |
| REQ-GOV-06 | audit, ai_orchestration |
audit.audit_event (actor_kind, on_behalf_of_user_id, ai_run_id, source_service), ai.ai_model_invocation |
audit | 1 | AC0.4 — every write to a classified table produces an audit row with a correct actor; 05 §7.3 audited events |
| REQ-GOV-07 | audit |
audit.audit_event partitions, audit.retention_policy |
audit | 1 | OBD-17 — 13 months hot, 7 years archived. Per-jurisdiction derivation absent — GAP-13 |
| REQ-GOV-08 | ai_orchestration |
ai.ai_model_invocation (written before its result is usable), ai.ai_review |
chatbot, audit | 1 | 03 §27.2; 06 §4.1 GET /ai/runs; ADR-0011 |
| REQ-GOV-09 | assistant, identity |
ai.query_intent.required_permission_key NOT NULL, ai.conversation_tool_invocation.acting_user_id NOT NULL + authorization_passed |
chatbot | 2 | AC2.6 — the same question refused for an interviewer, both attempts in the access audit log; ADR-0010; 00 §7 rejects text-to-SQL and a chatbot service account |
| REQ-GOV-10 | ai_orchestration |
ai.ai_capability.availability in (unavailable,internal_alpha,beta,general) |
chatbot | 1 | AC4 — AI Studio shows true per-capability availability rather than a coming-soon grid; PROP-18 |
| REQ-GOV-11 | all | ai.ai_model_invocation.status; circuit-breaker state (no table) |
all | 1 | 02 §9.2 degradation matrix; 04 §8.2 degradation ladder. No acceptance criterion in any phase — GAP-21 |
| REQ-GOV-12 | ai_orchestration, application |
ai.ai_suggestion + accept_suggestion(suggestion, actor) |
applications, chatbot | 1 | 02 §4 diagram 2 — the only edge from intelligence into domain state is accept_suggestion with a human actor, enforced by import-linter contracts in CI |
2.10 Interviews (6)
| Requirement | Module | Database Entity | API Group | Phase | Acceptance Evidence |
|---|---|---|---|---|---|
| REQ-ITV-01 | interview, config |
app.interview, ref.vocabulary_value (7 types, 3 modes), ref.lifecycle_status domain interview (4 states), app.interview_status_history |
interviews | 2 | 03 §23.1; AC2 demo |
| REQ-ITV-02 | interview |
app.scorecard_template(+_version, +_criterion), app.scorecard, app.scorecard_criterion_score |
feedback | 2 | AC2 D2 — scorecard submitted and locked; 05 §2.9 interviewer row |
| REQ-ITV-03 | interview |
app.interview.starts_at (UTC) + scheduling_timezone (IANA) + local_start_wall |
interviews | 2 | AC2.5 — a reschedule across a DST boundary preserves the organiser's wall-clock intent; 00 §7 rejects UTC-only and numeric offsets |
| REQ-ITV-04 | interview |
app.interview_participant GiST EXCLUDE constraint |
interviews | 2 | AC2.4 — the database refuses it, not the UI; PROP-12 |
| REQ-ITV-05 | interview (view) |
No table — calendar deliberately not a module (02 §4.6) |
interviews | 2 | 02 §4.6 rationale. No acceptance criterion — GAP-18 |
| REQ-ITV-06 | interview |
app.scorecard.submitted_at + locked, append-only after submit |
feedback | 2 | AC2 D2 — "scorecard submitted and locked" |
2.11 Assessments and offers (5)
| Requirement | Module | Database Entity | API Group | Phase | Acceptance Evidence |
|---|---|---|---|---|---|
| REQ-ASM-01 | assessment |
app.assessment_template(+_version), app.assessment_assignment, app.assessment_result |
assessments | 3 | AC3 scope; 03 §24 |
| REQ-ASM-02 | assessment |
app.assessment_result.breakdown jsonb, time_taken_minutes |
assessments | 3 | Three named dimensions are not queryable dimensions — GAP-07 |
| REQ-OFR-01 | offer, config |
app.offer, ref.lifecycle_status domain offer (6 states), app.offer_status_history |
offers | 3 | AC3; 03 §25 |
| REQ-OFR-02 | offer |
app.offer_version (immutable) + its own approval_request; app.offer_response |
offers | 3 | AC3 — mismatched-currency salary range refused; JPY with decimals refused by the rounding trigger; D3 two-step chain |
| REQ-OFR-03 | offer |
app.offer_version.issued_by_user_id; issue() guard |
offers | 3 | AC3 — an offer cannot be issued by a background job; D3 "issued by a human with confirmation" |
2.12 Search (4)
| Requirement | Module | Database Entity | API Group | Phase | Acceptance Evidence |
|---|---|---|---|---|---|
| REQ-SRC-01 | candidate |
app.candidate + child tables; app.candidate_search_index |
candidates | 1 | 06 §2.11 filter set; 03 §29.6 expected query patterns |
| REQ-SRC-02 | assistant, candidate |
ai.query_intent (typed, parameterised); app.candidate_search_index |
chatbot, candidates | 2 | AC2.6; ADR-0006; DEF-14 keeps pgvector out of Phase 1 with a stated entry condition |
| REQ-SRC-03 | candidate |
pg_trgm indexes on candidate.name_normalised, current_employer_name; ref.skill.name_normalised |
candidates | 1 | 03 §29.3 exact DDL; the same signals feed duplicate detection — one mechanism, tuned once |
| REQ-SRC-04 | candidate, config |
app.relevance_config, app.relevance_config_version (03 §29.4, migration 014) |
candidates | 1 | 03 §29.4 formula plus the immutable versioned weights and the sum-to-1.0 trigger. GAP-04 CLOSED |
2.13 Analytics, reporting and dashboard (9)
| Requirement | Module | Database Entity | API Group | Phase | Acceptance Evidence |
|---|---|---|---|---|---|
| REQ-ANL-01 | analytics |
app.v_kpi_time_to_hire, app.v_kpi_time_to_fill (03 §9.6, migration 021), app.job.close_reason_id, app.job_vacancy, plus read-only views over job_application*, job*, offer* (7 KPIs, Phase 2); app.job_posting_metric.spend_amount (Cost per Hire, Phase 4) |
analytics | 2 (7 KPIs) / 4 (Cost per Hire) | AC2.7 covers the seven Phase 2 KPIs. Cost per Hire ruled out of Phase 2 and into Phase 4 — GAP-02, OPEN-02. Time to Fill had no fill instant, no close reason and no written formula — GAP-28, now closed in 03 §9.1/§9.6 |
| REQ-ANL-02 | analytics |
Analytics read views; rendered by the retained js/charts.js |
analytics | 2 | AC2.7; REQ-NFR-11 (no charting dependency added) |
| REQ-ANL-03 | analytics |
app.saved_report, app.report_run |
analytics | 3 | AC3 scope; DEF-08 entry condition (KPIs stable, read models materialised) |
| REQ-ANL-04 | analytics |
Analytics read views over raw_intake, job_application*, offer*, job_assignment |
analytics | 2 | AC2.7 — each series matches a hand-written SQL query over the same window |
| REQ-ANL-05 | assistant, analytics |
ai.query_intent analytics intents |
analytics, chatbot | 3 | 05 §6.4; AC2.7 parity rule applies to NL answers |
| REQ-ANL-06 | analytics |
ref.business_unit as a column, not a database boundary |
analytics | 2 | OBD-23 — one master data model with brand as an attribute; 00 §6 row 17 rejects per-region databases |
| REQ-ANL-07 | analytics, identity |
app.access_scope, app.role_assignment, app.v_user_effective_scope |
analytics | 2 | 05 §2.3 access-scope model; 05 §2.11 permission tests; AC1.10 extended to analytics endpoints |
| REQ-ANL-08 | analytics |
No entity holds the baseline | analytics | 2 | ASM-15 flags the 27-day figure as possibly a demo value — it is the scalar constant timeToHire: 27 at js/data.js:245 (the arrays at js/data.js:232-233 are the trend series, not the baseline). GAP-03 |
| REQ-ANL-09 | analytics |
raw_intake.source_channel_id (volume); app.job_posting_metric (spend, Phase 4) |
analytics | 3 | Volume side testable at Phase 3. Cost side unavailable until Phase 4 — GAP-24 |
2.14 Tasks, worklist and notifications (3)
| Requirement | Module | Database Entity | API Group | Phase | Acceptance Evidence |
|---|---|---|---|---|---|
| REQ-WRK-01 | worklist |
app.task — typed nullable subject columns (candidate_id, job_application_id, job_id, interview_id, offer_id, raw_intake_id) under ck num_nonnulls(subjects) <= 1, plus assignee_user_id, due_at, status (03 §28.4) |
worklist (06 §4.5) |
2 | 02 §4.4 module 25; GET /worklist/tasks/counts is the navigation open-item count. Corrected — an earlier revision of this cell named a subject_ref column and a bare assignee. Neither exists, and subject_ref would have described the (subject_type, subject_id) shape 03 §5 P7 forbids |
| REQ-WRK-02 | worklist, ai_orchestration |
app.task.origin in (manual,rule,ai_suggestion) |
worklist (06 §4.5, ?origin= filter) |
2 (column) / 4 (producer) | REQ-GOV-03 badge rule. Producer lands Phase 4 — GAP-14 |
| REQ-WRK-03 | notifications |
app.notification, app.notification_preference |
communications | 2 | Phase cell stays 2 under T-ASM-03: "with unread counts" is first satisfied when the count surface exists (07 A-36), not when Phase 1 starts writing rows. What was wrong here was the phase rationale, now corrected: 07 §4.2 "Phase 1 writes an in-app notification row only"; internal notification email is Phase 2, not Phase 3 (OBD-21 revised — §7 finding 10); notification_preference is Phase 2 with it. The minimal Phase 1 candidate-facing send slice (app.outbound_message, 07 T-16b) satisfies no part of REQ-WRK-03 and is traced under §2.1 intake failure handling |
2.15 Identity, access control and application security (9)
| Requirement | Module | Database Entity | API Group | Phase | Acceptance Evidence |
|---|---|---|---|---|---|
| REQ-SEC-01 | identity |
app.app_user, app.user_session |
authentication | 0 | AC0.3 — a deny-by-default endpoint with no permission declared returns 403, not 200; findings §D is the baseline this replaces |
| REQ-SEC-02 | identity |
app.role (7), app.permission (250 = 25 × 10), app.role_permission (~265 grants); ref.app_module (25), ref.permission_action (10) |
roles and permissions | 1 | AC1.10 — the permission matrix test covers every endpoint × every role; 05 §2.9 matrix (7 × 25 × 10 = 1,750 triples walked per 05 §2.11); 05 §2.11 four test layers; 03 §7.2 |
| REQ-SEC-03 | identity |
app.access_scope (incl. region_id, 03 §7.3), app.access_grant (03 §7.7), app.v_user_effective_scope branches 1–4 (03 §7.5) |
internal (can()) |
1 | AC0.3 — iam.can() is the only authorization implementation; 05 §2.4/§2.8; ADR-0009. GAP-27 CLOSED — both scope sources ADR-0009 resolves against now have schema: app.access_grant in migration 011 (branch 4 of the view, with interview_id added by 019 and offer_id by 023) and ref.region + ref.location.region_id + access_scope.region_id in 002/011. The region branch is empty by construction until OPEN-05 admits 'region' to access_scope.scope_type's CHECK — one line — and 03 §7.3 states that explicitly rather than leaving the ADR pointing at nothing |
| REQ-SEC-04 | identity, interview |
app.interview_participant → scope branch 3 of v_user_effective_scope (migration 019) |
candidates, interviews | 2 | D1 — "log in as an interviewer and show the same candidate is not visible"; 06 §2.11 interviewer field list |
| REQ-SEC-05 | identity, scoring |
app.setting (03 §28.4, Phase 1 migration 012, seeded with the score-visibility keys) |
roles and permissions, ATS scoring | 1 | 06 §1.15 scope-filtering rule; 06 §8 "configurable role setting with field omission"; A-22 asserts a flip takes effect on the next request with no deploy. GAP-05 CLOSED |
| REQ-SEC-06 | frontend | — (rendering-layer property) | health (CSP headers) | 0 | AC0.2 — CI is red for a new unescaped ${ in a template literal or a dangerouslySetInnerHTML; AC0.5 — all 34 sites escaped, zero inline handlers, CSP without unsafe-inline, a stored-XSS payload renders as literal text in both themes; D0 |
| REQ-SEC-07 | identity |
app.candidate_access_token (token_hash bytea, never the token) |
authentication | 3 | PROP-03; OBD-07 (token-gated status page only, no portal); 03 §7.6 |
| REQ-SEC-08 | identity |
app.app_user, app.user_session, app.setting |
authentication, users | 1 | 05 §10 Phase 1. Password policy has nothing to enforce under OBD-15 — GAP-16 |
| REQ-SEC-09 | identity |
app.role_permission read by can() |
roles and permissions | 1 | AC1.10; findings §D (js/rbac.js:78,111-112 — no can() exists today) is the explicit baseline |
2.16 Data protection and retention (6)
| Requirement | Module | Database Entity | API Group | Phase | Acceptance Evidence |
|---|---|---|---|---|---|
| REQ-DAT-01 | platform | — | — | 1 | No engineering artefact — contractual. GAP-11. ADR-0012 topology; OBD-01 / OBD-14; ASM-06 |
| REQ-DAT-02 | ai_orchestration |
ai.ai_model_config_version.retains_data, data_processing_agreement_ref |
chatbot | 1 | 03 §27.1 — "columns, not documentation", making "which candidate data went to a retaining provider" a query. Recorded, not enforced — GAP-12 |
| REQ-DAT-03 | files, candidate |
audit.retention_policy, audit.retention_hold, audit.retention_action, app.candidate_embedding; app.candidate_erasure_request (03 §28.5, migration 025a — the request itself as a record, with FKs to retention_action and retention_hold that make execution checkable). GAP-27 CLOSED |
candidates, documents | 2 | AC3 — a retention purge pseudonymises a candidate while leaving ats_result and stage history intact; a subject-access export returns everything pii_classification says it should |
| REQ-DAT-04 | platform | audit.pii_classification (~900 rows) |
— (CI gate) | 1 | PROP-05 CI completeness check; 05 §10 Phase 1 "pii_classification registry + CI completeness check" |
| REQ-DAT-05 | all | Absence of any special_category column, backed by audit.pii_classification.ck_no_special_category CHECK (class <> 'special_category') |
— (CI gate) | 1 | 05 §5.4 — "the strongest control is that most of these attributes have no column to be written to"; 03 §31.1 makes it a constraint rather than a promise. Nothing flags a migration that DROPS the CHECK — GAP-23 (Low) |
| REQ-DAT-06 | platform | audit.audit_event_redaction (two-person rule); app.candidate_erasure_request (03 §28.5, migration 025a) as the workflow record — GAP-27 CLOSED; pseudonymisation, not deletion |
candidates, audit | 2 | PROP-15; AC3 purge test; 00 §7 rejects hard delete on retention expiry |
2.17 Current state, history and audit (6)
| Requirement | Module | Database Entity | API Group | Phase | Acceptance Evidence |
|---|---|---|---|---|---|
| REQ-HIS-01 | all domain | Six typed history tables: job_status_history, job_application_stage_history, job_application_status_history, candidate_status_history, interview_status_history, offer_status_history |
per owning group | 1 | AC1.11; 00 §7 rejects one generic history table and rejects event sourcing, with reasons |
| REQ-HIS-02 | all domain | actor_user_id, actor_kind, reason_id on every history row |
per owning group | 1 | AC1.11 — actor and reason shown; PROP-19 actor_unknown data-quality signal for rows written without an attributable actor |
| REQ-HIS-03 | audit |
audit.audit_event (RANGE-partitioned, hash-chained, incl. source_service with ck_audit_source — 03 §28.1, migration 004). GAP-27 CLOSED |
audit | 1 | AC0.4; 05 §7.1; 06 §4.1 GET /api/v1/audit queryable by actor and by candidate |
| REQ-HIS-04 | audit |
audit.audit_event — grants revoked, trigger raises, hash chain verifies |
audit | 1 | AC0.4 nightly hash-chain verifier passes; PROP-08 off-box immutable partition export as the independent check; 00 §7 rejects a separate audit database / SIEM / blockchain |
| REQ-HIS-05 | audit |
audit.audit_event with read actions, attributed to an originating service by audit.audit_event.source_service (03 §28.1, migration 004 — GAP-27 CLOSED); ai.conversation_tool_invocation |
audit | 1 | 05 §7.3; 06 §1.13 the audit rule (profile detail view, timeline, notes, export, erasure are audited reads; list/search are not) |
| REQ-HIS-06 | requisition, scoring |
app.job_version, app.job_requirement, app.scoring_config_version, app.pipeline_config_version, app.matching_config_version, app.offer_version |
jobs, ATS scoring | 1 | AC1.7 — UPDATE raises on all four Phase 1 version tables, asserted by tests |
2.18 Outbound publishing (2)
| Requirement | Module | Database Entity | API Group | Phase | Acceptance Evidence |
|---|---|---|---|---|---|
| REQ-PUB-01 | integrations_outbound |
app.job_posting, app.job_posting_metric; ref.publish_platform (8 rows, cost_band_id) |
job publications | 4 | AC4 — publishing and unpublishing reconciles state; D4 — applications arrive back through raw_intake |
| REQ-PUB-02 | integrations_outbound, identity |
app.role_permission (publication.publish restricted) |
job publications | 4 | OBD-13 — central Talent Ops accounts, publish restricted to HR Administrator; DEF-04 entry condition |
2.19 API contract (5)
| Requirement | Module | Database Entity | API Group | Phase | Acceptance Evidence |
|---|---|---|---|---|---|
| REQ-API-01 | API layer | — | all | 1 | 06 §1.1 versioning; 07 §4.1 sub-slice 1C — OpenAPI schema by drf-spectacular plus a generated TypeScript client, so the contract is written once |
| REQ-API-02 | API layer, ai_orchestration |
ai.ai_model_config_version (version is data, not a route) |
chatbot | 1 | ADR-0011; 06 §8 — if legal requires self-hosting the /ai/* contracts survive |
| REQ-API-03 | API layer | — (circuit breaker) | all | 1 | 06 §1.7 error envelope; 02 §9.2 degradation matrix. Shares GAP-21's missing test |
| REQ-API-04 | API layer | Queue tables owned by procrastinate in schema queue |
async jobs | 1 | 06 §1.14 job-handle convention; 06 §5 complete async register; ADR-0004 |
| REQ-API-05 | API layer | — | all | 1 | 06 §1.11 covers rate limits. Expected latency is not published anywhere in the contract — GAP-19 |
2.20 Non-functional and experience requirements (11)
These are met by the prototype today and verified (findings §G). They are in the matrix because the migration must not lose them — every one is a regression target, not a build target.
| Requirement | Module | Database Entity | API Group | Phase | Acceptance Evidence |
|---|---|---|---|---|---|
| REQ-NFR-01 | frontend | — | — | 0 onward | AC4 — the accessibility sweep passes WCAG 2.1 AA across every migrated route in both themes; baseline is 23 routes × 2 themes, 8,459 text nodes, 0 failures (findings §G) |
| REQ-NFR-02 | frontend | — | — | 0 onward | AC4 sweep; css/styles.css responsive 320px→ultrawide retained verbatim |
| REQ-NFR-03 | frontend | — | — | 0 onward | AC4 sweep; 44px touch targets in the retained design system |
| REQ-NFR-04 | frontend | — | — | 0 onward | D0 — the React shell renders the same design tokens; js/app.js:64,193,198 theme behaviour preserved |
| REQ-NFR-05 | frontend | — | — | 0 onward | AC0.2 — CI is red for a CSS rule using a raw colour instead of a var(--…) token |
| REQ-NFR-06 | frontend | — | — | 0 onward | AC4 sweep; safe-area and dvh handling in the retained CSS |
| REQ-NFR-07 | ai_orchestration |
ai.ai_model_invocation.latency_ms |
chatbot | 1 | Ceiling unresolved (OBD-03), no acceptance criterion — GAP-10 |
| REQ-NFR-08 | frontend, ai_orchestration |
— | all AI-bearing groups | 1 | PROP-13 in-screen panels and badges. Not measurably stated — GAP-22 |
| REQ-NFR-09 | frontend | — | — | 0 onward | 06 §7 maps all 23 prototype routes to API contract targets; AC4 — no prototype screen remains reachable at the end |
| REQ-NFR-10 | frontend | — | — | 0 onward | AC0.2 token gate; 00 §7 rejects rebuilding the CSS with a stated reason |
| REQ-NFR-11 | frontend | — | analytics | 1 onward | D0 — "one chart from the retained charts.js"; 00 §6 row 6 cost statement |
2.21 Money and time (3)
| Requirement | Module | Database Entity | API Group | Phase | Acceptance Evidence |
|---|---|---|---|---|---|
| REQ-MON-01 | platform | Paired *_amount numeric + *_currency_code columns with an FK to ref.currency, on job_version (salary_min_* / salary_max_*), offer_version (base_salary_*), candidate (desired_salary_*), ref.grade (band_min_* / band_max_*) and the ai.* cost columns |
jobs, offers, candidates | 1 | AC3 — an amount in JPY with decimal places is refused by the rounding trigger; a salary range with mismatched currencies is refused; 00 §7 rejects float, money and integer minor units with reasons |
| REQ-MON-02 | platform | app.fx_rate (Phase 3, migration 015); ref.currency (Phase 1, migration 002) |
analytics, offers | 3 | PROP-09; AC3. Phase mismatch resolved — the 03 §5 row is split per table (GAP-20 closed) |
| REQ-MON-03 | platform | timestamptz throughout; date for calendar-only values |
all | 1 | 03 §4.3; 00 §7 rejects timestamp without time zone plus a UTC convention; AC2.5 DST test |
3. Domain-modelling principles (assignment §5)
3.1 A numbering discrepancy, stated rather than papered over
00-scope-classification.md §1 cites the data-model principles as §5.1–§5.6.
05-security-rbac-ai-governance.md §5 is headed "AI governance (§5.6, §5.7, §12)". So the
package as written does not agree on how many principles §5 contains. Reading both documents
together, the only consistent assignment is that §5.7 is the AI-governance principle (output
explainable, versioned, reviewable; advisory only) and §5.6 is the scoring principle, which is why
05 cites the two together — an ATS score is both.
Separately, the principle "duplicate detection must include manual review and reversible merge"
is cited throughout 00 §2.3 as a bare Constraint with no section number at all. It is fully
traced below as PRN-DUP so that nothing is lost regardless of where it actually sits in §5.
00 §1 should be corrected to read §5.1–§5.7. Recorded as GAP-26.
3.2 Principle traceability
| Principle | REQ ids that carry it | Structural mechanism (not a policy) | Primary entities | Phase | Acceptance Evidence |
|---|---|---|---|---|---|
| §5.1 Raw intake must exist before candidate creation | REQ-INT-01, INT-09, INT-12, CAN-03, APP-04 | candidate.created_from_raw_intake_id NOT NULL against a non-deferrable FK, plus the deliberate absence of POST /candidates |
app.raw_intake, app.intake_resolution, app.candidate |
1 | AC1.3 (insert fails at the database), AC1.2 (terminal state with no candidate), 03 §12.5 five layers, D1 |
| §5.2 Candidate identity separate from applications | REQ-CAN-01, CAN-05, APP-01, APP-05, SCR-01 | Two tables with no denormalised job columns on candidate; ats_result has no candidate_id |
app.candidate, app.job_application, app.ats_result |
1 | AC1.4, AC1.5, 05 §5.2 closing note, D1 |
| §5.3 Jobs, requirements and scoring configs versioned | REQ-JOB-02, JOB-03, JOB-04, JOB-06, SCR-12, SRC-04, HIS-06, OFR-02, MON-02 | INSERT+SELECT-only grants plus immutability triggers on six *_version tables; config binding is its own historical table |
app.job_version, app.job_requirement, app.scoring_config_version, app.job_scoring_assignment, app.pipeline_config_version, app.matching_config_version, app.offer_version |
1 | AC1.6, AC1.7 (UPDATE raises, asserted by tests), ADR-0007, D1 |
| §5.4 Recruiter assignment flexible and historical | REQ-ASG-01, ASG-02, ASG-03, ASG-04 | Two concrete interval tables (never polymorphic — an unenforceable FK), GiST EXCLUDE for overlap, partial unique index for the single current primary |
app.job_assignment, app.job_application_assignment, ref.assignment_role |
1 | 07 §4.1 sub-slice 1B; 00 §10 row 2 resolution; owners_at(subject, ts) |
| §5.5 Current state and history both exist | REQ-APP-03, HIS-01, HIS-02, HIS-03, DUP-08, DAT-06, OFR-02 | Six typed per-entity history tables with EXCLUDE-enforced non-overlapping intervals, plus one partitioned append-only audit log — deliberately not one generic event table |
six *_history tables + audit.audit_event |
1 | AC1.11, AC0.4, 00 §7 (generic history table and event sourcing both rejected with reasons) |
| §5.6 ATS score is per application, explainable and reproducible | REQ-SCR-01 … SCR-12, AIC-01 | ats_result reachable only through job_application; weight_applied and contribution stored not recomputed; five pinned inputs; is_current + superseded_by_id so a rescore inserts; a human override is a new append-only row, never a mutation |
app.ats_result, _criterion, _skill, app.ats_result_override, app.scoring_config_version |
1 | AC1.4, AC1.6, AC1.7, AC1.8, 05 §5.2 full record, D1. The two parts of the record that were [additive] and unbuilt are now built — _criterion.match_state (03 §20.5) and append-only app.ats_result_override with tg_ats_result_override_immutable, uq_ats_result_override_current and tg_ats_override_target_current (03 §20.7), both in migration 012: GAP-27 CLOSED |
| §5.7 AI output explainable, versioned, reviewable; advisory only | REQ-GOV-01, GOV-03, GOV-06, GOV-08, GOV-11, GOV-12, SCR-06, SCR-07, SCR-10 | Three independent layers: import-linter contracts (intelligence cannot import domain), the transition() service guard on actor_kind, and the ats_result CHECK. Plus two absences: no bulk-reject endpoint, no rule may emit a terminal-negative transition |
ai.ai_model_invocation, ai.ai_suggestion, ai.ai_review, ai.prompt_template_version, ai.ai_model_config_version |
1 | AC1.9, AC0.2 (boundary-violating import fails CI), 05 §5.3, ADR-0011, D0/D1 |
| §5-DUP Duplicate detection with manual review and reversible merge | REQ-DUP-01 … DUP-08 | Marked never deleted; merge requires a human decider; reversal is a mechanical replay of candidate_merge_operation rows in reverse seq under stack discipline |
app.duplicate_candidate_pair, app.candidate_merge, app.candidate_merge_operation |
1 detect / 2 merge | AC2.1, AC2.2, AC2.3, D2 (merged then unmerged live), ADR-0008 |
All eight principles are traceable to an enforcing database or CI mechanism, not to a policy statement. That is the single most important line in this document, because every one of the eight was violated by the prototype (findings §F) and a policy-level fix would not have survived contact with two developers and a schedule.
4. Coverage summary
4.1 By phase
| Phase | Confirmed requirements first satisfied | Share | Cumulative |
|---|---|---|---|
| 0 | 10 | 6% | 10 (6%) |
| 1 | 94 | 59% | 104 (66%) |
| 2 | 30 | 19% | 134 (85%) |
| 3 | 19 | 12% | 153 (97%) |
| 4 | 5 | 3% | 158 (100%) |
158 confirmed requirements. 104 (66%) land in Phase 0–1; 54 (34%) are deferred to Phase 2 or
later. Phase 1 carrying 59% of the requirement count is not a claim that Phase 1 is 59% of the
work — it reflects that intake, identity, versioning, scoring, history and authorization are
requirement-dense and screen-sparse, which is exactly why 07 prices Phase 1 at 85–119 dev-days
against Phase 2's 60–85 for less than a third of the requirement count.
4.2 By traceability quality
| State | Count | Share |
|---|---|---|
| Fully traceable — module, entity, API group, phase and at least one named acceptance criterion | 129 | 82% |
| Carries a gap (listed in §5) | 29 | 18% |
Twenty-nine requirements, twenty-eight gaps — the two are different counts and always will be.
Three gaps span two requirements each (GAP-04 → REQ-SRC-04 + REQ-SCR-09; GAP-21 → REQ-GOV-11 +
REQ-API-03; GAP-27 → REQ-SCR-03 + REQ-AIC-04) and one spans none (GAP-26 is a numbering defect in
00 §1, not a requirement gap). Of the remaining 24 single-requirement gaps, two land on the same
requirement — GAP-02 and GAP-28 both gap REQ-ANL-01 — so they contribute 23 distinct requirements:
23 + 3 × 2 + 0 = 29. §4.3's Gapped column and this table count requirements; §5's heading
counts gaps. Do not reconcile them by changing one to match the other. Corrected — this note
read "twenty-seven gaps" and "23 single-requirement gaps" after GAP-28 was raised, which left the
gap total one below §5's own row count and made the 23 look like a gap count rather than a
requirement count.
How many are still open. Per the disposition rule in §5, the counts above are the measurement
as raised and do not move on closure. Tracked separately: eight of the twenty-eight gaps are
closed or ruled — GAP-01, GAP-04, GAP-05, GAP-20, GAP-25, GAP-27 and GAP-28 closed, GAP-02 ruled
— leaving twenty live, with GAP-23 corrected and downgraded to Low rather than closed. Four of
the twenty live gaps have non-engineering owners held in _open-items.md (GAP-03 → OPEN-12,
GAP-10 → OPEN-09, GAP-13 → OPEN-10, GAP-17 → OPEN-11), and two of the eight closed ones carry a
residual business question there (GAP-01 → OPEN-01, GAP-02 → OPEN-02).
4.3 By family
| Family | Count | P0 | P1 | P2 | P3 | P4 | Gapped |
|---|---|---|---|---|---|---|---|
| INT — raw intake | 12 | – | 12 | – | – | – | 1 |
| CAN — candidate identity | 11 | – | 10 | – | 1 | – | 2 |
| DUP — duplicates and merge | 8 | – | 4 | 4 | – | – | 0 |
| JOB — requisitions and versioning | 8 | – | 8 | – | – | – | 2 |
| APP — applications and pipeline | 7 | – | 6 | 1 | – | – | 0 |
| ASG — assignment | 4 | – | 3 | 1 | – | – | 0 |
| SCR — scoring and explainability | 12 | – | 12 | – | – | – | 3 |
| AIC — AI capabilities | 15 | – | 3 | 4 | 5 | 3 | 2 |
| GOV — AI governance and audit | 12 | – | 8 | 1 | 3 | – | 2 |
| ITV — interviews | 6 | – | – | 6 | – | – | 1 |
| ASM / OFR — assessments and offers | 5 | – | – | – | 5 | – | 1 |
| SRC — search | 4 | – | 3 | 1 | – | – | 1 |
| ANL — analytics | 9 | – | – | 6 | 3 | – | 3 |
| WRK — tasks and notifications | 3 | – | – | 3 | – | – | 1 |
| SEC — identity and security | 9 | 2 | 5 | 1 | 1 | – | 2 |
| DAT — data protection | 6 | – | 4 | 2 | – | – | 3 |
| HIS — history and audit | 6 | – | 6 | – | – | – | 0 |
| PUB — outbound publishing | 2 | – | – | – | – | 2 | 0 |
| API — API contract | 5 | – | 5 | – | – | – | 2 |
| NFR — non-functional | 11 | 8 | 3 | – | – | – | 2 |
| MON — money and time | 3 | – | 2 | – | 1 | – | 1 |
| Total | 158 | 10 | 94 | 30 | 19 | 5 | 29 |
Two arithmetic corrections are folded into the Gapped column, stated rather than absorbed
silently. API is 2, not 1 — REQ-API-05 carries GAP-19 and REQ-API-03 carries GAP-21, and only
the first was counted. SCR is 3 and AIC is 2 — REQ-SCR-03 and REQ-AIC-04 both trace to
ats_result_criterion.match_state, which GAP-27 showed did not exist in 03 when this audit ran.
The total therefore moves 25 → 29: two newly gapped requirements plus the one miscount, and the
previous total was itself one below its own column sum. The match_state column now exists
(03 §20.5, migration 012) and GAP-27 is closed, but the Gapped column stays at 3 / 2 and the
total at 29 — this column is the as-raised measurement, per §4.2 and the §5 disposition rule.
4.4 Coverage of the two id sets that support the confirmed set
- 20
PROP-items (00§3): every one supports at least oneREQ-, and everyREQ-cited by aPROP-appears in §2 above. No orphanPROP-. - 21
DEF-items (00§4): 18 map to a deferredREQ-. Three map to no confirmed requirement at all — DEF-14 (pgvector), DEF-19 (PWA) and DEF-21 (hiring-manager and calendar modules) — which is correct, because they are deferrals of ideas, not of requirements. DEF-21 in particular is a consolidation decision recorded as a deferral so it is not re-litigated. - 25
OBD-items (00§8): 22 are referenced by at least one row in §2. Three are not — OBD-22 (career-portal content ownership), OBD-24 (never point the prototype at real data) and OBD-25 (mandatory screening questions). All three are genuine business decisions with no requirement to attach to; OBD-24 in particular is a prohibition, and its acceptance evidence is the absence of a real-data connection to the prototype, which no test can assert. Noted, not gapped.
5. GAP TABLE
Twenty-eight gaps, of which eight are closed or ruled and twenty are live (§4.2). Each states which part is missing, what would close it, and who should own it. A gap here is not automatically a defect in the design — several are honest consequences of an unanswered business question — but every one is something a reviewer would otherwise have to discover by reading four documents side by side.
Severity: High = a requirement will not be satisfied when its phase completes, or two documents actively contradict. Medium = satisfiable but unverifiable as written, or partially covered. Low = bookkeeping, but it will cause confusion at implementation time.
Dispositions, and why the counts in §4 do not move when a gap closes. §4 counts gaps as
raised by this audit — that is the measurement, and re-deriving it every time a row closes would
make the coverage percentages unciteable. Closure is tracked per row instead. Current state:
GAP-02 is RULED (Cost per Hire out of Phase 2, into Phase 4 — see also _open-items.md
OPEN-02), GAP-20 is CLOSED (the 03 §5 Currency and FX row is split), and GAP-23 is
corrected and downgraded to Low (the control it said was missing already exists as a database
CHECK; the real residual risk is narrower). Six rows are cross-document contradictions with
non-engineering owners and are tracked in the register: GAP-01 → OPEN-01, GAP-02 → OPEN-02,
GAP-03 → OPEN-12, GAP-10 → OPEN-09, GAP-13 → OPEN-10, GAP-17 → OPEN-11.
Closed in the 03 review pass, all four with the recommended fix applied and an acceptance
criterion added: GAP-04 (app.relevance_config + app.relevance_config_version in migration
014, weights as typed columns with a sum-to-1.0 trigger, relevance_config_version_id pinned on
candidate_job_match), GAP-05 (app.setting moved to Phase 1 migration 012, seeded with the
score-visibility keys, with the A-22 no-deploy criterion), GAP-25 (app.job_posting split into
Phase 1 migration 006a; the row's citation of "migration 013" corrected to 011/027), and
GAP-28 — newly raised in the same pass, because Time to Fill was a named headline KPI that
this list had treated as covered. §4's counts do not move for any of these, per the rule above;
GAP-28 is the twenty-eighth gap id and is counted only in §5.
Closed in the Close pass: GAP-27, this list's only High finding. All nine additive objects 05
§9.2 declares are now in 03 with a named migration each — four tables (ref.region §6/002,
app.access_grant §7.7/011, app.ats_result_override §20.7/012,
app.candidate_erasure_request §28.5/025a), four columns (ats_result_criterion.match_state
§20.5/012, audit_event.source_service §28.1/004, intake_parse_attempt.injection_signal +
_codes §18.1/010, pii_classification.data_subject_kind §28.2/004), plus
access_scope.region_id and ref.location.region_id (§6, §7.3) and the three read-only roles as
roles with GRANTs (§28.6, provisioned 001, policy 021a/023/027a). 03 §32.1 gains
reconciliation row 5 and 03 §5 records the four new tables, its map derived at 159. The
GAP-27 annotations in §2.7, §2.8, §2.15 and §3.2 above are removed and those cells now name real
entities. Two parts of the remediation text below did not land, both deliberately and both
stated at their tables in 03: the role_assignment scope columns, which 05 §9.2 itself
withdraws (role_assignment.access_scope_id already carries them, 03 §7.4), and 'region' in
access_scope.scope_type's CHECK, which is OPEN-05 — the column, the exclusive-arc branch and
the scope_key coalesce entry are all in place, so adopting it is one line rather than the three
coordinated edits whose third-edit omission this row flagged as a silent authorization defect. The
migration placements also differ from the recommendation below, because the recommendation
forward-referenced: ref.region is 002 not 003, and access_grant + access_scope.region_id
are 011 not 003, since 003 cannot see ref.location, app.job or app.job_application
(03 §33.1). §4's counts do not move, per the rule above; the closure is recorded here and in
§4.2's open/closed tally. Closed 2026-07-29; _open-items.md C-10 carries the same closure from the
arbitration side.
| Gap | Requirement | What is missing | Severity | Closure | Owner |
|---|---|---|---|---|---|
| GAP-01 | REQ-JOB-07 | CLOSED — engineering half applied; one business question remains. The gap as found: 00 §2.4 said Phase 1; 03 §5 put the four approval_* tables in Phase 2; 07 §4.2 excluded "approval chains" from Phase 1 and listed them under Phase 3; no acceptance criterion existed at any phase; and so publishing a requisition version was unapproved in Phase 1 — the exact control OBD-12 and the job-board cost bands (OBD-13) assume. The recommendation below has now been applied: 07 gains T-17b (approval_request + approval_decision, single-approver route, publish_version() refuses an unapproved version) and A-16b (approve/reject action, job.approve-gated), 07 §4.5 gains criterion 13 asserted as a forbidden-write test in A-26, 07 §4.2 now excludes only multi-step chains, 03 §5 shows Approval workflow as 1–2, and 03 §33 splits migration 017 into 017a (Phase 1, Talha) and 017b (Phase 2, Ahmed). Residual: OBD-12 must still confirm who the single Phase 1 approver is — T-17b hardcodes "the requisition's hiring manager, else department head" and stores the resolved approver on the request, so a different answer changes one resolution rule, not the schema. |
High → Low (residual) | Applied as recommended: single-approver in Phase 1, multi-step in Phase 2. Remaining action is OBD-12's answer, tracked as OPEN-01. | Talha (done) + Talent Lead (OBD-12, residual) |
| GAP-02 | REQ-ANL-01 | Cost per Hire has no data source. RULED — option (a). One of the eight headline KPIs in REQ-ANL-01 is Phase 2, and no entity in the design holds recruiting cost. The only candidate source, app.job_posting_metric.spend_amount, is Phase 4 and covers job-board spend only — not agency fees, referral bonuses or recruiter time. The prototype's figure is the literal constant costPerHire: 4280 at js/data.js:247. So Phase 2 as scoped would ship a KPI tile two phases before even its partial source exists. |
High | Ruled: Cost per Hire is dropped from the Phase 2 KPI set and reinstated at Phase 4 alongside app.job_posting_metric, scoped honestly as cost per hire from measurable channel spend with agency fees, referral bonuses and recruiter time named as out of scope. Phase 2 ships seven KPIs and AC2.7 covers all seven. Amend: 07 §5.1 and the D2 script (done), 03 §11 to name job_posting_metric as the Phase 4 source of REQ-ANL-01's cost dimension and of REQ-ANL-09's, and §6.2 below (done — this also settles that table's keep/cut verdict). Why (a) and not (b): option (b)'s app.hiring_cost table is cheap, but the number it produces is only as good as recruiter and Finance entry compliance, and nobody has committed to it. A tile that under-reports cost by the majority of real spend is worse than a tile that is honestly absent, and reinstating it later is one migration. app.hiring_cost does not exist and is not designed — read every mention of it below as the shape option (b) would take, not as an entity in 03. The ruling chose (a), so REQ-ANL-01's cost dimension points at app.job_posting_metric.spend_amount at Phase 4 (03 §11) and at nothing before that; app.hiring_cost appears in no 03 section, no 03 §5 map row and none of the migrations in 03 §33, deliberately, and it is the only table named anywhere in this document that 03 does not define. Reversible by Finance: if Finance confirms they will maintain manual entry, the table would ship in a Phase 2 migration with a recruiter entry screen and the tile would stay. Its sketched shape — job_id or job_application_id as P7 typed nullable FKs with CHECK (num_nonnulls(...) = 1), cost_type_id FK to a ref vocabulary covering posting spend / agency fee / referral bonus / other, an amount numeric + currency_code ISO-4217 FK pair per REQ-MON-01, incurred_on date, entered_by_user_id — is a proposal for that migration to specify, not a design this package has reviewed. Tracked as OPEN-02 in _open-items.md. |
Finance + Talent Lead (ruling stands unless they flip it) |
| GAP-03 | REQ-ANL-08 | The 27-day time-to-hire baseline is not stored anywhere. It exists only as the prototype constant timeToHire: 27 at js/data.js:245, and ASM-15 already flags that the figure may be a demo value rather than a measurement. "Measurable against the baseline" is untestable without the baseline as data. |
Medium | Store the baseline as a dated app.setting row with a source note, or as a ref-style baseline table if more than one metric gets one. Confirm provenance with Talent Ops before Phase 2 (ASM-15). |
Talent Ops |
| GAP-04 | REQ-SRC-04, REQ-SCR-09 | The relevance-blend configuration has no table — CLOSED. 03 §29.4 stated the four weights and half_life_days "live in a versioned config row, not in code" and gave the formula, but named no table, and no such table appeared in the 03 §5 table map or the §33 migration sequence. REQ-SRC-04 explicitly requires the blend be attributable to a versioned configuration and REQ-SCR-09 depends on the same thing, so as written the requirement was unsatisfiable and the weights would have become constants in the search service — precisely the failure mode the versioning principle (03 §5.3, P3) exists to prevent, and the one that makes "why did this candidate rank third in March" unanswerable. |
High → Closed | Applied. 03 §29.4 now defines app.relevance_config (identity: key, name, owner_user_id, current_version_id) and app.relevance_config_version (immutable: version_no, the four weights as numeric(6,4) columns, not JSON, half_life_days, published_at, created_by_user_id, config_hash), with the same INSERT/SELECT-only grants plus BEFORE UPDATE OR DELETE immutability trigger used on scoring_config_version, and a DEFERRABLE sum-to-1.0 check (the blend is treated as normalised, stated as such). Both appear in 03 §5's table map ("Search ranking configuration", §29.4, 2 tables, Phase 1) and in 03 §33 migration 014 — the same migration that creates candidate_search_index. app.candidate_job_match gains relevance_config_version_id, pinned exactly as it already pins scoring_config_version_id, so any stored ranked result is reproducible. Phase 1 acceptance criterion added: changing a weight mints a new version rather than mutating the row, asserted by a test that attempts an UPDATE on a published version and expects the immutability exception. |
Talha (done) |
| GAP-05 | REQ-SEC-05 | The score-visibility setting is Phase 1 but its storage was Phase 2 — CLOSED. app.setting was created in migration 020 (Phase 2, 03 §33), while REQ-SEC-05 requires match-score visibility to be a configuration setting in Phase 1 and 05 §2.10 rule 5 commits to it concretely ("ATS score visibility is a configurable role setting, defaulting to hr_admin + recruiter + hiring_manager in Phase 1 and off for interviewer permanently"). Without Phase 1 storage that rule becomes the hardcoded constant the requirement forbids — and it is not cosmetic: OBD-05 is an open business decision, so the answer WILL change after Phase 1 ships, and changing it would then be a code deploy rather than a configuration change. |
High → Closed | Applied as recommended. app.setting moves forward to Phase 1 migration 012, seeded with the score-visibility keys only; migration 020 (renamed 020_worklist.sql) keeps task, saved_search, saved_report and report_run, and the rest of the workspace-settings surface plus the Settings screen stay in Phase 4. No UI work is added — 00 PROP-06 already makes the Django admin the Phase 1 administration surface, so registering the table there is the whole administrative story. 03 §33's migration order and 03 §5's table map are amended together (Operational surfaces now reads Phase 1–2, 5 tables). The Phase 1 acceptance criterion this gap lacked is added: flipping the ats.score_visible_to_roles setting changes what a hiring_manager sees on the next request with no deploy, asserted in A-22's field-policy tests — and asserted negatively for interviewer, whose exclusion is not configurable. |
Talha (done) |
| GAP-06 | REQ-CAN-02 | No bulk-action endpoint on candidates. REQ-CAN-02 requires "filter, sort, rank, bulk actions and a full profile view". 06 §2.11 delivers filter, sort, rank and profile, but bulk operations exist only for inbox, applications, assignments and messages — never candidates. 06 §1.16 correctly forbids a generic POST /batch, so this must be a named endpoint. |
Medium | Add the named bulk operations recruiters actually need — POST /candidates/bulk-tag and POST /candidates/bulk-add-to-pool (≤50, all-or-nothing, idempotency key, one audit event per candidate plus one batch event, per the §2.10/§2.12 pattern). Phase 1 for tagging. |
Ahmed |
| GAP-07 | REQ-ASM-02 | The three named assessment dimensions are not queryable. REQ-ASM-02 requires assessments to report Code Quality, Problem Solving and Time Management. 03 §24 stores per-section detail in assessment_result.breakdown jsonb and §30.3 justifies not creating assessment_result_section on the grounds that "no report dimension needs them yet" — but this requirement is that report dimension. |
Medium | Since the three dimensions are named and fixed, promote them: either three typed nullable columns on assessment_result, or assessment_result_section as §30.3 already anticipates. Decide before the Phase 3 migration, not after data exists in breakdown. |
Ahmed, Talha review |
| GAP-08 | REQ-INT-02 | 3 of 11 channels at Phase 1. Outlook, careers portal and manual upload land in Phase 1; four job boards in Phase 3 (DEF-12, gated on OBD-13 and ASM-14); four intake forms in Phase 4 (DEF-11, gated on OBD-11). The requirement — "all eleven normalise into one candidate record structure" — is only fully met in Phase 4. | Medium | No engineering fix; this is honest phasing. But the normalisation half should be proven early: seed all 11 ref.source_channel rows in Phase 1 and add a Phase 1 test asserting that a synthetic submission on each of the 11 channel types produces a structurally identical raw_intake row. That converts a Phase 4 promise into a Phase 1 assertion. |
Talha |
| GAP-09 | REQ-SCR-11 | A Phase 1 requirement verified in Phase 3. "Ranking across the full candidate base does not degrade the interface" is Phase 1, but 07 §11.6 schedules performance checks in Phase 3 (A-43), and none of the 12 Phase 1 acceptance criteria mentions latency or dataset size. |
Medium | Add a Phase 1 criterion with a number: ranked candidate list p95 under a stated threshold against a seeded dataset at the ASM-03 volume (10⁴–10⁵ rows). Without a seeded dataset the criterion is untestable, so the seed script is part of the fix. | Ahmed, Talha review |
| GAP-10 | REQ-NFR-07 | No latency threshold, so no criterion. OBD-03 recommends first token under 2s and completion under 8s, but that is an assumption, not an agreed ceiling, and no Phase 1 acceptance criterion references it. ai.ai_model_invocation.latency_ms records the number; nothing asserts it. |
Medium | Get OBD-03 answered, then add the criterion as a p95 over ai_model_invocation.latency_ms with a stated window, plus the async-fallback rule from REQ-API-04 for anything above it. |
Talent Lead + AI Technology |
| GAP-11 | REQ-DAT-01 | No engineering artefact at all. Processing only within Utopia-controlled infrastructure or under a DPA is traceable to no module, entity, API group or test. It is discharged by ADR-0012 (topology), OBD-01/OBD-14 (hosting) and a signed DPA — all outside the codebase. | Medium | Accept that this is contractual, and make the acceptance evidence explicit rather than absent: a named DPA reference recorded on every ai.ai_model_config_version (the column already exists) plus an ADR stating the infrastructure boundary. Then the requirement is verifiable by document review, which is the honest standard. |
Legal + Talha |
| GAP-12 | REQ-DAT-02 | Recorded, not enforced. ai.ai_model_config_version.retains_data makes "which candidate data went to a retaining provider" a query, which is genuinely valuable — but nothing prevents activating a version with retains_data = true. |
Medium | Add the guard the design already implies: refuse activation of any ai_model_config_version with retains_data = true unless an explicit, named override with a legal reference exists. One CHECK plus one service guard, Phase 1. |
Talha |
| GAP-13 | REQ-GOV-07 | Per-jurisdiction audit retention is not derivable. The requirement ties audit retention to "the jurisdictions in which the requisition was posted". OBD-17 sets a single uniform policy (13 months hot, 7 years archived), and no entity links an audit.audit_event to a posting jurisdiction. |
Medium | Two honest options. (a) State that the strictest applicable standard is applied uniformly — which OBD-04 already recommends — and amend the requirement wording so it stops implying per-jurisdiction derivation. (b) If a jurisdiction genuinely needs longer, that needs a job_posting → jurisdiction → retention-class join, and OBD-17 must be reopened. (a) is recommended. |
Legal |
| GAP-14 | REQ-WRK-02 | Column in Phase 2, producer in Phase 4. task.origin = 'ai_suggestion' exists from Phase 2, but the capability that produces AI next-best-action suggestions (REQ-AIC-12, AI-12) is Phase 4. For two phases the requirement is structurally satisfied and functionally empty. |
Low | Say so in the Phase 2 demo rather than letting the empty state read as a bug: the origin dimension and the badge are proven in Phase 2 with rule-originated tasks; AI-originated tasks arrive in Phase 4. Alternatively pull one narrow AI suggestion type forward. | Talent Lead |
| GAP-15 | REQ-AIC-02 | Half the requirement is three phases away. Candidate Matching is Phase 1 with modules scoring + talent_pool, but talent_pool and its three tables are Phase 3 (DEF-06). The Phase 1 half is real — candidate_job_match (03 §20.6) answers "best-fit open roles across all Utopia brands" without a pool. |
Low | Restate REQ-AIC-02's phase as "1 (ad-hoc match) / 3 (pool-scoped rematch)" in 00 §2.8, matching how REQ-AIC-10 is already split. No engineering change. |
Talha |
| GAP-16 | REQ-SEC-08 | One of the four named settings has nothing to enforce. REQ-SEC-08 requires SSO, 2FA, session timeout and password policy to be enforced rather than display chrome. Under OBD-15 (SSO only, no local password store) there is no password to have a policy about, and 2FA is enforced by Entra ID, not TalentFlow. | Low | Reword to what is actually enforced and where: session timeout and idle expiry in app.user_session (TalentFlow), SSO and 2FA at the IdP with TalentFlow asserting the claim, password policy not applicable under OBD-15. State the IdP dependency so nobody reads the settings screen as a control it isn't. |
Talha |
| GAP-17 | REQ-CAN-08 | No seed source, no write path, no test for the reviewable half. ref.skill (~500) and ref.skill_alias (~2,000) exist, but the only cited source is js/data.js:46 skillsPool, which is synthetic demo data (findings §C). 06 exposes GET /ref/{vocab} only — administration is Django admin (PROP-06) with no API. And no acceptance criterion tests that unmapped parser labels remain storable and reviewable. |
Medium | Three small items: name a real seed taxonomy source and its licence; add a Phase 1 criterion asserting an unmapped raw_label survives parse, is visible in the profile, and is promotable to a ref.skill by an admin; confirm Django admin is an acceptable write path for ~2,000 aliases before Phase 1 closes. |
Ahmed, Talha review |
| GAP-18 | REQ-ITV-05 | No acceptance criterion for the month view. Having no entity is correct — 02 §4.6 rightly makes calendar a read view over interview + worklist rather than a table. But none of the seven Phase 2 criteria mentions it, so a requirement with no state and no criterion has nothing holding it. |
Low | Add a Phase 2 criterion: the month view renders scheduled interviews in the viewer's timezone with the organiser's wall-clock intent preserved across a DST boundary (reusing the AC2.5 fixture), and shows nothing the viewer's scope forbids. | Ahmed |
| GAP-19 | REQ-API-05 | Rate limits are published; expected latency is not. 06 §1.11 defines rate limiting with headers. Nothing in 06 publishes per-endpoint expected latency, and the SLOs in 02 §11.1 are operational targets, not part of the contract the interface reads. |
Low | Put a latency budget in the OpenAPI schema as a per-operation extension, generated from the same source as the SLO table so the two cannot drift. Then "published" is literally true. | Talha |
| GAP-20 | REQ-MON-02 | CLOSED. 03 §5 listed Currency and FX as one row at Phase 1 while REQ-MON-02 and PROP-09 are Phase 3, so app.fx_rate had no Phase 1 consumer — and the same row claimed one table where §26.2 documents two objects. |
Low | Done. The 03 §5 row is split: ref.currency Phase 1 (REQ-MON-01 depends on it, migration 002, counted in the §6 reference-data area) and app.fx_rate Phase 3 (migration 015). The phase column is now honest per table and the area count no longer double-counts ref.currency. |
Talha — closed |
| GAP-21 | REQ-GOV-11, REQ-API-03 | "Fully operable with AI unavailable" is never tested. 02 §9.2 has a degradation matrix and 04 §8.2 a degradation ladder, but no phase acceptance list contains a criterion that exercises them. This is the requirement most likely to be quietly false, because the failure only appears when the provider is down. |
Medium | Add a Phase 1 criterion: with the AI provider forced to fail, the five Playwright journeys still pass, every AI-derived surface renders an explicit unavailable state, and no request returns 5xx because of it. That is a fault-injection fixture, not a chaos platform. | Ahmed, Talha review |
| GAP-22 | REQ-NFR-08 | Not measurably stated. "Recruiters act on AI output from the screens where they already work" has no entity, no endpoint and no criterion, and no definition of what would falsify it. | Low | Convert it into something checkable: every AI-derived value in the migrated screens carries a provenance badge and an in-place accept/dismiss action, with no separate AI screen required to act on it — asserted by the Playwright journeys. Then it is a UI contract rather than an aspiration. | Ahmed |
| GAP-23 | REQ-DAT-05 | A database-level control exists; nothing flags its removal. Corrected — an earlier revision of this row claimed "a future migration could add such a column and nothing would fail", which understates a control the design already has. audit.pii_classification.ck_no_special_category CHECK (class <> 'special_category') (03 §31.1, §28.2) is the strongest available control and it is a constraint, not a promise: introducing special-category data requires a deliberate migration that drops the CHECK, which forces the access-control, aggregate-only-read and lawful-basis decisions to be made explicitly. The genuine residual risk is much narrower than "nothing would fail" — it is that a migration can drop the CHECK, and no gate notices when it does. |
Low | One CI assertion, not a new control: after migrations run against the scratch database, assert that ck_no_special_category is still present on audit.pii_classification (query pg_constraint by name). That turns removing it into a deliberate, reviewable act instead of a silent one, and it composes with the RULING-02 drift check that already stands up a migrated scratch database. Optionally pair it with the name denylist as a warning, not a gate. Do not build a column-absence checker — 03 §31.1 already documents the control; an implementer sent to build one would be duplicating the CHECK in Python. |
Ahmed |
| GAP-24 | REQ-ANL-09 | Cost side of channel comparability unavailable until Phase 4. REQ-ANL-09 is Phase 3 and requires the eleven inbound channels be comparable. Volume comes from raw_intake.source_channel_id (Phase 1). Cost per channel needs job_posting_metric.spend_amount, which is Phase 4, and four of the eleven channels do not exist until Phase 4 either (GAP-08). |
Low | Scope REQ-ANL-09 explicitly at Phase 3 to volume, conversion and quality by channel — which is the comparison recruiters actually use — and phase the cost dimension with REQ-PUB-01 at Phase 4. Interacts with GAP-02. | Talent Lead |
| GAP-25 | REQ-JOB-06 | job_posting phase conflict — CLOSED. 03 §5 listed Job postings and publishing at Phase 4; 07 §4.1 sub-slice 1B includes job_posting in Phase 1; and job_application.job_posting_id is a Phase 1 column created in 03 §33 migration 011 while app.job_posting was created in migration 027 (Phase 4). Citation corrected: an earlier revision of this row cited "migration 013", which creates matching_config, duplicate_candidate_pair, candidate_merge and candidate_merge_operation and has nothing to do with postings — the FK's owner is 011 and the referenced table was 027. That is not merely a wrong citation; it is a hard violation of 03 §33's own stated rule that "a migration may only reference objects created in a lower-numbered file", and 03 §33.1 now records it as one of six such violations with a CI gate that parses each migration for referenced relations and fails on any forward reference. |
Medium → Closed | Applied as recommended. 03 splits app.job_posting out of 027 into a new Phase 1 migration 006a (careers-portal postings only, one row per published version), leaving job_posting_metric and all external-platform state in 027 (renamed 027_publishing_external.sql). 03 §5's table-map row now reads "1 (job_posting, migration 006a) / 4 (job_posting_metric)" and 03 §11 states the split and its forcing reason. |
Talha (done) |
| GAP-28 | REQ-ANL-01 | Time to Fill is a named headline KPI with no fill instant, no close reason and no written formula — CLOSED. This gap list previously flagged only Cost per Hire (GAP-02) and treated time-to-fill as covered. It was not. 03 §9.1 gave job.opened_at ("first transition to Open"), job.closed_at and job.vacancies_filled int (a trigger-maintained counter with no timestamp), which produces three separate wrong answers: (a) closed_at conflates "filled" with "cancelled / withdrawn / headcount pulled" — there was no close-reason column — so the metric could not exclude requisitions that were never filled, inflating or deflating it arbitrarily; (b) job_version.vacancies can exceed 1 and vacancies_filled is a bare counter, so for a 3-vacancy requisition "the fill instant" was undefined; (c) opened_at is the first Open transition, so a requisition closed and reopened spans its dormant period. Time to Hire was fine in principle (job_application.applied_at → terminal_at where the status is hired, exactly recoverable from job_application_status_history) but neither formula was written down anywhere as SQL, so every report would have re-derived it differently. |
Medium → Closed | Applied — three additions to 03. (1) app.job.close_reason_id bigint NULL FK ref.vocabulary_value (vocabulary job_close_reason: filled / cancelled / headcount_withdrawn / merged) with ck_job_close_reason CHECK ((closed_at IS NULL) = (close_reason_id IS NULL)), so time-to-fill restricts to genuinely filled requisitions. (2) The fill instant is defined as the hired transition read from job_application_status_history — min(valid_from) for a single vacancy, the Nth such transition for N — and app.job_vacancy (job_id, seq, filled_by_application_id, filled_at, closed_without_fill_at) gives a per-slot timestamp, with a unique index preventing one hire filling two slots. (3) Both formulas are written as SQL views in migration 021 and nowhere else — app.v_kpi_time_to_hire and app.v_kpi_time_to_fill, the latter taking its origin from the latest open interval in job_status_history rather than job.opened_at, and filtering on close_reason = 'filled'. Report-level aggregation sits on top; the definition is not repeated. 03 §5's Job requisitions row goes 6 → 7 tables. |
Talha (done) |
| GAP-26 | Assignment §5 numbering | 00 §1 cites the principles as §5.1–§5.6; 05 §5 cites §5.6 and §5.7. And the duplicate-detection principle is cited throughout 00 §2.3 as a bare Constraint with no section number. |
Low | Correct 00 §1 to §5.1–§5.7 and give the duplicate-detection principle its section number. §3.2 above traces all eight regardless, so nothing is lost — but two documents disagreeing about how many principles exist is exactly the kind of thing a reviewer notices first. |
Whoever owns 00 |
| GAP-27 | REQ-SCR-03, REQ-AIC-04 (counted); REQ-SEC-03, REQ-DAT-03/06, REQ-HIS-03/05 (affected, not counted) | The nine additive schema objects 05 §9.2 declares as security and explainability controls existed in no table and no migration in 03 — CLOSED 2026-07-29. The finding as raised follows, unedited, because it is the evidence; what was done about it is in the Closure cell. access_grant, ref.region + ref.location.region_id, role_assignment scope columns for BU / department / region, audit_event.source_service, ats_result_override, ats_result_criterion.match_state, intake_parse_attempt.injection_signal, candidate_erasure_request, the staff subject flag on pii_classification, and the three read-only database roles (ats_ai_reader, ats_report_reader, ats_support_readonly) appear only in 05 and in adr/0009–adr/0011. 03 §7.3 constrains access_scope.scope_type to (global, business_unit, department, location, job, job_application, talent_pool) — no region, no grant table; 03 §32.1 lists four reconciliation points and none of these; none of the 29 migrations in 03 §33 creates them. Three consequences, in order of severity: (a) adr/0009 is Accepted and its enforcement strategy resolves scope from access_grant and job__current_version__location__region_id, so an accepted ADR depends on objects the schema does not define; (b) REQ-SCR-03 and REQ-AIC-04 name match_state as their Database Entity, which is a dangling reference, and 05 §5.2's claim that "missing requirements are a queryable fact, not an inference from a null" is unimplementable against 03 as written — a missing requirement is an absent criterion row, indistinguishable from one never assessed; (c) REQ-SEC-03, REQ-DAT-03/06 and REQ-HIS-03/05 stay traceable through objects that do exist (access_scope, v_user_effective_scope, retention_*, audit_event) but lose the specific control 05 designed for them. |
High → Closed | Applied — all nine are in 03, each in a named migration, and every cell above now names a real entity. Where each landed: ref.region + ref.location.region_id → 03 §6, migration 002 (ref.region created before ref.location in the same file so the FK is a same-file backward reference); audit.audit_event.source_service with ck_audit_source and audit.pii_classification.data_subject_kind with ck_pii_subject_kind — the staff subject flag, as an enumerated column rather than a boolean → §28.1 / §28.2, migration 004; app.intake_parse_attempt.injection_signal + injection_signal_codes + ck_parse_injection, both inside the closing-column grant → §18.1, migration 010; app.access_grant (P7 typed nullable subject FKs, not 05 §2.3's (subject_table, subject_id); ck_access_grant_window/_ceiling/_no_self/_revoked/_subject, the revocation-column grant and tg_access_grant_immutable) and app.access_scope.region_id with its exclusive-arc branch and its scope_key coalesce entry → §7.7 / §7.3, migration 011, with access_grant.interview_id added by 019 and offer_id by 023, each re-declaring ck_access_grant_subject, and v_user_effective_scope gaining branch 4 in the same files; app.ats_result_criterion.match_state text NOT NULL with ck_ats_criterion_match_state, ck_ats_criterion_unassessed and ix_ats_criterion_match, and append-only app.ats_result_override → §20.5 / §20.7, migration 012; app.candidate_erasure_request → §28.5, migration 025a; the three read-only roles → §28.6, provisioned NOLOGIN with zero privileges in 001, column-scoped grants and RLS in 021a / 023 / 027a. 03 §32.1 carries them as reconciliation row 5 and 03 §5 records the four new tables with its map derived at 159 from its own column sum. Two parts of the recommendation deliberately did not land, and both are stated at their tables in 03 rather than dropped silently: the role_assignment scope columns, which 05 §9.2 itself withdraws — role_assignment.access_scope_id → access_scope already carries the same enum (03 §7.4), and parallel columns would give it two homes, so the recommendation text above is stale on that single point; and 'region' in access_scope.scope_type's CHECK, which is OPEN-05 and not an engineering decision — the other two of the three coordinated edits are done, so adopting it is one DROP/ADD CONSTRAINT pair and the collision hazard is gone in both worlds. The recommended migration numbers were also wrong and were corrected in the applying: 003 cannot create access_grant, ref.region or access_scope.region_id, because it cannot see ref.location, app.job or app.job_application (03 §33.1's forward-reference gate) — hence 002 and 011. Each of the four new tables traces to a confirmed requirement (REQ-SEC-03, REQ-SEC-03, REQ-SCR-08 / REQ-GOV-01, REQ-DAT-03 / REQ-DAT-06), so §6's reverse check is unaffected in direction; T-ASM-05's figure moved with them. |
Talha (done) |
6. Reverse check — designed things with no confirmed requirement
The matrix above runs requirement → design. This section runs design → requirement, which is where
over-engineering shows up. All 25 modules and every table defined in 03 §§6–28 were checked
— 142 tables at the baseline this check was run against, derived below rather than inherited. 03 §5's headline has since moved to 159, derived from its own column sum: the 03 review pass added seven tables (ref.retention_subject, ref.non_identifying_contact, app.job_vacancy, app.relevance_config + _version, app.communication_suppression, app.api_idempotency_record, audit.partition_seal) and fixed the headline-vs-column arithmetic this section flagged, and the Close pass added GAP-27's four (ref.region, app.access_grant, app.ats_result_override, app.candidate_erasure_request). The 142-table derivation below is the baseline of this audit and is not re-run; the eleven new tables each trace to a confirmed requirement (REQ-GOV-07, REQ-CAN-01, REQ-ANL-01, REQ-SRC-04/REQ-SCR-09, REQ-COM-04, REQ-API-02, REQ-GOV-05; then REQ-SEC-03 ×2, REQ-SCR-08/REQ-GOV-01, REQ-DAT-03/REQ-DAT-06), so §6's conclusion is unaffected in direction.
The table count was recounted, because the figure this check rested on did not add up. 03 §5
opened "144 tables" while its 25 area rows summed to 147, and the Currency and FX row claimed
one table where §26.2 documents two objects. With the headline three below its own column sum,
nothing in §6 could be shown to be exhaustive in either direction, and T-ASM-05 rested on the
figure.
Recounting two independent ways — §5's areas against the section bodies §§6–28, and the 29
migrations in 03 §33, which are the only place every object is enumerated one by one — both give
142: 19 ref + 104 app + 13 ai + 6 audit, with the five app.v_* views excluded because
views own no state. Seven cells in 03 §5 do not survive the recount, and its owner must correct
them there — this document cannot fix the source of truth it audits:
03 §5 row |
States | Should be | Why |
|---|---|---|---|
| Reference data and vocabularies | 20 | 19 | ref.consent_purpose is explicitly "folded into ref.vocabulary_value" in the §6 roll-up — a documented row, not a table |
| Recruitment intake and inbox | 10 | 8 | §12.1–§12.4 document eight: intake_channel, raw_intake, raw_intake_attachment, raw_intake_read, ingestion_run, ingestion_dead_letter, integration_webhook_event, intake_resolution. intake_parse_attempt and parse_issue are counted at §18, as §12.3 says |
| Candidates | 12 | 14 | §13.2 documents thirteen child tables plus candidate |
| Candidate documents | 2 | 1 | §14 documents only app.candidate_document; the blob registry is app.stored_file at §8 |
| Applications | 4 | 3 | §15.1–§15.2 document three; job_application_assignment is counted at §16 |
| Interviews, feedback and scorecards | 11 | 9 | user_availability_rule / _exception are documented and counted at §7.6, which says so |
| Currency and FX | 1 | split the row | ref.currency Phase 1 (migration 002, counted in the §6 reference-data area) and app.fx_rate Phase 3 (migration 015). One row cannot carry two phases honestly — this closes GAP-20 |
The figure is a floor and it has moved. GAP-01, GAP-04 and GAP-27 are now all adopted, and 03
§5's headline reads 159 against a column sum of 159 — the arithmetic defect this section flagged
did not survive the additions. Re-derive the total from the migration sequence whenever §5 changes;
do not inherit it, and do not inherit it from here either. The per-area corrections above are
independent of the additions and stand regardless. See T-ASM-05.
6.1 Result
- All 25 modules trace to at least one confirmed requirement.
02§4.6 additionally lists eleven tempting modules that were deliberately not created (search,embeddings,workflow_engine,calendar,managers,recruiterhub,aistudio,settings,help,reporting_warehouse,tenant/region), each with a stated reason. That is the reverse check already having been done once at module level, and it holds. - 121 of the 142 tables at the checked baseline trace directly to a confirmed requirement. The
16 entries below — 21 tables plus five views, which own no state — do not, or do so only weakly.
Verdicts follow. Tables added to
03§5 after this baseline (GAP-04's two, GAP-27's four) each trace to a named confirmed requirement, so they enter the traceable side and do not lengthen this list; re-run §6.2 if anything else is added.
6.2 The sixteen
| Table(s) | Phase | Nearest confirmed requirement | Judgement |
|---|---|---|---|
app.user_availability_rule, app.user_availability_exception |
2 | REQ-ITV-04 (no double-booking) | Not justified as scoped. REQ-ITV-04 needs only the interview_participant EXCLUDE constraint — recurring working hours, timezones and leave periods are a scheduling-assistant feature nobody confirmed. They also add a personal-class PII surface (03 §7.6 notes leave is inferable) for no confirmed requirement. Recommend cutting from Phase 2 and reintroducing as a PROP- if interviewers actually complain about being offered impossible slots. Two tables, ~12,000 rows, and a data-protection surface, for a nice-to-have. |
app.job_posting_metric |
4 | REQ-PUB-01, REQ-ANL-01 (cost dimension), REQ-ANL-09 | Justified — the condition is now met. Keep. At ~1.1M rows it was the largest unrequired table in the design, and REQ-ANL-09's volume dimension is served by raw_intake.source_channel_id without it. GAP-02's ruling settles it: Cost per Hire moves out of Phase 2 and into Phase 4 with this table named as its source, so the requirement and the table now land in the same phase and the table is required rather than speculative. 03 §11 must state that link explicitly — it is the whole justification. Scope honestly: this yields cost per hire from measurable channel spend, not from agency fees, referral bonuses or recruiter time. |
app.integration_webhook_event |
1 | REQ-INT-02 (Graph subscription lifecycle) | Justified, built one phase early. Its three stated consumers — delivery receipts (Phase 2), posting state changes (Phase 4), assessment completions (Phase 3) — none exist in Phase 1. The Graph subscription-renewal path (04 §2.7) does, and that alone warrants the table. Keep; state the single Phase 1 consumer in 03 §12.3 so nobody builds Phase 2 handlers early to justify it. |
app.approval_request, app.approval_decision |
1 | REQ-JOB-07, REQ-OFR-02, REQ-OFR-03 | Justified, and the phase is now correct. Two requirements need approval and 03 §30.1 correctly consolidates them into one engine rather than two shapes. The phase disagreement with REQ-JOB-07 (GAP-01) is resolved: these two tables are Phase 1 in their single-approver form (07 T-17b), with the offer subject arriving in Phase 3 via the same typed nullable subject column. 03 §10 already flags the "is this a workflow engine?" tension honestly; the answer — typed nullable subject columns, real FKs, no generic engine — is the right one. |
app.approval_route, app.approval_route_step |
2 | REQ-JOB-07 (multi-step), REQ-OFR-02, REQ-OFR-03 | Justified as Phase 2. Phase 1 needs one approver, not a routing engine: T-17b hardcodes the resolution rule and stores the resolved approver on the request, so migration 017b can introduce routes and backfill without rewriting Phase 1 decisions. Deferring the route tables is what makes the Phase 1 gate small enough to actually land. |
app.saved_search |
2 | REQ-SRC-01 (weakly), REQ-NFR-09 | Justified on two grounds, neither of which is the obvious one. Not because search needs saving, but because the prototype has four saved searches (js/data.js:410-415) and REQ-NFR-09 makes the 23-route IA the screen backlog; and because 03 §26.1 makes it a compliance surface — a saved filter can encode a discriminatory query, so they are readable by compliance. 1,000 rows. Keep. |
app.saved_report, app.report_run |
2 | REQ-ANL-03 (Phase 3) | Justified, phase-early by one phase. Migration 020 is Phase 2; the requirement is Phase 3. Harmless (the tables are empty until used) but it makes 07's Phase 2 look larger than the requirements demand. Move to the Phase 3 migration or note why not. |
app.candidate_tag, ref.tag |
1 | REQ-CAN-02 (filter/sort), REQ-NFR-09 | Justified. No requirement names tags, but the prototype has them, REQ-CAN-02 requires filtering, and ~100 tag rows plus a join table is the cheapest thing in the schema. Also the target of the GAP-06 bulk-tag endpoint. |
app.candidate_embedding |
2 | REQ-SRC-02 | Correctly deferred, correctly placed. DEF-14 gives a specific entry condition (Phase 1 FTS plus trigram measured and found insufficient for a named query class), and ADR-0006 chose pgvector in the same database precisely so candidate erasure does not have to reach into a second system (REQ-DAT-03). Keep as a Phase 2 migration; do not enable it early to look modern. |
app.fx_rate |
3 | REQ-MON-02 (Phase 3) | Justified, and the phase is now correct. Was listed Phase 1 by an unsplit 03 §5 row; the row is now split (ref.currency Phase 1, app.fx_rate Phase 3) and GAP-20 is closed. |
app.candidate_access_token |
1 (migration 003) | REQ-SEC-07 (Phase 3) | Justified, deliberately early. Five other tables carry candidate_access_token_id FKs (assessment_assignment, offer_response, and the status-page/upload/interview-confirm paths), so creating it with identity in migration 003 avoids three later ALTERs. Phasing note, not over-engineering. |
ai.query_intent |
2 | REQ-GOV-09, REQ-SRC-02 | Justified, and load-bearing. ~40 rows that are the entire Phase 1–2 chatbot capability surface. ADR-0010 and 00 §7 both reject text-to-SQL and a chatbot service account; this table is the mechanism that makes the rejection structural. |
audit.audit_event_redaction |
1 | REQ-DAT-06 vs REQ-HIS-04 | Justified — it is the only honest reconciliation. REQ-HIS-04 says the application has no update or delete path into the audit log; REQ-DAT-06 says identifying data must be erasable. One of the two has to bend, and a two-person-rule, append-only, reasoned exception path is the correct bend. ~200 rows. |
app.pipeline_transition_rule |
1 | REQ-JOB-08 (weakly), DEF-01 (Phase 3) | Thin at Phase 1. With one default pipeline config, "which moves are legal" is a constant. The versioning of pipeline_config is justified (03 §30.2 — a config edit must not retro-change which moves were legal, which is §5.3 reasoning), but the rule table itself has no Phase 1 requirement. Keep the versioned config; consider deferring pipeline_transition_rule to Phase 3 with DEF-01. |
app.message_thread |
2 | REQ-INT-01, REQ-INT-06 | Justified. Without it a candidate's reply lands as an unrelated raw_intake row and the recruiter re-triages a conversation they are already having (04 §2.8). It is what makes per-source attribution survive a reply. |
app.candidate_job_match |
1 | REQ-AIC-02, REQ-CAN-10 | Justified, and a good consolidation. 03 §30.1 folds the pool-rematch result table into it because the question is identical and both answers must be pinned and explainable the same way — one explainability implementation instead of two. |
app.v_activity_feed, app.v_candidate_live, app.v_job_live, app.v_job_application_live, app.v_user_effective_scope (views) |
1–2 | REQ-HIS-03, REQ-DUP-08, REQ-SEC-03 | Justified. 03 §30.3 explicitly rejects an activity_event table in favour of a 30-day whitelisted view because a table would duplicate and drift from audit.audit_event. v_user_effective_scope is the single scope resolution REQ-SEC-03 demands. Views own no state and cost nothing. |
6.3 The one thing this check found that matters
Of sixteen candidates, one is genuinely unrequired (user_availability_rule /
_exception), one became required when GAP-02 was ruled (job_posting_metric — Cost per Hire
now lands in Phase 4 with this table named as its source, so the conditional verdict resolved to
keep), one is thin (pipeline_transition_rule), and the remaining thirteen
are justified — several of them by reasoning the owning document already wrote down. Two tables
out of 142 is a low over-engineering rate for a design of this size, and it is low for a specific
reason worth stating: 03 §30 and 02 §4.6 already ran this check at the design stage and
recorded twenty-one deliberate non-creations. The discipline that produced those two sections is
what kept this section short.
7. Cross-document consistency findings
Where cross-document contradictions are settled:
_open-items.md. That register is the single arbitration layer for the whole package. It deduplicates the ~50 instances raised across00§10,02§15,03§32.1,04§9.1,05§9.1,06§9.1 and this section into ~32 distinct questions, and it ends each one in a ruling (RULING-01…RULING-09), a named non-engineering owner plus a blocking gate (OPEN-01…OPEN-12), or a closure (C-01…C-11)._open-items.md§4 is the lookup table: any row of any reconciliation section maps to one register id. §5 lists the gates in the order they bite. Do not resolve a contradiction locally in a document again — record the evidence where it arose and take the ruling from the register.The nine per-document instances this section previously re-litigated now resolve as: row 1 → OPEN-01, row 4 → GAP-20 (closed), row 8 → C-11, and rows 2/3/5/6/7 stay here because each is a single-document phase or numbering correction with a named owner rather than an arbitration.
Recorded here because building the matrix is what surfaced them. These are additional to the
five inconsistencies 00 §10 recorded inside _decisions.md, and to the per-document
reconciliation sections in 03 §32, 04 §9.1, 05 §9.1 and 06 §9.1 — all of which now point at
_open-items.md for the ruling.
| # | Finding | Documents | Disposition |
|---|---|---|---|
| 1 | Requisition approval phase stated three ways (1 / 2 / 3) | 00 §2.4, 03 §5, 07 §4.2 |
GAP-01 — closed. The three documents now agree: single-approver in Phase 1, multi-step in Phase 2. 07 gains T-17b, A-16b and §4.5 criterion 13; 07 §4.2 excludes only multi-step chains; 03 §5 reads 1–2; migration 017 is split into 017a (Phase 1) and 017b (Phase 2) — 017a depends only on 006, so nothing reorders. Residual OPEN-01 is narrowed to OBD-12's business question (who approves), which changes one resolution rule rather than any phase or table. |
| 2 | job_posting phase stated two ways (1 / 4) |
03 §5, 07 §4.1 |
GAP-25 — closed. 03 splits app.job_posting into Phase 1 migration 006a and leaves job_posting_metric plus external-platform state in 027; the 03 §5 table-map row and 03 §11 both state the split. The row's citation of "migration 013" is corrected to 011 (the FK's owner) and 027 (the former target). |
| 3 | app.setting needed in Phase 1, delivered in Phase 2 |
00 §2.15, 03 §33 |
GAP-05 — closed. app.setting moves to Phase 1 migration 012, seeded with the score-visibility keys; 020 is renamed 020_worklist.sql. Registered in the Django admin, which 00 PROP-06 already makes the Phase 1 administration surface, so no UI work is added. |
| 4 | Currency and FX phased as one row when the two halves differ | 03 §5, 00 §2.21 |
GAP-20 — closed. The row is split in 03 §5; see §6 above. |
| 5 | Assignment §5 principle count stated two ways (§5.1–§5.6 / §5.6, §5.7) | 00 §1, 05 §5 |
GAP-26. Correct 00 §1. |
| 6 | talent_pool named as a Phase 1 module for REQ-AIC-02 while the module and its tables are Phase 3 |
00 §2.8, 02 §4.2, 03 §5 |
GAP-15. Split the phase cell as REQ-AIC-10 already is. |
| 7 | A versioned relevance config is asserted but never given a table | 03 §29.4, 00 §2.12 |
GAP-04 — closed. app.relevance_config and app.relevance_config_version are defined in 03 §29.4, listed in 03 §5 and created in migration 014; weights are four numeric(6,4) columns with a deferred sum-to-1.0 trigger, and candidate_job_match pins relevance_config_version_id. |
| 8 | 00 §2.14 assigns the worklist module to REQ-WRK-01/02, but the group was named in neither 06 §2 (25 groups, no tasks group) nor 06 §4 (which listed four omitted surfaces and not this one) — its five endpoints existed only inside 06 §7 row 23, a prototype-route mapping table. 02 §4.4 module 25 compounded it by declaring GET /api/v1/tasks, a path no other document uses |
00 §2.14, 06 §2 / §4 / §7, 02 §4.4 |
Resolved — C-11. 06 §4.5 now defines the worklist group (five endpoints, permission mapping to 05 §2.9 row 25, contract note); 06 §7 row 23 is a cross-reference; 02 §4.4 module 25 reads GET /api/v1/worklist/tasks. The endpoints were never in analytics — 06 §2.24's eighteen endpoints contain no task endpoint, so an earlier reading of this finding that filed them there was wrong. |
| 9 | The nine additive objects 05 §9.2 declares were in no 03 table, no 03 §5 map row, no 03 §32.1 reconciliation point and none of the 29 migrations. access_grant and ref.region appeared only in 05 and adr/0009; match_state only in 05, this document and adr/0011; source_service, injection_signal and candidate_erasure_request only in 05 |
05 §9.2, 03 §5 / §7.3 / §32.1 / §33, adr/0009 §"Consequences", adr/0011 §4–§5, this document §2.7 / §2.8 / §2.15 |
GAP-27 — was the only High finding in this table; now RESOLVED. 03 adopted all nine per migration (002, 004, 010, 011, 012, 025a, plus the roles in 001/021a/023/027a), 03 §32.1 carries reconciliation row 5, 03 §5 records the four new tables at a derived 159, and the annotations in §2.7 / §2.8 / §2.15 / §3.2 above are out — those cells name real entities. adr/0009 no longer rests on undefined schema. Two exceptions, both deliberate and both stated at their tables in 03: the role_assignment scope columns are withdrawn by 05 §9.2 itself, and 'region' as a grantable scope_type is OPEN-05. |
| 10 | Outbound email delivery phased four different ways, and this matrix did not flag it. 07 §5.1 and 03 §5 / §33 migration 018 put the whole of Communications at Phase 2; 00 DEF-07 said 2–3; 00 OBD-21 said in-app Phase 1–2 with email at Phase 3; 04 §9.1 row 5 argued a minimal outbound_message slice must be pulled into Phase 1 because "every parse failure is a dead end a recruiter must resolve in Outlook by hand, outside the audit trail". Same class as findings 1 and 2 and it appeared in neither §5 nor §7. | 00 §4 DEF-07 / §5 OBD-21, 03 §5 / §22 / §33, 04 §9.1 row 5, 07 §4.1 / §4.2 / §5.1, 02 §4.1 module 5, this document §2.14 | Resolved, not open — no GAP row. Ruled: Phase 1 takes 04's minimal slice (the app.outbound_message row, Mail.Send through the same MailProvider port as inbound, the send idempotency guard, NDR classification, one seeded transactional template — 4–6 dev-days, now 07 T-16b); Phase 2 takes 07/03's delivery pipeline (templates UI, retry, bounce and complaint handling, digests, notification_preference, notification centre — 07 T-29 / A-31 / A-36). 04 wins on Phase 1 because an NDR with no outbound_message row to attach to cannot be classified at all, which makes this a Phase 1 correctness hole rather than a deferred feature; 07/03 win on Phase 2 because the pipeline's costly parts (sender domain, template approval, bounce policy) are separable from one Mail.Send call. OBD-21's Phase 3 answer is superseded and its scope narrowed to internal notification email, which stays Phase 2. Amended in all six places listed. |
Finding 9 is the one exception to the sentence below: an Accepted ADR resting on schema that does not exist is a design error, not a bookkeeping one, and it is the only finding here that can silently ship as a missing control rather than as a visible inconsistency.
Findings 1–8 and 10 are not design errors. They are the predictable cost of eight documents written against one decision file, and all nine are cheap to fix now and expensive to discover during the Phase 1 migration sequence. Finding 10 carries no GAP row because it was ruled rather than left open; findings 1–8 keep theirs because they still need someone's decision. Finding 8 is already closed, which is the shape the other seven should end in: the group is named in one place, every other document points at it, and no endpoint path exists in two spellings.
8. Assumptions made by this document
Every item is an assumption, labelled as such, and each states how it would be falsified.
| # | Assumption | How it would be falsified |
|---|---|---|
| T-ASM-01 | The assignment's §5 contains exactly seven numbered principles, with §5.7 being the AI-governance principle, inferred from 05 §5's heading. |
Reading assignment §5 directly. This is the weakest assumption here and the reason §3.1 exists rather than a silent renumbering. |
| T-ASM-02 | "Confirmed requirement" means exactly the 158 REQ- rows in 00 §2 — no more, no fewer. PROP-, DEF- and OBD- items are not requirements and are not counted in the coverage totals. |
A stakeholder treating a PROP- item as confirmed scope, which would raise the denominator and change every percentage in §4. |
| T-ASM-03 | A requirement's Phase is the phase in which it is first satisfied, not the phase in which it is fully complete for every sub-case. REQ-INT-02 and REQ-AIC-10 are counted at their first phase. | A stakeholder reading "66% by Phase 1" as "66% of the platform by Phase 1". §4.1 states the distinction explicitly for exactly this reason. |
| T-ASM-04 | The acceptance criteria in 07 §3.5–§7 are the complete acceptance set. Where a requirement has no criterion there, it has none anywhere. |
A separate test plan existing that 07 does not reference. None exists in the repository (findings §B — no test file, runner or CI configuration of any kind). |
| T-ASM-05 | Withdrawn as an assumption and replaced by a derivation. The old form — "the 144-table figure and the per-area counts in 03 §5 are complete, so the reverse check in §6 is exhaustive" — was not safe to assume: §5's headline read 144 while its own 25 area rows summed to 147, so the premise was internally false and the reverse check's exhaustiveness rested on it. §6 now derives the figure two independent ways (§5's areas against the section bodies §§6–28, and the 29 migrations in §33) and reconciles them at 142 for the baseline it checked, and it lists the seven 03 §5 cells that do not survive the recount. What remains assumed is narrower: that a table documented in a 03 section body is also created by some migration in §33, and vice versa — checked in both directions and reconciled table by table. |
A table in a section body that no migration creates, or a migration creating a table no section documents. Also: the total is a floor and it has moved. GAP-04's two tables and GAP-27's four are now adopted, and 03 §5's headline reads 159 against a column sum of 159 — the arithmetic defect did not survive the additions, and §5 now states outright that its headline is derived from the column. Re-derive from §33 whenever §5 changes rather than inheriting either number. The per-area corrections in §6 are independent of the additions and hold regardless. |