HR-ATS-Portal/docs/architecture/08-requirements-traceabilit...

714 lines
117 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 08 — Requirements Traceability
## Status / Scope of this document
This is the closure check on the package. Documents 0007 and ADRs 00010012 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.
```mermaid
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 14 (`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 01; 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 85119 dev-days
against Phase 2's 6085 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 one `REQ-`, and every `REQ-` cited by a
`PROP-` appears in §2 above. No orphan `PROP-`.
- **21 `DEF-` items** (`00` §4): 18 map to a deferred `REQ-`. 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 `GRANT`s (§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 12, 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 12, 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` §§628 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 §§628, 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 `ALTER`s. 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 12 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) | 12 | 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 across `00` §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 12; 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 23; `00` OBD-21 said in-app Phase 12 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 — 46 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 18 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 18 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 §§628, 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. |