HR-ATS-Portal/docs/architecture/_open-items.md

334 lines
34 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.

# Open Items Register — the single place a contradiction is settled
> **This file is the arbitration layer for the whole package.** Before this file existed, the same
> cross-document contradictions were re-litigated independently in eight reconciliation sections
> (`00` §10, `02` §15, `03` §32.1, `04` §9.1, `05` §9.1, `06` §9.1, `08` §5, `08` §7) — roughly
> fifty raised instances of about thirty distinct disagreements, each ending in a *recommendation*.
> An engineer could not implement from that. **Every row below ends in a ruling or a named owner
> plus a blocking gate.** Nothing here is a recommendation.
>
> **Precedence.** `_decisions.md` §Rulings and this file are binding and rank equally — the
> numbered `RULING-` series is continuous across both. Where a ruling and any document body text
> disagree, the ruling wins and the document is wrong. The per-document reconciliation sections
> are retained as **evidence and reasoning**, not as decisions; each now points here.
Two id series:
| Series | Meaning | Who can change it |
|---|---|---|
| `RULING-nn` | **Decided.** Binding package-wide. Implement it; do not re-open it in a document. | An ADR that supersedes it |
| `OPEN-nn` | **Not decided, and cannot be decided by an engineer** — it needs a business, legal or IT answer. Carries a named owner and the artefact it blocks. | The named owner |
---
## 1. Rulings
### RULING-01 — actor vocabulary (already binding; restated for completeness)
Stated in full in `_decisions.md` §Rulings. Summary: the column is `actor_kind`; its value set is
exactly `('user','system','integration','ai_agent')`; there is **no** `human` value and no
`actor_type` column; the "AI must never auto-reject" guard is written character for character
`actor_kind = 'user'`.
**Status: CLOSED and already propagated.** `02` §1/§4.4, `03` §18.1/§28.1, `04` §1, `06` §9.1 #2
and `adr/0011` all carry the ruling, and `06` §9.1 #2's earlier recommendation to standardise on
`human` is explicitly withdrawn at source. It is listed here only so a reader who finds
`actor_type`/`human` in a stale draft knows which way the ruling went.
**Gate:** already passed (migration 006 onward). **Test:** A-26 in `07` §11 asserts the literal string.
---
### RULING-02 — migration tooling: plain SQL is the schema authority, Django is the runner
The most-repeated contradiction in the package. Raised six times: `00` §10 #1, `02` §15 I1,
`03` §32.1 #1, `04` §9.1 #2, `05` §9.1 I-1, `06` §9.1 #4. Every instance recommended some form of
hybrid and none of them decided it.
**Ruling.**
| Aspect | Decision |
|---|---|
| Schema authority | Hand-written SQL. The DDL for every object lives in `db/migrations/NNN_*.sql` exactly as documented in `03` §33. |
| Ledger and runner | Django's `migrations` app. Each numbered SQL file is wrapped by one Django migration containing a single `RunSQL(sql_file.read_text(), reverse_sql=RunSQL.noop)`. `python manage.py migrate` is the only command anyone runs, and there is **one** ledger (`django_migrations`), not two. |
| Models | Hand-written, `managed = False` on every model whose table is created by SQL — which is all of them. Models are read/write mappers over a schema they do not own. |
| The CI gate | **`makemigrations --check` is replaced, not kept.** It presumes autogenerate and would fail permanently against `managed = False`. The gate is a **schema-drift check**: apply all migrations to a scratch database, introspect it with `inspectdb`, and assert (a) every model field maps to a real column of the right type, and (b) no model references a column that does not exist. Drift fails the build. |
| Why not Django-owned structure with `RunSQL` only for invariants | Because the invariant set is not a garnish on the schema — it *is* the schema. Partial and expression unique indexes, `CHECK` regexes, `DEFERRABLE` constraint triggers, GiST `EXCLUDE`, generated columns, `RANGE` partitions and column-level `GRANT`s (`03` §4.8, §28.1; `05` §5.3) carry the requirements. A schema half-owned by two tools means every invariant-bearing table is described twice and drifts, and the failure mode is a missing constraint in production. One owner, and it is the one that can express the constraints. |
| Cost accepted | Two developers lose `makemigrations` convenience and gain a reviewable SQL diff. Talha reviews every migration (`03` §33) — the diff being SQL is what makes that review meaningful. |
**Documents to amend.** `_decisions.md` §"Backend language and framework" (drop "migrations are
built in" as a *justification* for Django — the admin, the permission framework and the Python
parsing ecosystem carry the choice on their own) and §"Testing, CI" (replace the
`makemigrations --check` gate with the drift check). `00` §10 #1, `02` §15 I1 (§12.4 already
states this position — align the wording), `03` §32.1 #1, `04` §9.1 #2, `05` §9.1 I-1,
`06` §9.1 #4: replace the recommendation with a pointer to RULING-02.
**ADR required — written. `adr/0017-plain-sql-migrations-as-schema-authority.md`.** One ADR
recording this, because it changes daily developer workflow. An earlier revision of this ruling
directed that it be renumbered to `0013` on the basis that the package held `0001``0012`; that
basis no longer holds — the package holds `0001``0018`, `0013` is the frontend strangler migration,
and the file was written at **`0017`**. The number in `02` §15 and §12.4 was correct as written and
was not changed. The index in `02` §13 is authoritative for every ADR number in this package.
**Gate: before migration `001`.** Nothing else in Phase 1 can start.
---
### RULING-03 — entity naming: Part 2's table names are the database vocabulary, Part 1's are the module vocabulary
Raised five times: `00` §10 #5, `02` §15 I4, `03` §32.1 (the "smaller alignments" paragraph),
`04` §9.1 #3, `05` §9.1 I-4, plus `06` §9.1 #5/#6/#11 as per-area instances.
**Ruling.** Part 2's snake_case table names are authoritative and are the only names that appear
in SQL, migrations, ERDs, index names, constraint names and API payload field names. Part 1's
CamelCase names are the Python package/module and service-facade vocabulary and appear only in
module names, class names and service-method signatures. Neither is "the" name for the aggregate;
each is the name in its own layer, and the mapping is now published rather than inferred.
**The glossary is published as a standalone document: `_glossary.md`.** `03` §32.1 already drafted
the mapping inline, where an API or security author reading `05` or `06` would never find it. It is
now its own file, extended to cover every divergence the eight reconciliation sections named, and
it is the artefact a reviewer checks a name against.
**Documents to amend.** `00` §10 #5, `02` §15 I4, `04` §9.1 #3, `05` §9.1 I-4: replace "a glossary
should be published" with a pointer to `_glossary.md`. `03` §32.1's inline mapping stays as the
derivation but must cite `_glossary.md` as the published form. `_decisions.md` Part 1 module tables
keep their CamelCase entity names — they are correct in their layer — but the four *substantive*
Part 1 shapes that Part 2 rejects outright are corrected at source by RULING-04, RULING-05,
RULING-08 and OPEN-05, not renamed.
**Gate: before Phase 1 application code.** Not before migration 001 — the SQL is unambiguous
already. The names that leak into code are the ones this blocks.
---
### RULING-04 — recruiter assignment: two concrete tables, never polymorphic
Raised three times: `00` §10 #2 ("Part 2 wins"), `02` §15 I7 (already corrected at source),
`06` §9.1 #3. Also `03` §16 and §30.3 and `08` §2.6.
**Ruling.** Part 2 wins, per `00` §10 row 2. Two concrete tables — `app.job_assignment` and
`app.job_application_assignment` — each with interval columns, a GiST `EXCLUDE` overlap
constraint, and a partial unique index on the current holder of any `ref.assignment_role` where
`is_exclusive`. There is **no** polymorphic `Assignment(subject_type, subject_id, …)` table and no
`POST /assignments` endpoint that takes a `subject_type` discriminator. One `assignment` service
facade and one permission surface present both tables, so the module inventory is unaffected at 25.
**Reason the polymorphic shape loses:** a polymorphic subject FK cannot be enforced by the database
at all, and unenforceable references to jobs and applications are the specific failure this whole
design exists to eliminate (`_repo-findings.md` §F).
**Documents to amend.** `_decisions.md` Part 1 module 12 — the stale polymorphic definition is the
source that leaked into three documents; correct it there. `06` §9.1 #3 and `00` §10 #2 become
pointers.
**Gate: migration `006`** (`job_assignment`) and **`011`** (`job_application_assignment`).
**Status: implemented.** `02` §4.2 module 12, `03` §16 and `06` §2.16 already carry the two-table
shape.
---
### RULING-05 — `stored_file` is the single content-addressed registry; domain rows hold an FK plus per-occurrence data
Raised three times: `03` §32.1 #2 (the additive reading), `04` §9.1 #4 (proposes
`files.stored_object`), `06` §9.1 #10 ("the underlying table is unresolved. **Needs a ruling**").
**Ruling.** `03` §8.1's additive reading is authoritative, and the table is `app.stored_file`
(migration `005`) — not `files.stored_object`; `04` §9.1 #4 describes the same table under a
different name and is superseded, not overruled.
| Concern | Where it lives | Rule |
|---|---|---|
| Content identity, blob location, size, MIME | `app.stored_file` (`sha256`, `storage_key`, `byte_size`, `mime_type`) | One row per distinct content. Five candidates sending the same file share one row. |
| **Malware scan status** | `app.stored_file.scan_status`**and nowhere else** | This is the load-bearing half of the ruling. A scan status in two places is exactly how an unscanned file becomes viewable. No domain row carries a `virus_scan_status` column; `03` §12.3 and §14.1 are already correct on this and must stay correct. |
| Retention class | `app.stored_file.retention_class` | One place for the purge to look — Part 1's stated reason for a separate `files` module. |
| Per-occurrence data | `app.raw_intake_attachment`, `app.candidate_document` | `filename_raw` / `original_filename`, `attachment_index`, `is_probable_cv`, `extraction_state`, `revision`, `layout_metadata`. |
| The duplicated `sha256` on domain rows | Denormalised domain data | Legitimate: it backs the identical-file duplicate-detection index (`03` §12.3) without a join. It is **not** a second source of truth, and a mismatch between it and `stored_file.sha256` is a bug, not a state. |
One consequence worth stating because `06` §9.1 #10 asked it directly: **yes, one `stored_file` row
can be referenced by two subjects**, and that is the point — it is why erasure, virus scanning and
content dedupe each have exactly one place to happen.
**Documents to amend.** `04` §9.1 #4 — replace the `files.stored_object` proposal with a pointer
here and drop the "Data-model document to confirm". `06` §9.1 #10 — resolved; the resource shape
`06` chose (`/documents/{id}` with `subject_type`/`subject_id`) is correct under this ruling.
`_decisions.md` §"Cross-cutting persistence patterns" / `files` module: state that domain rows
never carry a scan status.
**Gate: migration `005`.**
---
### RULING-06 — one aggregate, one resource tree: `/jobs`. `/requisitions` does not exist
Raised in `06` §9.1 #1, which explicitly flags it as needing a ruling because assignment §26.7
lists "requisitions" and "jobs" as *separate* API groups. Related to RULING-03 but distinct: this
one is about URL space, not vocabulary.
**Ruling.** One row, one canonical resource tree, `/api/v1/jobs`. `/requisitions` is not a
resource and will not be minted. Assignment §26.7's two entries are preserved as **two OpenAPI
tags over one resource tree**, which is what the assignment was asking for and what `06` §2.6/§2.7
already builds:
| OpenAPI tag | Covers | Paths |
|---|---|---|
| `requisitions` | The authoring, versioning and approval **workflow** | `/jobs/{id}/versions`, `/jobs/{id}/requirements`, `/jobs/{id}/approvals`, `/jobs/{id}/scoring-config` |
| `jobs` | The job record, its lifecycle, its pipeline and its assignments | `/jobs`, `/jobs/{id}`, `/jobs/{id}/status`, `/jobs/{id}/pipeline-config`, `/jobs/{id}/assignments` |
**Reason two resources lose:** Part 2's uniqueness rules key on `job_id`
(`uq_application_live (candidate_id, job_id)`, `03` §15.1). Two resource paths over one row means
two `PATCH` surfaces onto one uniqueness domain and two sets of permission checks, and the second
one will be the one that is wrong.
**Documents to amend.** `06` §9.1 #1 — replace "**Needs a ruling**" with this ruling and add the
tag table to `06` §2.6. `08` §1.2 already applies the vocabulary split correctly and needs no
change.
**Gate: before Phase 1 application code** (the OpenAPI schema is generated from the views).
**Status: implemented in `06`; this ruling makes it a decision rather than a pick.**
---
### RULING-07 — `pgvector` installed at Phase 1 provisioning, used at Phase 2; PostgreSQL major pinned at 16
Raised twice: `00` §10 #4, `02` §15 I2. Both read the two positions as compatible without deciding
the provisioning question they actually disagree about.
**Ruling.** The extension is `CREATE EXTENSION`-ed at Phase 1 provisioning — it is free, and
installing an extension later is privileged DDL against a live database. **No Phase 1 feature
reads it.** `app.candidate_embedding` and its HNSW index land in migration `028` at Phase 2, gated
on DEF-14's stated entry condition (Phase 1 FTS plus trigram measured and found insufficient for a
named query class), not on enthusiasm. The provisioning runbook must say in one sentence that the
extension being present is not a signal that semantic search is available.
**PostgreSQL major is pinned at 16 for Phase 1.** `app.uuidv7()` comes from the migration-`001`
shim (`03` §33). PG18's native `uuidv7()` is adopted only through a deliberate version-upgrade
migration that swaps the shim, never by an environment drifting forward — because a UUIDv7 whose
generator changed mid-life breaks the time-ordering the id strategy relies on (`03` §3).
**Documents to amend.** `00` §10 #4, `02` §15 I2 → pointers. `_decisions.md` §"Deployment topology"
(provision the extension, do not imply Phase 1 use) and §"Database engine" (pin 16; drop
"16+ target 17" for Phase 1).
**Gate: provisioning and migration `001`.**
---
### RULING-08 — merge reversal has no time window
Raised in `00` §10 #3.
**Ruling.** Part 2 wins. There is no `reversible_until` column and no fixed reversal window — a
window would be an arbitrary number that turns a correctness property into a deadline. Reversal is
blocked by exactly two conditions, both real and both surfaced as `reversal_blocked_reason`:
(a) stack discipline — a later merge on either candidate must be reversed first (`03` §19.5), and
(b) a retention purge that has already pseudonymised a re-parented row (risk R6 in `03` §32.2).
**Documents to amend.** `_decisions.md` Part 1 module 8 — drop `reversible_until` from the
`MergeOperation` entity. `00` §10 #3 → pointer.
**Gate: migration `013`. Status: implemented** (`03` §19.5, `06` §2.13, ADR-0008).
---
### RULING-09 — one approval engine serves job versions and offer versions
Raised in `03` §32.1 #4, which correctly labels it a judgement call rather than compliance.
**Ruling.** One engine. `app.approval_route`, `app.approval_route_step`, `app.approval_request`
and `app.approval_decision` serve both `job_version` and `offer_version` through typed nullable
subject columns with **real** FKs and a `num_nonnulls = 1` CHECK — the same idiom as
`candidate_access_token` (`03` §7.6). This is not the rejected generic `workflow_engine`: it drives
no state machine, it records route-driven sign-off, and `03` §10 already argues the distinction.
The escape hatch stays documented and stays cheap: if a reviewer disagrees, duplicating the two
request/decision tables as `job_version_approval*` and `offer_approval*` is a copy-paste **before**
migration `017` and a data migration after it.
**Gate: migration `017`.** `03` §32.1 says "decide at `006`" — that is the right moment to *know*,
because `006` adds `job_version.approval_request_id`, but the tables themselves land in `017`.
---
## 2. Open items — decisions an engineer cannot make
Each carries an owner who is not a developer and the artefact it blocks. These are the honest
residue: nothing here is unresolved through neglect.
| # | Item | Raised in | What must be decided | Owner | Blocks |
|---|---|---|---|---|---|
| **OPEN-01** | **Requisition approval phase — was stated three ways (1 / 2 / 3); engineering half now applied.** The split is in: single-approver approval in Phase 1 (`approval_request` + `approval_decision`; `03` §33 splits migration `017` into `017a` Phase 1 and `017b` Phase 2), multi-step and parallel routes in Phase 2. `03` §5's Approval workflow row and `07` T-17b / A-16b / §4.5 criterion 13 carry it. **Residual and genuinely non-engineering:** *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. | `08` GAP-01 (now **CLOSED**, residual Low), `08` §7 #1; `00` §2.4 (P1) vs `03` §5 (P2) vs `07` §4.2 (P3) | Who the Phase 1 approver is — and, prior to that, whether Phase 1 requisitions require approval at all | Talent Lead (OBD-12) | The T-17b approver-resolution rule only. No longer blocks the migration order. |
| **OPEN-02** | **Cost per Hire has no data source at Phase 2.** Ruled in `08` GAP-02 as option (a) — dropped from the Phase 2 KPI set, reinstated at Phase 4. Listed here because Finance can flip it to option (b) and the table shape is ready. | `08` GAP-02, `08` §6.2 | Whether Finance and Talent Ops will actually maintain manual cost entry. If yes, `app.hiring_cost` ships in Phase 2 and the tile stays. If no or unanswered, (a) stands. | Finance + Talent Lead | The Phase 2 KPI set, `07` §5.1, the D2 script, and the keep/cut verdict on `app.job_posting_metric` |
| **OPEN-03** | **Model hosting: contracted API under a DPA, or self-hosted.** Already `_decisions.md`'s top-listed risk. | `04` §9.2 Q3, BRD OQ-1, REQ-DAT-01/02 | Whether AI pipeline steps may call an external provider at all | Legal + business | `ai` queue sizing, GAP-11, GAP-12, the whole `04` §4 pipeline |
| **OPEN-04** | **Cloud platform, and therefore object storage.** `_decisions.md` recommends Azure Blob; the assignment asks for an S3-compatible design. `04` §6.2 resolves the *contract* with three adapters, but the production target is unconfirmed and Azure Blob is not S3-API-compatible. | `04` §9.1 #1, `04` §9.2 Q4 | Which cloud | Business + Talha | The storage adapter, the local emulator choice, key management, and the immutable audit archive mechanism. **Cheap now, expensive after the retention and lifecycle rules exist.** |
| **OPEN-05** | **Access-scope dimensions: what "brand" means, and whether `region` exists.** `_decisions.md` Part 1 says `scope: brand/department/requisition`; Part 2 has `business_unit` and `department` and no region concept. `05` §2.3 assumes eight dimensions including `region`; `06` §2.3 uses four. | `05` §9.1 I-3, `06` §9.1 #9 | (a) Confirm "brand" **is** `business_unit` and drop the alias — the likely answer, and the one consistent with the not-multi-tenant constraint. (b) Whether `region` becomes a **grantable** `scope_type`. Not whether the tables exist — `ref.region`, `ref.location.region_id` and `access_scope.region_id` are now built (`03` §6, §7.3; migrations `002` and `011`, per C-10), with the exclusive-arc branch and the `scope_key` `coalesce` entry in place so the three-edit collision hazard is gone in both worlds. If the answer is *no*, `ref.region` stays a reporting vocabulary and regional desks are granted several `location` rows; the cost is one unused nullable column and one unreachable CHECK branch. | Talent Lead + Talha | **One line:** `'region'` in `access_scope.scope_type`'s CHECK, plus the permission tests that would then exercise adr/0009's `region_ids` branch against non-empty input. **Answer before migration `011`** — that is where `access_scope` is created (`03` §33.1); it is no longer `003`. |
| **OPEN-06** | **Mail provider, and whether a dedicated careers mailbox exists.** | `04` §9.2 Q1Q2 | Provider, mailbox, and whether IT will grant application-scope Graph permissions with an `ApplicationAccessPolicy` | Utopia Brands IT | The entire `04` §2 design and the least-privilege claim. **Ask in Phase 0**`_decisions.md` flags it as a weeks-long Phase 1 critical-path risk. |
| **OPEN-07** | **Minimal `notifications` slice in Phase 1 — ruled; engineering half applied.** The split is in: **Phase 1** gets `outbound_message` written before the provider call, `Mail.Send` through the `MailProvider` port, the idempotency guard, NDR classification and one seeded transactional template (`07` T-16b, M, 46 dev-days, `07` §4.5 criterion 14); **Phase 2** keeps the pipeline — template UI, retry, complaint handling, digests, `notification_preference`, notification centre (`07` T-29 / A-31 / A-36). `03` §33 splits the schema into `011a` (Phase 1) and `018` (Phase 2), numbered below `017b` because a phase label cannot reorder an apply sequence (`03` §33.1). Recorded as `07` §17 divergence 6 and `08` §7 finding 10; `00` DEF-07 and OBD-21 amended (OBD-21's Phase 3 answer superseded and its scope narrowed to **internal** notification email, which stays Phase 2). **Residual and genuinely non-engineering:** IT must grant `Mail.Send` alongside `Mail.Read` in the same admin-consent request (`07` T-07) — this is now on the Phase 1 critical path, not Phase 2's, and it is the only part of OPEN-07 a developer cannot settle. | `04` §9.1 #5 | Nothing on the engineering side. Remaining: whether IT will consent to `Mail.Send` as an application permission scoped by `ApplicationAccessPolicy`, and who signs off the one seeded template's wording | Utopia Brands IT (consent) + Talent Lead (template wording) | T-16b's live path, and `07` §4.5 criterion 14 against a real mailbox. T-16b itself is not blocked — it is built and tested against `FakeMailProvider`. Without the slice at all, every parse failure is resolved by hand in Outlook, outside the audit trail. |
| **OPEN-08** | **Fairness evaluation needs data the PII decision forbids storing.** Resolved *structurally* in `05` §5.5 (Track A needs no protected data and gates activation from Phase 1; Track B is designed and blocked), so this is not a design defect — but Track B cannot start. | `05` §9.1 I-2, BL-4, BRD OQ-2 | Whether a protected-attribute evaluation dataset may exist at all, under what lawful basis, in a separate restricted schema | Legal + business (BL-4) | REQ-GOV-04/05 Track B. **The Phase 3 gate must not be described to the business as a disparate-impact review until Track B exists.** |
| **OPEN-09** | **AI latency ceiling.** OBD-03 recommends first token <2s, completion <8s. That is an assumption, not an agreed ceiling, and no acceptance criterion references it. | `08` GAP-10, REQ-NFR-07 | The agreed p95 ceiling | Talent Lead + AI Technology | The Phase 1 acceptance criterion over `ai.ai_model_invocation.latency_ms` |
| **OPEN-10** | **Audit retention: uniform or per-jurisdiction.** OBD-17 sets one uniform policy; REQ-GOV-07 implies derivation from posting jurisdiction, and no entity links an audit event to one. Recommended: apply the strictest applicable standard uniformly (OBD-04 already recommends this) and amend the requirement wording. | `08` GAP-13 | (a) uniform, or (b) reopen OBD-17 and build a `job_posting` jurisdiction retention-class join | Legal | REQ-GOV-07's wording and `audit.retention_policy` seeds |
| **OPEN-11** | **Skill taxonomy seed source and its licence.** `ref.skill` (~500) and `ref.skill_alias` (~2,000) currently cite only `js/data.js:46` `skillsPool`, which is synthetic. | `08` GAP-17 | A real taxonomy source and its licence; and whether Django admin is an acceptable write path for ~2,000 aliases | Talent Ops + Talha | Migration `002` seed data |
| **OPEN-12** | **The 27-day time-to-hire baseline may be a demo value.** `js/data.js:245` sets `timeToHire: 27` as a constant; ASM-15 already flags it. "Measurable against the baseline" is untestable without the baseline as data. | `08` GAP-03, ASM-15 | Confirm or replace the figure, and its provenance | Talent Ops | REQ-ANL-08's acceptance criterion. Store the confirmed figure as a dated `app.setting` row with a `source` note. |
---
## 3. Items closed on inspection — recorded so they are not raised a fourth time
These were raised as contradictions and are not. Each is closed; no gate, no owner.
| # | Raised as | Raised in | Why it is closed |
|---|---|---|---|
| C-01 | `CandidateProfileVersion` exists / does not exist | `03` §32.1 #3 | Replaced by `app.candidate_field_provenance`, which answers the actual question (per-field origin and confidence) without duplicating `intake_parse_attempt.parsed`. `_decisions.md` Part 1's `candidate` entity list should drop it a rename in one file, not a design question. |
| C-02 | Module count: 26 or 25 | `02` §15 I3 | 25 modules plus one cross-cutting API layer. `_decisions.md`'s "26 modules" prose is a stale count from before the consolidations; `02` §4 and §4.6 are consistent at 25. Cosmetic. |
| C-03 | Search-index ownership | `02` §15 I5, `03` §29.3 | No conflict. `candidate_search_index` is a `candidate`-owned table because a generated `tsvector` column cannot read child tables. Part 1's "Postgres FTS + trigram" is the mechanism; Part 2's table is the implementation of it. |
| C-04 | Part 1 state sets vs Part 2's | `06` §9.1 #5, #6 | Part 2's state sets win, and the renames are load-bearing, not cosmetic: Part 1's `failed` intake state is dropped because an intake whose parse failed is `needs_review` which is the entire purpose of the raw layer and `failed` becomes an `intake_parse_attempt` status. Part 1's `rejected` duplicate state becomes `confirmed_distinct`, which suppresses re-flagging forever, where `rejected` reads like a dismissed queue item. Recorded in `_glossary.md` §3. |
| C-05 | Score entity pin set | `06` §9.1 #7 | Part 2 wins and `06` §2.14 makes the full pin set a mandatory response field. Part 1's shorter `ApplicationScore` would not satisfy its own reproducibility requirement, so there is nothing to arbitrate. |
| C-06 | Pipeline config bound to job or job version | `06` §9.1 #8 | Bound to the **job**. Binding to a version would mint a fake job revision on every pipeline tweak the exact failure `03` §20.3 avoids for scoring configs by keeping `job_scoring_assignment` orthogonal. `_decisions.md` Part 1's `PipelineConfig(requisition_version_id)` should adopt the same orthogonality; one-line correction. |
| C-07 | Communications, assessment and talent-pool tables absent from Part 2 | `06` §9.1 #12, #13 | **A gap that has since been filled, not a conflict.** `03` §22 (7 tables), §24 (4) and §26.1 (3) now model all of them, including the pinned `template_version_id` on every sent message. Nothing to decide. |
| C-08 | Redis used for the HMAC replay nonce | `04` §9.1 #6 | No divergence. A nonce is a cache entry with a TTL, squarely inside the stated Redis use, and losing it widens the replay window to 300s rather than breaking correctness. |
| C-09 | RLS deferred to Phase 2 | `02` §15 I6 | **A risk, not a contradiction**, and already carried as R8 in `03` §32.2. Mitigation is concrete: one centralised authorization module, no repository access from views, and a test asserting every candidate-reading endpoint passes through `iam.can()`. |
| C-10 | Additive extensions introduced by `05` | `05` §9.1 I-5, I-6, I-7 and §9.2 | All additive, none contradictory: `audit_event.source_service`, append-only `ats_result_override`, `ats_result_criterion.match_state`, a `staff` subject flag on `pii_classification`, `access_grant`, and the three read-only database roles. They extend `_decisions.md` rather than disagreeing with it. **Closed 2026-07-29 — the schema half is applied, and all nine objects are now in `03`:** `ref.region` and `ref.location.region_id` (`03` §6, migration `002` `ref.region` created before `ref.location` in the same file), `app.access_grant` 7.7, `011`, with `interview_id` added by `019` and `offer_id` by `023`), `app.access_scope.region_id` with its exclusive-arc branch and its `scope_key` `coalesce` entry 7.3, `011`), `audit.audit_event.source_service` 28.1, `004`), the `pii_classification` staff flag as `audit.pii_classification.data_subject_kind` an enumerated column rather than a boolean 28.2, `004`), `app.intake_parse_attempt.injection_signal` + `injection_signal_codes` 18.1, `010`), `app.ats_result_criterion.match_state` NOT NULL with `ck_ats_criterion_match_state` 20.5, `012`), append-only `app.ats_result_override` 20.7, `012`), `app.candidate_erasure_request` 28.5, `025a`), and the three read-only roles provisioned `NOLOGIN` with zero privileges in `001`, their column-scoped grants and RLS in `021a` / `023` / `027a` 28.6). `03` §32.1 row 5 records the reconciliation; `03` §5 carries the four new tables and its map is derived at 159. **The first clause of this row was false and is withdrawn:** an earlier revision read "`03` already carries `match_state`", and it did not the column appeared **0 times** in `03`, which is exactly what `08` GAP-27 found by grep. The schema task it deferred was owned by **Talha** (`08` GAP-27) and is **done**; GAP-27 is closed in `08` §5. **Residue, and it is not this row's:** whether `region` becomes a *grantable* `scope_type` one line in `access_scope`'s `scope_type` CHECK is **OPEN-05**. And `05` §9.2's own withdrawal of the `role_assignment` scope columns for BU / department / region stands, so those are deliberately **not** built: `role_assignment.access_scope_id` already carries them (`03` §7.4). |
| C-11 | `tasks` has no API group | `08` §7 #8 | **Closed.** `worklist` is now a named group at `06` §4.5 alongside the other four surfaces assignment §26.7 omits, carrying the five task endpoints and the contract note; `06` §7 row 23 cross-references it; `02` §4.4 module 25 reads `GET /api/v1/worklist/tasks`; `08` §2.14 files REQ-WRK-01/02 under `worklist`. The earlier note that tasks were "served under the analytics area" was wrong `06` §2.24 has no task endpoint. |
---
## 4. Where each reconciliation section resolves to
The deduplication map. Any of the ~50 raised instances can be looked up here and followed to one row.
| Section | Row | Resolves to |
|---|---|---|
| `00` §10 | 1 · 2 · 3 · 4 · 5 | RULING-02 · RULING-04 · RULING-08 · RULING-07 · RULING-03 |
| `02` §15 | I1 · I2 · I3 · I4 · I5 · I6 · I7 | RULING-02 · RULING-07 · C-02 · RULING-03 · C-03 · C-09 · RULING-04 |
| `03` §32.1 | 1 · 2 · 3 · 4 · 5 · smaller (PG version) · smaller (naming) | RULING-02 · RULING-05 · C-01 · RULING-09 · C-10 (with the `region` half at OPEN-05) · RULING-07 · RULING-03 |
| `04` §9.1 | 1 · 2 · 3 · 4 · 5 · 6 | OPEN-04 · RULING-02 · RULING-03 · RULING-05 · OPEN-07 · C-08 |
| `04` §9.2 | Q1 · Q2 · Q3 · Q4 | OPEN-06 · OPEN-06 · OPEN-03 · OPEN-04 |
| `05` §9.1 | I-1 · I-2 · I-3 · I-4 · I-5 · I-6 · I-7 | RULING-02 · OPEN-08 · OPEN-05 · RULING-03 · C-10 · C-10 · C-10 |
| `06` §9.1 | 1 · 2 · 3 · 4 · 5 · 6 · 7 · 8 · 9 · 10 · 11 · 12 · 13 | RULING-06 · RULING-01 · RULING-04 · RULING-02 · C-04 · C-04 · C-05 · C-06 · OPEN-05 · RULING-05 · RULING-03 · C-07 · C-07 |
| `08` §5 | GAP-01 · GAP-02 · GAP-03 · GAP-10 · GAP-13 · GAP-17 | OPEN-01 · OPEN-02 · OPEN-12 · OPEN-09 · OPEN-10 · OPEN-11 |
| `08` §5 | GAP-04 · GAP-05 · GAP-15 · GAP-20 · GAP-25 · GAP-26 | Closed in `08` §5 itself each is a single-document correction with a named owner and a phase, not a cross-document arbitration |
| `08` §5 | remaining GAPs | Requirement-coverage gaps, correctly owned by `08` §5. Not arbitration items. |
| `08` §7 | 1 · 2 · 3 · 4 · 5 · 6 · 7 · 8 | OPEN-01 · GAP-25 · GAP-05 · GAP-20 · GAP-26 · GAP-15 · GAP-04 · C-11 |
**Count.** Roughly 50 raised instances resolve to **9 rulings, 12 open items and 11 closures**
32 distinct questions. Nine are now decided, twelve have a named non-engineering owner and a
blocking gate, and eleven were never really open.
---
## 5. Gates, in the order they bite
The practical reading order for anyone starting Phase 1.
| Gate | Must be settled first |
|---|---|
| **Before any Phase 1 code or migration** | RULING-02 (migration tooling and its ADR, `adr/0017-plain-sql-migrations-as-schema-authority.md`, still `Proposed`) |
| **Provisioning / migration `001`** | RULING-07 (PG major pinned at 16, `pgvector` installed not used), OPEN-04 (cloud, therefore storage) |
| **Migration `002`** | OPEN-11 (skill taxonomy seed source and licence) |
| **Migration `005`** | RULING-05 (`stored_file` registry; scan status in one place) |
| **Migration `006`** | RULING-04 (two assignment tables), RULING-09 (one approval engine known at `006`, built at `017`), OPEN-01 (requisition approval phase, which decides whether `017` is Phase 1) |
| **Migration `011`** | OPEN-05 (what "brand" means; whether `region` is a grantable `scope_type`) `access_scope` is created in `011`, not `003` (`03` §33.1), so this gate bites later than earlier revisions of this table said |
| **Migration `013`** | RULING-08 (no reversal window) |
| **Before Phase 1 application code** | RULING-03 (`_glossary.md` published and used), RULING-06 (`/jobs` only) |
| **Before the Phase 2 KPI set is shown to stakeholders** | OPEN-02 (Cost per Hire) |
| **Phase 0, in parallel with everything** | OPEN-06 (mail provider and mailbox), OPEN-03 (model hosting) |