# 06 — API Boundaries and Contracts ## Status / Scope of this document Design document. **Nothing here is implemented.** The repository contains no backend, no API layer, no routes, no controllers and no server-side code of any kind (`_repo-findings.md` §B); it also makes zero network calls — a repo-wide grep for `fetch(`, `XMLHttpRequest`, `axios`, `WebSocket` and `EventSource` across `js/` and `index.html` returns nothing (§C). Every endpoint below is therefore greenfield. This document defines **boundaries and contracts**: which HTTP surface each module owns, what it accepts, what it returns, who may call it, what is validated, what must be idempotent, what must be audited, and what must never exist. It deliberately does not specify serializer code, view classes, SQL, or OpenAPI YAML. Binding inputs: `_decisions.md` Part 1 (modular monolith, Django 5 + DRF, two processes, Postgres-backed queue, `ai_orchestration` as the only model client) and Part 2 (one PostgreSQL database, `public_id` in every external path, raw-intake-before-candidate, version pinning, additive reversible merge, current-state-plus-history, audit as append-only partitioned table). Where this document had to make a call that `_decisions.md` does not settle, the call is marked **[API decision]** with its tradeoff. Where `_decisions.md` is internally inconsistent, it is flagged in §9 rather than silently resolved. There is no meeting transcript anywhere in the repository or the user's Documents tree (§B). The assignment prompt is the requirements source, and the CONFIRMED / UNCONFIRMED split in §8 follows assignment §3 vs §4. Assumption labelling: every statement about user counts, volumes, role names or client behaviour that is not verifiable from the repository is marked **(assumption)**. --- ## 1. Cross-cutting conventions These are decided once and are non-negotiable per group. A group section may add rules; it may not contradict this section. Owner: Talha (`_decisions.md`, "the API layer" note after the intelligence/surfaces module table). ### 1.1 Versioning | Rule | Value | |---|---| | Namespace | `/api/v1/…`. The version is in the path, never in a header or query param. | | What v1 promises | Additive change only: new endpoints, new optional request fields, new response fields. Clients must ignore unknown response fields. | | What forces v2 | Removing or renaming a response field, narrowing a type, changing a default, changing pagination shape, changing the error envelope, or changing the meaning of an existing enum value. | | Enum evolution | Reference-data values (`ref.pipeline_stage`, `ref.rejection_reason`, …) are **data, not API version surface** — a new stage key is not a breaking change. Clients must render unknown reference keys by `label`, never by a hardcoded switch. This is the direct consequence of the reference-table-not-enum decision. | | Deprecation | `Deprecation: true` and `Sunset: ` response headers, minimum 90 days, plus a changelog entry. Internal platform with one first-party client, so a v1→v2 flag day is affordable; the header discipline exists so it is *planned* rather than discovered. | | Schema | One OpenAPI 3.1 document generated by drf-spectacular at `/api/v1/schema/` (`Accept: application/json` or `application/yaml`), human docs at `/api/v1/docs/`. The TypeScript client for the React app is **generated** from this schema in CI — the contract is written once. | | Non-versioned paths | `/healthz`, `/readyz`, `/metrics` only. These are infrastructure, not product API, and are not reachable from the internet. | **[API decision]** URL-path versioning over header negotiation. Tradeoff: path versioning duplicates route tables if v1 and v2 ever coexist. Chosen anyway because there is exactly one first-party consumer (the React SPA), path versions are trivially visible in logs, CDN rules and `curl`, and two developers should not debug content negotiation. ### 1.2 Authentication | Concern | Decision | |---|---| | Staff sessions (the SPA) | Entra ID SSO (OIDC authorization-code + PKCE) → server-side session, `HttpOnly; Secure; SameSite=Lax` cookie, Redis-backed session store. **Not** a JWT in `localStorage`. | | Why cookie sessions | The SPA is same-origin with the API (the `web` process serves the built bundle), so there is no cross-origin reason for bearer tokens; an `HttpOnly` cookie is not readable by injected script, which matters in a codebase whose predecessor had 34 unescaped `innerHTML` sinks (§E). Cost: CSRF must be handled — see below. | | CSRF | Django CSRF token required on every unsafe verb (`POST`/`PUT`/`PATCH`/`DELETE`), delivered via `GET /api/v1/auth/csrf`, sent as `X-CSRFToken`. `SameSite=Lax` alone is not treated as sufficient. | | Session lifetime | Idle 8 h, absolute 12 h, rotate on privilege change. Configurable via `config` — replacing the inert session-timeout select in `js/settings.js:148-154`. | | Step-up auth | Re-authentication (fresh IdP prompt) required for: merge/unmerge confirmation, offer issue, scoring-config activation, role assignment, and retention purge trigger. | | Break-glass access | **Not a general local-login option.** `00` OBD-15 assumes SSO only with **no local password store**, and `08` GAP-16 follows it; the only local credentials that exist are the named administrator accounts `02` §3 provisions for incident response (Argon2id, mandatory TOTP, admin-created, no self-service signup). `POST /api/v1/auth/break-glass-login` (§2.1) is `404` unless the documented incident flag is set, is limited to those accounts, pages on-call and is audited on every attempt. It exists so an Entra outage is not a total outage — not so ordinary users have a password. | | Candidate-facing surfaces | Never a session. Single-purpose `candidate_access_token` (hash stored, scope, `expires_at`, `consumed_at`, `revoked_at`) presented as `?t=` to a **separate** `/api/v1/public/…` namespace. `public_id` is an identifier, never a capability. | | Service-to-service | None in Phase 1. There is no machine principal: mail polling and job-board webhooks run inside the `worker` process against the domain services directly, not over HTTP. | | The chatbot | Has **no principal of its own**. `ai_orchestration.invoke()` carries the human actor. There is no service account for the assistant and none may be added. | Every authenticated request resolves to exactly one `app_user`, and every authorization decision goes through one function — `identity.can(actor, action, resource)` — exposed to DRF as permission classes. There is no second authorization implementation anywhere, including for the assistant. Today there is none at all: the RBAC matrix is a display widget and no `can()` exists (`js/rbac.js:78`, `js/rbac.js:111-112`). ### 1.3 Resource identity in paths `{id}` in every path in this document means **`public_id` (UUIDv7)**. Bigint primary keys are never serialised outside the database. `reference_code` (`CAN-5001`, `JOB-1001`, `APP-30001` — the shapes preserved from `js/data.js:118`, `js/data.js:95`, `js/data.js:298`) is internal-only, enumerable by construction, and appears in responses and internal search but **never in a path, a candidate-facing URL, or an email link**. Lookup by reference code is a query, not a path: `GET /api/v1/candidates?reference_code=CAN-5001`. **Aggregate naming and the scope vocabulary, stated once.** `/jobs` is the canonical resource tree for the requisition aggregate (`_open-items.md` RULING-06); **`job` is the matching authorization scope value**; `requisition` is the *module and service-facade* name only (RULING-03). All three name one row. Consequently **`requisition` is never a value of `scope_type`, never a path segment, and never an enum value in a payload** — where an earlier revision of this document used it as a scope value it was a defect, corrected in §2.3 and restated in §2.6. There is exactly one authorization scope vocabulary in this package, `app.access_scope.scope_type` (`03` §7.3); it is reproduced in §1.15 and no group in §2 may extend or rename it. ### 1.4 Pagination Two modes, chosen per endpoint, never per request. | Mode | Used for | Request | Response envelope | |---|---|---|---| | **Cursor** (default for anything append-heavy or unbounded) | applications, candidates, intake, audit-scoped feeds, history, notifications, messages | `?limit=` (1–100, default 25), `?cursor=` (opaque, base64 of the sort tuple) | `{ "data": [...], "page": { "next_cursor": "…"\|null, "prev_cursor": "…"\|null, "limit": 25 } }` | | **Offset with total** (only where a UI genuinely needs a page count) | reference-data lists, roles, departments, saved reports | `?limit=`, `?offset=` | `{ "data": [...], "page": { "limit": 25, "offset": 0, "total": 412 } }` | Rules: `total` is **never** returned on cursor endpoints — an exact count over a filtered candidate set is a full scan and the list screens do not need it; where a UI wants a sense of size it calls the group's `…/count` or facet endpoint, which may return `{"count": n, "is_estimate": true}`. Cursors are opaque and unstable across sort changes; changing `sort` invalidates a cursor and the API returns `400 invalid_cursor` rather than silently reinterpreting it. `limit > 100` is a validation error, not a silent clamp. **[API decision]** Cursor-first. Tradeoff: cursor pagination cannot jump to page 7, and `UI.dataTable` in the prototype is a client-side sort/paginate widget over an in-memory array (`js/ui.js:251`) that assumes it holds everything. That widget is already slated for replacement by TanStack Table against server-side pagination, so the cost is already budgeted, and stable pagination over a table receiving continuous intake is worth more than page numbers. ### 1.5 Filtering - Filters are **explicit, enumerated query parameters per endpoint**. There is no generic `?filter[field][op]=value` grammar and no `?q=` that is interpreted as a predicate language. Rationale: a generic grammar is an unbounded query surface — the same class of risk as text-to-SQL — and it defeats index planning. - Equality: `?stage=screening`. Multi-value OR: repeat the param, `?stage=screening&stage=interview`. - Ranges: `?applied_from=`, `?applied_to=`, `?score_min=`, `?score_max=`, `?experience_months_min=`. Suffix convention `_from`/`_to` for time and date, `_min`/`_max` for numbers. - Reference values are filtered **by stable text key**, never by numeric id (`?rejection_reason=role_filled`). - Free text: `?q=` is a **search** parameter only, routed to the FTS + trigram path, never a field predicate. Groups that do not support search do not accept `q`. - Unknown query parameters are a `400` validation error, not ignored. Silently ignoring a misspelled `?stauts=hired` returns unfiltered candidate data to a screen that believes it is filtered. - Soft-deleted rows are excluded by default (reads go through `v_*_live` views). `?include_deleted=true` exists only on endpoints where the permission `.read_deleted` is held. ### 1.6 Sorting `?sort=field` ascending, `?sort=-field` descending, multi-key comma-separated (`?sort=-score,applied_at`). Each endpoint publishes a **closed allowlist** of sortable fields in the OpenAPI schema; anything else is `400`. Every sort is made total by appending `public_id` as the final tiebreaker, because a non-total sort makes cursor pagination skip and duplicate rows. Sorting on a scope-filtered field the caller cannot see (e.g. `score` for a role where score visibility is off) is `403`, not a silent downgrade — a silent downgrade would let a caller infer ordering they may not see. ### 1.7 Error envelope One shape, every non-2xx response, every endpoint, including framework-generated errors (DRF's default exception handler is replaced). ```json { "error": { "type": "validation_error", "code": "field_invalid", "message": "One or more fields are invalid.", "detail": null, "request_id": "018f9a2c-6b41-7c3e-9f10-2a5b7c9d1e04", "occurred_at": "2026-07-30T09:14:22.118Z", "documentation_url": "/api/v1/docs/errors#field_invalid" } } ``` | Field | Rule | |---|---| | `type` | Coarse machine class. Closed set: `validation_error`, `authentication_error`, `permission_error`, `not_found`, `conflict`, `precondition_failed`, `rate_limited`, `unprocessable`, `dependency_unavailable`, `server_error`. | | `code` | Specific, stable, snake_case, documented. Clients branch on `code`, never on `message`. Examples: `field_invalid`, `stage_transition_not_allowed`, `terminal_transition_requires_human_actor`, `cooling_off_active`, `merge_reversal_blocked_by_later_merge`, `idempotency_key_reused_with_different_body`, `ai_provider_unavailable`. | | `message` | Human-readable, English, safe to display. Never contains SQL, stack frames, table names, internal ids, or another user's data. | | `detail` | Type-specific structured payload, or `null`. Only `validation_error` and a small number of documented conflict codes populate it. | | `request_id` | Always present, always equal to the `X-Request-Id` response header. This is the only string a user needs to quote in a support ticket. | Status-code mapping is fixed: `400` malformed/validation, `401` unauthenticated, `403` authenticated but not permitted, `404` not found **or** not visible in the caller's scope, `405`, `409` state conflict (including idempotency-key conflict), `412` `If-Match` failure, `415`, `422` semantically valid but rejected by a domain invariant, `429` rate limited, `503` a dependency (model provider, mail, storage) is down. **403 vs 404 rule:** for scope-filtered resources the API returns **`404`** when the caller may not see the resource at all, and `403` only when the caller can see the resource but not perform the action. Returning `403` for an out-of-scope candidate leaks existence. This is the API-layer expression of "URL-guessability is not authorization". ### 1.8 Validation error shape `400` with `type: "validation_error"`, and `detail.fields` as a **flat list**, not a nested object, because nested objects cannot express array indices or cross-field errors without ambiguity: ```json { "error": { "type": "validation_error", "code": "field_invalid", "message": "One or more fields are invalid.", "detail": { "fields": [ { "path": "salary_min_amount", "code": "required_with", "message": "Required when salary_min_currency_code is set.", "meta": { "with": "salary_min_currency_code" } }, { "path": "requirements[3].weight", "code": "out_of_range", "message": "Must be between 0 and 1.", "meta": { "min": 0, "max": 1 } }, { "path": "requirements", "code": "weights_must_sum_to_one", "message": "Requirement weights must sum to 1.0 (±0.0001); received 0.85.", "meta": { "sum": 0.85 } }, { "path": null, "code": "end_before_start", "message": "ends_at must be after starts_at." } ] }, "request_id": "018f9a2c-…", "occurred_at": "2026-07-30T09:14:22.118Z" } } ``` Rules: `path` uses dotted + bracketed JSON-pointer-ish notation matching the request body (`requirements[3].weight`); `path: null` means a whole-object/cross-field error; `code` is stable and drives client-side field highlighting; `meta` carries the numbers a UI needs to render a useful message without parsing prose. Validation is **all-fields-at-once**, never fail-fast — a form that reveals one error per round trip is the behaviour the prototype's ad-hoc per-form checks already produce (`js/offers.js:129`, `js/ui.js:241-249`). Every DRF serializer has a paired Zod schema on the client, generated from the OpenAPI schema where possible and hand-written where not. **The server is the authority**; the client schema exists for latency, not for trust. Database-enforced invariants surface as `422` with a specific `code`, not as a `500`. The mapping is explicit and tested — e.g. the deferrable contactability trigger → `contact_channel_required`; `uq_candidate_email` → `email_already_identifies_candidate` (plus the conflicting candidate's `public_id` in `detail` *only if* the caller may see it, otherwise `detail: null` and a "raised for duplicate review" message); the weight-sum trigger → `weights_must_sum_to_one`; the interview participant `EXCLUDE` constraint → `participant_double_booked`; `uq_application_live` → `active_application_exists`. ### 1.9 Idempotency keys Header: `Idempotency-Key: `. Storage: **`app.api_idempotency_record`** (`03-database-design.md` §28.4, Phase 1 migration `003`) — `idempotency_key`, `actor_user_id`, `method`, `path`, `request_body_sha256`, `response_status`, `response_body_sha256`, `created_resource_table`/`_pk`, `state ∈ in_flight/completed/failed`, `first_seen_at`, `completed_at`, `expires_at` — with `UNIQUE (actor_user_id, method, path, idempotency_key)` and 24-hour retention swept on the `maintenance` queue. The uniqueness key includes `method` and `path` as well as the actor, which is stricter than a bare `(actor_user_id, key)`: it means a client reusing one key across two different endpoints gets two independent records rather than a false replay of the wrong response. Semantics: 1. First request with a key executes and stores the response. 2. Replay with the **same** key and the **same** `request_body_sha256` returns the stored response verbatim with `Idempotency-Replayed: true`. 3. Replay with the same key and a **different** body is `409 idempotency_key_reused_with_different_body`. 4. A concurrent second request while the first is in flight is `409 idempotency_key_in_flight`. 5. The record row and the domain write commit in the **same transaction**, which is possible only because the queue and the data live in one Postgres — the same property that makes transactional enqueue work. | Verb / operation class | Idempotency key | Why | |---|---|---| | `GET`, `HEAD`, `OPTIONS` | Not accepted | Already idempotent; accepting a key would imply caching semantics we do not provide. | | `PUT`, `DELETE` on a known resource | Not required | Idempotent by definition. `DELETE` of an already-deleted row is `204`, not `404`. | | `PATCH` | Not required, but `If-Match` **is** required (see §1.10) | Concurrency, not duplication, is the risk on a partial update. | | `POST` that creates a domain row | **Required** | Every one of these is a retry-on-timeout candidate from a browser or an integration, and a duplicate is a real defect: a duplicate application inflates every funnel metric, a duplicate candidate lands in the dedupe queue, a duplicate intake row breaks `UNIQUE (channel_id, external_message_id)` accounting. | | `POST` that sends something irreversible outside the system (offer issue, message send, publication) | **Required, and step-up auth** | The failure is not a stray row, it is two offer letters in a candidate's inbox. | | `POST` that enqueues an async job (rescore batch, reparse, publish, bulk import) | **Required** | Prevents N identical batches from one impatient double-click; the key also becomes the dedupe key on the queue via procrastinate's queueing lock. | | `POST` that is a pure query (`/candidates/search`, `/analytics/*/query`, `/assistant/messages` retrieval) | Not required | No state change. Note `/assistant/messages` *does* create a message row and therefore *does* require a key. | | Webhook receivers (`/api/v1/webhooks/*`) | Provider key or provider message id, whichever exists; `channel_id + external_message_id` is the durable backstop | Providers retry by design; the database unique constraints are the last line. | **[API decision]** Required, not optional, on creating `POST`s — the server rejects a creating `POST` without a key (`400 idempotency_key_required`). Tradeoff: it is friction for `curl` and for the junior's first integration test, and it is unusual for an internal API. Chosen because "optional idempotency" means "absent in the paths that were written under time pressure", and the two Phase 1 intake channels are *retrying by nature* — Microsoft Graph delta polling and job-board webhooks. ### 1.10 Optimistic concurrency Every single-resource `GET` returns `ETag` (weak, derived from the row's `updated_at` plus version counter). `PATCH` and `PUT` on `candidate`, `job`, `job_application`, `interview`, `offer` and `assignment` require `If-Match`; a mismatch is `412 stale_resource`. Rationale: two recruiters editing the same candidate profile is an ordinary Tuesday, and last-write-wins silently discards the other's work. Endpoints that append rather than mutate (history, scores, notes, messages) do not need `If-Match`. ### 1.11 Rate limiting Redis-backed (Redis is cache and rate limiting only — never a broker or a store of record). Limits are per authenticated user, not per IP, because 66 named seats (assumption: peak concurrency ~20–25) sit behind a small number of office egress IPs. | Bucket | Limit | Notes | |---|---|---| | Global authenticated | 600 req/min/user | Generous; catches runaway client loops. | | Login / token endpoints | 10 attempts / 15 min / (username + IP), exponential backoff | Plus account lockout counter. | | Candidate-facing `/public/*` | 20 req/min/token, 60 req/min/IP | Untrusted callers. | | `POST /assistant/messages` | 20/min, 300/day/user | Real per-call money. Returns `429` with `Retry-After` and `detail.quota_reset_at`. | | `POST /*/rescore`, `POST /intake/*/reparse` (async batch triggers) | 5/hour/user, 20/hour platform-wide | Batch fan-out; guarded by queueing locks as well. | | Document upload | 30/min/user, 200 MB/hour/user | Parser CPU is the scarce resource. | | Search (`?q=`, `/candidates/search`) | 120/min/user | FTS + trigram cost. | | Export endpoints | 5/hour/user | Every export is an audited PII egress event. | Responses carry `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset` and, on `429`, `Retry-After`. Rate-limit rejections are audited when they occur on a PII-reading or export endpoint (`outcome: denied`, `denial_reason: rate_limited`) so a scraping attempt is visible; ordinary limit hits are metrics only. ### 1.12 Correlation ids | Header / field | Rule | |---|---| | `X-Request-Id` | Accepted from the client if it is a valid UUID, otherwise generated. Echoed on every response including errors. Stored on `audit_event.request_id`. | | Transaction propagation | Middleware issues `SET LOCAL app.request_id`, `app.actor_user_id`, `app.actor_kind`, `app.change_reason`, `app.source_service` at transaction start — the five GUCs `03` §31 requires, no subset. This is the bridge the history and audit triggers read; if they are missing, history rows are written with `actor_kind='system'` and `actor_unknown=true`, and `audit.audit_event.source_service` takes its `'unknown'` default (`03` §28.1). **Any API path that writes domain state without these settings is a defect**, and the `actor_unknown` count **and the `source_service = 'unknown'` count** are one monitored data-quality alarm, not noise. | | `app.source_service` | Names the **code path**, not the actor, and it is the one GUC whose value is a property of the process rather than the request: the API tier always sets `web`, the worker sets `worker`, the per-channel integration adapters set `integration_adapter`, the assistant's own write paths set `assistant`, migrations set `migration` and the Django admin sets `admin`. The values are `03` §28.1's CHECK list exactly — `web`, `worker`, `integration_adapter`, `migration`, `admin`, `assistant`, `unknown` — and `unknown` is never set deliberately; it is the default that raises the alarm. **It is set once in the process's request/job transaction wrapper and never derived per endpoint**, because a per-view value is a per-view omission. Without it `audit_event` answers "which code path did this" with nothing, and `request_id` cannot substitute: one recruiter action propagates a single `request_id` through the API layer, a queue job and an `ai_model_invocation`, which is why it correlates them and why it cannot discriminate between them. | | Async continuity | The enqueued job inherits `request_id` as `parent_request_id` and gets its own `request_id`, and **re-issues all five GUCs in its own transaction** with `app.source_service = 'worker'` — the job runs in a different process from the request that enqueued it, so nothing is inherited implicitly. So an intake → parse → score chain is one query in the audit log, with the tier that wrote each row named on each row. | | AI continuity | `ai_model_invocation` rows carry `request_id`; `ats_result` and every suggestion carry the invocation reference. "Which HTTP request caused this score" is a join, not an investigation. | | Client exposure | `X-Request-Id` is safe to surface in the UI (it is in the error envelope already). Internal span/trace ids are not returned. | ### 1.13 The audit rule Stated once, and every group section below only says *what is extra*. 1. **Every** state-changing request (`POST`, `PATCH`, `PUT`, `DELETE`) writes at least one `audit.audit_event` row, in the same transaction as the change. There is no code path that mutates domain state outside a transaction carrying an actor. 2. Data-change events are written by the generic trigger on classified tables. The API layer does not hand-roll them and must not duplicate them. 3. **Access events cannot be observed by a trigger and are therefore explicit API-layer writes.** The closed list of audited reads: candidate profile view, CV/document download or signed-URL issue, ATS score explanation view, interview scorecard view, offer view, any export/report download, any assistant answer, any subject-access export, any `include_deleted=true` read, and any candidate-facing token redemption. Ordinary list-screen reads are **not** audited — auditing every list render produces a log nobody can query and a write amplification nobody budgeted. 4. Denials are audited: `403` and `412` on state-changing endpoints, and every `403` on the audited-read list, write `outcome: denied` with `denial_reason`. 5. Every AI-influenced decision writes an event carrying the invocation reference, model id and version, prompt template version, and the human actor. There is no `actor_kind='ai_agent'` event without `on_behalf_of_user_id`. 6. `before`/`after` payloads for columns classified `sensitive_personal` or above store a hash and the fact of change, not the value. Compensation, CV text, scorecards and AI evidence are `sensitive_personal`. 7. There is **no API that updates or deletes an audit row.** The application role holds `INSERT` and `SELECT` only. The single exception is the narrow, logged `audit_event_redaction` path used for erasure requests, which is an admin+compliance operation and is itself audited. ### 1.14 Asynchronous work: the job-handle convention Any operation that cannot reliably finish inside a request returns a **job handle**, never a result. One shape for all of them. ``` POST /api/v1/jobs/{id}/rescore → 202 Accepted Location: /api/v1/async-jobs/018f9a… { "async_job": { "id": "018f9a…", "kind": "rescore_batch", "status": "queued", "submitted_at": "…", "progress": null, "result_url": null } } GET /api/v1/async-jobs/{id} → 200 { "async_job": { "id": "018f9a…", "kind": "rescore_batch", "status": "running", "progress": { "done": 41, "total": 260, "percent": 15.8 }, "submitted_at": "…", "started_at": "…", "finished_at": null, "result_url": null, "error": null, "attempts": 1, "max_attempts": 5 } } ``` | Rule | Value | |---|---| | Status set | `queued`, `running`, `succeeded`, `partial`, `failed`, `cancelled`, `dead_letter`. `partial` is a first-class outcome for batch operations and for parsing — a parse that extracted a name but no employment history is `partial`, not `failed`. | | Terminal shape | On `succeeded`/`partial`, `result_url` points at the domain resource (`/applications/{id}/scores`, `/intake/{id}`), **not** at an inline blob. The job handle is a receipt; the domain is the source of truth. | | Failure shape | `error` uses the same `{type, code, message, detail}` object as §1.7, so one client renderer handles sync and async failures. | | Never silent | A `failed` or `dead_letter` job surfaces in the owning module's UI queue (intake `needs_review`, `failed` state). There is no drop. | | Cancellation | `POST /async-jobs/{id}/cancel` — permitted only while `queued`, and only for the submitter or an admin. A `running` parse or model call is not cancellable in Phase 1; the endpoint returns `409 job_not_cancellable`. Stated as a limitation rather than faked. | | Permission | Visible to the submitter, to holders of the permission that created it, and to admins. A job handle is **not** a capability to read the result — the `result_url` is authorized independently. | | No polling-only design | The React client polls `GET /async-jobs/{id}` with backoff (1 s → 5 s → 15 s) in Phase 1. **(assumption)** Server-Sent Events on `/async-jobs/{id}/events` is a Phase 2 addition; WebSockets are not planned. Chosen because one streaming endpoint (the assistant) is enough streaming infrastructure for two developers. | ### 1.15 Sensitive fields and the scope-filtering rule Three orthogonal mechanisms, applied in this order on every read: 1. **Row scope** — `identity.scopes_for(user)` narrows the queryset before any filter the client sent. A recruiter sees candidates and applications within their assignments, department and business unit; an interviewer sees only candidates attached to an interview they participate in; a hiring manager sees their requisitions; a department head sees their department; executives see aggregates. Out-of-scope rows are `404`. 2. **Field scope** — a field the caller may not see is **omitted from the response object**, never returned as `null` and never returned masked-but-present. `null` is indistinguishable from "no value", which makes a UI render "Salary: —" for a restricted field and "Salary: —" for an unset one. Omission plus a `meta.restricted_fields: ["expected_salary_amount"]` array on the object tells the UI to render a lock icon truthfully. 3. **Aggregate floor** — analytics responses suppress any group with fewer than 5 subjects (`{"count": null, "suppressed": true, "reason": "below_minimum_group_size"}`), so a "time to hire by department" breakdown cannot be used to infer one person's outcome. #### The authorization scope vocabulary — one value set, reproduced not restated `scope_type` is the load-bearing input to `identity.can()` and to every `403`/`404` decision in this document, so it may have exactly one value set. **That set is `app.access_scope.scope_type` (`03` §7.3) and nothing else.** Seven values today: `global` · `business_unit` · `department` · `location` · `job` · `job_application` · `talent_pool` `05` §2.3 and `adr/0009` describe **eight *dimensions***, which is a different thing and not a competing enum: a dimension is a `(scope_type, origin)` pair, where `origin` is the `v_user_effective_scope` column (`03` §7.5) recording *how* the scope arose. The mapping is one-to-one and is the reconciliation this document uses everywhere: | `05` §2.3 / `adr/0009` dimension | `scope_type` | `origin` | Resolved from | |---|---|---|---| | `global` | `global` | `role_grant` | `role_assignment` → `access_scope` | | `business_unit` | `business_unit` | `role_grant` | `role_assignment` → `access_scope` | | `department` | `department` | `role_grant` | `role_assignment` → `access_scope` | | `region` | `region` — **conditional, see below** | `role_grant` | `role_assignment` → `access_scope` → `ref.region` | | — (no `05` dimension) | `location` | `role_grant` | `role_assignment` → `access_scope` → `ref.location` | | `job` | `job` | `role_grant` \| `job_assignment` | `access_scope`, or `job_assignment` branch 2 | | `application` | `job_application` | `role_grant` \| `job_assignment` | `access_scope`, or `job_application_assignment` | | `interview` | `job_application` | `interview_participation` | `interview_participant` branch 3 | | — (no `05` dimension) | `talent_pool` | `role_grant` | `role_assignment` → `access_scope` | | `explicit_grant` | *the subject's own* `scope_type` | `access_grant` **[additive]** | `access_grant` (`05` §2.3) | Three consequences the API must honour: - **`requisition` is not a scope value** (§1.3). The requisition dimension is `job`. - **`interview` is not a scope value.** An interviewer's visibility is a `job_application` scope with `origin = interview_participation`, derived by existence rather than granted — which is precisely why removing someone from a panel removes their access in the same statement (`03` §7.5). An endpoint that tries to *grant* an `interview` scope is a defect. - **`explicit_grant` is not a scope value either.** It is a fourth `origin` that produces a scope row of whatever type the granted subject implies (`candidate`, `job`, `job_application`, `interview`, `offer` → the corresponding `scope_type`). **`region` is conditional and this document does not pretend otherwise — but only in one respect.** The *schema* for it exists: `03` §6 defines `ref.region` and gives `ref.location` a `region_id` FK to it in migration `002`, and `03` §7.3 provisions `app.access_scope.region_id` together with its branch in the `ck_access_scope_target` exclusive arc and its entry in the `scope_key` generated column's `coalesce` list, all three in migration `011` (`08` GAP-27 is what forced them). What is conditional is whether `region` is **grantable** — `'region'` in the `access_scope.scope_type` CHECK — and that is the open question `_open-items.md` **OPEN-05** (owner: Talent Lead + Talha), tracked in `08` GAP-27. Until it is answered this document treats `region` as an **eighth value pending adoption**, not as a value in force, and every `scope_type` enumeration below carries it marked as such. If OPEN-05 answers *no*, nothing in this contract changes shape: a regional desk is expressed as several `location` scope rows, because `location` already exists in the enum, and the standing cost of the provisioning is one unreachable CHECK branch and an empty `region_ids` bucket. If it answers *yes*, adoption is **one line** — a `DROP CONSTRAINT` / `ADD CONSTRAINT` pair on `scope_type` — precisely because the three edits that would otherwise have to be coordinated with it are already done. That ordering is deliberate: those three are the ones that are expensive later (tightening a live CHECK against existing rows, and redefining a generated column on the table every authenticated request reads), and missing the third is how two different scopes collide on one unique key. `sensitive_personal` field classes that are scope-filtered everywhere they appear: compensation (current, expected, offer amounts and components), CV files and extracted text, interview scorecards and free-text feedback, ATS score and its evidence (score visibility is a configurable role setting), AI evidence payloads, duplicate-detection signals, and notes. No `special_category` data exists in Phase 1 — no diversity, health or accommodation fields are stored, and therefore none are exposed. ### 1.16 Request/response mechanics | Concern | Rule | |---|---| | Content type | `application/json; charset=utf-8`. Uploads use `multipart/form-data` on the two documented upload endpoints only. | | Casing | `snake_case` in JSON, matching the database and the Python layer. The generated TS client keeps snake_case rather than transforming — one less mapping to be wrong. | | Timestamps | RFC 3339 UTC with `Z` and millisecond precision (`2026-07-30T09:14:22.118Z`). Date-only fields are `YYYY-MM-DD`. The client renders in the viewer's zone; the API never returns a localised string. This replaces `toLocaleDateString` display logic and the hardcoded `new Date('2026-07-09')` (`js/data.js:237`, `js/candidates.js:18`, `js/jobboard.js:167`). | | Money | Always a pair, never a bare number: `{"amount": "125000.00", "currency_code": "AED"}`. `amount` is a **string** to survive JSON float parsing. | | Timezones | IANA names only (`Asia/Karachi`). A `+05:00` offset is rejected as a timezone value. | | Nulls | Absent means "not provided" on `PATCH`; explicit `null` means "clear this field". These are different requests and the API must not conflate them. | | Response wrapping | Collections: `{"data": [...], "page": {...}}`. Single resources: the object at the top level with no envelope, plus optional `meta`. Rationale: an envelope on singletons buys nothing and doubles client unwrapping. | | Enum output | Every reference value is an object, never a bare string: `{"key": "screening", "label": "Screening", "order_index": 2, "is_terminal": false}`. Filters accept the bare key. | | Output escaping | The API returns raw stored values — `full_name_original`, `address_original`, parser output and raw payloads are deliberately preserved unsanitised so the original is recoverable. **Escaping is the rendering layer's job**, enforced by JSX plus an ESLint `react/no-danger` error, and by `UI.esc()` + CSP in the hardened prototype. The API must not sanitise on write, and no consumer may assume it did. | | Compression, caching | gzip/br; `Cache-Control: private, no-store` on everything carrying candidate PII; short `max-age` with `ETag` on reference data only. | | Bulk operations | Only where named in a group. A generic `POST /batch` multiplexer does not exist — it breaks per-request auditing, per-request authorization and per-request idempotency all at once. | ### 1.17 The request lifecycle, stated once Every state-changing request follows this path. No endpoint may skip a step, and the ordering matters: scope filtering happens **before** the client's filters are applied, and the transaction-local actor settings are established **before** any domain write, because the history triggers read them. ```mermaid sequenceDiagram autonumber participant C as React client participant M as Middleware participant V as DRF view participant S as Module service participant DB as PostgreSQL participant Q as Queue in same DB C->>M: request + session cookie + CSRF + Idempotency-Key M->>M: resolve or generate request_id M->>M: authenticate session, resolve app_user M->>DB: BEGIN, SET LOCAL the five actor GUCs of §1.12 (incl. app.source_service) M->>V: dispatch V->>V: identity.can(actor, action, resource) V->>V: replay check on Idempotency-Key V->>V: validate whole body, collect all field errors V->>S: service call, DTOs only, never another module's models S->>DB: domain write DB->>DB: history trigger writes interval row with actor DB->>DB: audit trigger writes audit_event S->>DB: explicit access audit_event, if an audited read S->>Q: enqueue background job DB-->>M: COMMIT, domain write plus audit plus enqueue together M-->>C: 2xx with X-Request-Id, ETag or job handle ``` The single Postgres is what makes the last step honest: the domain row, its audit event, the idempotency record and the queued job commit or roll back together, so there is no dual-write window in which a document is enqueued for a row that never existed. Async work then follows one lifecycle, whatever the job kind: ```mermaid stateDiagram-v2 [*] --> queued queued --> running queued --> cancelled : cancel while queued running --> succeeded running --> partial : some items failed, surfaced not hidden running --> failed failed --> queued : retry within max_attempts failed --> dead_letter : max_attempts exhausted succeeded --> [*] partial --> [*] failed --> [*] cancelled --> [*] dead_letter --> [*] ``` `partial` and `dead_letter` are the two states that exist because of a requirement rather than a convention: `partial` because a CV that yields a name but no employment history is a real and common outcome that must not be reported as success or failure, and `dead_letter` because no inbound document may ever be silently dropped — a dead-lettered job surfaces in the intake queue for a human. --- ## 2. API groups Assignment §26.7 lists 25 groups. They map onto the 25 logical modules of `_decisions.md` as follows — the API surface is **not** one-to-one with modules, and the mismatches are deliberate: | Assignment API group | Backing module(s) | Note | |---|---|---| | authentication | `identity` | | | users | `identity` | | | roles and permissions | `identity` | | | departments | `config` (`ref.department`, `ref.business_unit`) | Reference data, not a domain module | | regions and locations | `config` (`ref.location`, `ref.region` — both in `03` §6; whether `region` is *grantable* is OPEN-05, §2.5) | **Not** tenancy. No regional data partition exists. | | requisitions | `requisition` (draft + approval surface) | See §2.6 for the requisition/job naming call | | jobs | `requisition` (identity + versions + requirements) | | | job publications | `requisition` + `integrations_outbound` | | | recruitment intake | `intake`, `integrations_inbound`, `document_parsing` | | | recruitment inbox | `intake` (triage view) | Same aggregate as intake; different permission and shape | | candidates | `candidate` | | | applications | `application`, `pipeline` | | | documents | `files`, `candidate` (`candidate_document`), `document_parsing` | | | ATS scoring | `scoring`, `fairness_evaluation` | | | duplicate review | `duplicate_review` | | | recruiter assignments | `assignment` | | | pipeline | `pipeline` (rules) + `application` (state) | | | communications | `notifications`, `config` (templates) | | | interviews | `interview` | | | feedback | `interview` (scorecards) | Scorecards are the feedback surface | | assessments | `assessment` | | | offers | `offer` | | | talent pool | `talent_pool` | | | analytics | `analytics` | | | chatbot | `assistant` + `ai_orchestration` | | Column key in every endpoint table: **Perm** = required permission (`module.action`; scope is always applied on top, §1.15); **Idem** = `Idempotency-Key` required; **Async** = returns a job handle (§1.14). Permission names are this document's proposal — `identity` owns the authoritative list. --- ### 2.1 Authentication **Module:** `identity`. **Responsibility:** establish and end sessions, expose the current principal and their effective permissions, handle step-up. It does **not** own users. | Method + path | Purpose | Perm | Idem | Async | |---|---|---|---|---| | `GET /api/v1/auth/csrf` | Issue CSRF token | anon | – | – | | `GET /api/v1/auth/sso/start` | Begin OIDC authorization-code + PKCE | anon | – | – | | `GET /api/v1/auth/sso/callback` | IdP redirect; establishes session | anon | – | – | | `POST /api/v1/auth/break-glass-login` | **Break-glass only** — named administrator accounts, disabled unless the documented incident flag is set (see below) | anon | – | – | | `POST /api/v1/auth/mfa/verify` | Second factor for the break-glass path only — normal users' MFA is enforced at the IdP | partial break-glass session | – | – | | `POST /api/v1/auth/step-up` | Re-auth for privileged operations | authenticated | – | – | | `GET /api/v1/auth/session` | Current principal, roles, scopes, effective permissions, expiry | authenticated | – | – | | `POST /api/v1/auth/logout` | End this session | authenticated | – | – | | `POST /api/v1/auth/logout-all` | End all sessions for self | authenticated | – | – | | `GET /api/v1/auth/sessions` | List own active sessions (device, ip, last_seen) | authenticated | – | – | | `DELETE /api/v1/auth/sessions/{id}` | Revoke one own session | authenticated | – | – | **There is no local password store for normal users.** `00` OBD-15's recommended assumption is **SSO only for internal users, with no local password store**; the only interactive path to a session is `sso/start` → `sso/callback`. `08` GAP-16 draws the same conclusion — under OBD-15 "there is no password to have a policy about, and 2FA is enforced by Entra ID, not TalentFlow" — so this document defines **no** password-policy, password-reset or password-change endpoint, and the settings screen's password-policy control is documented as not applicable rather than wired to an API. **`break-glass-login` is the incident-response mechanism `02` §3 describes**, and nothing else. Its properties are part of the contract, not deployment detail: | Property | Rule | |---|---| | Accounts | Only the named local Django superuser accounts provisioned per `02` §3 (Entra ID row). No ordinary `app_user` can authenticate this way; a normal user's credentials do not exist to be presented. | | Availability | `404` unless the documented incident flag is set (a deploy-time setting flipped under the IdP-outage runbook, not a per-request header or query parameter). Disabled state is indistinguishable from the endpoint not existing. | | Second factor | Mandatory. `mfa/verify` is reachable only from a partial break-glass session — it is not a step in the SSO flow. | | Notification | Every attempt, successful or not, pages the on-call engineer at the moment of the attempt. Paging is synchronous with the audit write, not a batched digest. | | Session | Short absolute lifetime, no "remember me", not eligible for `logout-all` suppression, and revoked automatically when the incident flag is cleared. | **Request fields:** `break-glass-login` — `username`, `password`. `mfa/verify` — `code`. `step-up` — `reason` (`merge`, `offer_issue`, `scoring_activation`, `role_assignment`, `retention_purge`) plus IdP assertion or TOTP code. **Response fields:** `session` — `user` (`public_id`, `display_name`, `email`, `job_title`, `avatar_url`), `roles[]` (`key`, `label`, `scope_type`, `scope_ref`, `origin` — all four from the §1.15 vocabulary), `permissions[]` (flat allowlist of `module.action` strings), `scopes` (the materialised scope set of §1.15, keyed by `scope_type` — `global` bool plus `business_unit_ids`, `department_ids`, `location_ids`, `job_ids`, `job_application_ids`, `talent_pool_ids`, and `region_ids` only once OPEN-05 adopts `region`; **not** a "requisition id list" — the requisition dimension is `job_ids`), `expires_at`, `absolute_expires_at`, `step_up_valid_until`, `mfa_enrolled`, `is_break_glass` (bool — true only for the break-glass path, so the UI can show the incident banner). `mfa_enrolled` is an **assertion mirrored from the IdP claim**, not a control TalentFlow enforces: TalentFlow reads it from the token, stores it on the session for display and audit, and has no way to turn it on, off, or re-verify it. Treating it as a TalentFlow control would be the same category error as the prototype's inert 2FA toggle (findings §D, `js/settings.js:148-154`). For break-glass sessions it reflects the local TOTP enrolment instead. There is no `must_change_password` field — with no local password store for normal users there is nothing to force; break-glass credential rotation is a Corporate IT process under the runbook, not an API-signalled state. **Validation:** OIDC `state` and `nonce` verified; PKCE verifier single-use. `redirect_uri` must match an allowlist — open-redirect is a `400`, not a warning. On the break-glass path only: the password is never echoed, never logged and never accepted in a query string; the check is constant-time with respect to whether the username exists; lockout after 10 failures in 15 min. No lockout counter exists for normal users, because they never present a password here — repeated-failure handling for them lives in Entra ID. **Idempotency:** none — these are session operations, and a replayed login must produce a fresh session, not a cached one. **Audit:** every login success, login failure (with reason class only, never the attempted password), MFA outcome, step-up grant, logout, and session revocation. `outcome: denied` carries `denial_reason` from a closed set. `ip` and `user_agent` recorded. Every `break-glass-login` call is additionally audited as its own event class — including calls rejected because the incident flag was clear — and pages on-call; that audit row is written before the response is returned, so a successful break-glass session cannot exist without a corresponding page. **Never exists:** any endpoint returning a password hash; any password-set, password-reset or password-policy endpoint for ordinary users (`00` OBD-15, `08` GAP-16); any endpoint that accepts a user id and returns a session for that user ("impersonate"); `GET` login with credentials in the query string; a break-glass path that can be enabled by request content rather than by the deployed incident flag. --- ### 2.2 Users **Module:** `identity`. **Responsibility:** the `app_user` record and its lifecycle. A hiring manager is a `app_user` with a role plus assignment rows — there is **no parallel Manager entity**, which is a deliberate correction of the prototype's separate manager records with their own name/title/department. | Method + path | Purpose | Perm | Idem | Async | |---|---|---|---|---| | `GET /api/v1/users` | List/search users (`?q=`, `?role=`, `?department=`, `?is_active=`) | `user.read` | – | – | | `POST /api/v1/users` | Create user (invite) | `user.manage` | ✔ | – | | `GET /api/v1/users/{id}` | Read one | `user.read` | – | – | | `PATCH /api/v1/users/{id}` | Update profile/status | `user.manage` | – (`If-Match`) | – | | `POST /api/v1/users/{id}/deactivate` | Deactivate (never delete) | `user.manage` | ✔ | – | | `POST /api/v1/users/{id}/reactivate` | Reactivate | `user.manage` | ✔ | – | | `GET /api/v1/users/me` | Own profile | authenticated | – | – | | `PATCH /api/v1/users/me` | Own display name, avatar, locale, timezone, notification prefs | authenticated | – | – | | `GET /api/v1/users/{id}/roles` | Effective role assignments | `user.read` | – | – | | `GET /api/v1/users/{id}/workload` | Open requisitions/applications, SLA counts | `assignment.read` | – | – | | `GET /api/v1/users/lookup?ids=` | Bulk display-name resolution (≤100 ids) | authenticated | – | – | **Request fields:** `email` (work address, must match a configured domain allowlist), `display_name`, `job_title`, `department_key`, `business_unit_key`, `default_timezone` (IANA), `locale`, `manager_user_id`, `initial_role_assignments[]`. **Response fields:** `public_id`, `display_name`, `email`, `job_title`, `department`, `business_unit`, `is_active`, `deactivated_at`, `last_login_at`, `roles[]`, `timezone`, `avatar_url`. `email` and `last_login_at` are omitted for callers holding only `user.read_basic` (the interviewer tier) — see §1.15. **Validation:** email unique on the normalised address; deactivation refuses if the user is the **sole current `primary_recruiter`** on any open requisition (`422 sole_primary_recruiter`, `detail` lists the requisition public_ids the caller may see) — the partial unique index on the current primary recruiter means a reassignment must happen first. Timezone validated against `pg_timezone_names`. A user may not deactivate themselves. `manager_user_id` must not create a cycle. **Idempotency:** required on create and on the state-change `POST`s; an invite retried after a timeout must not send two invitation emails. **Audit:** create, every field change, activation state change, and role change (the last also audited under §2.3). Reading a user list is not audited; reading a user's workload is not audited. **Never exists:** hard delete of a user (`DELETE /users/{id}` is not implemented — history, assignments, scorecards and audit rows reference them permanently); an endpoint that returns another user's session list, notification inbox, or raw audit log. --- ### 2.3 Roles and permissions **Module:** `identity`. **Responsibility:** the role catalogue, the permission catalogue, and **scoped** role assignments. This is the group that replaces a display-only matrix (`js/rbac.js:78`, `js/rbac.js:83-85`, `js/rbac.js:111-112`) with something that gates behaviour. | Method + path | Purpose | Perm | Idem | Async | |---|---|---|---|---| | `GET /api/v1/roles` | Role catalogue | `role.read` | – | – | | `POST /api/v1/roles` | Create custom role | `role.manage` | ✔ | – | | `GET /api/v1/roles/{id}` | Role with its permission set | `role.read` | – | – | | `PUT /api/v1/roles/{id}/permissions` | Replace a role's permission set | `role.manage` | – (`If-Match`) | – | | `GET /api/v1/permissions` | Permission catalogue (`module`, `action`, `label`, `risk_tier`, `is_scopable`) | `role.read` | – | – | | `GET /api/v1/permission-matrix` | Denormalised role × permission grid for the Access Control screen | `role.read` | – | – | | `POST /api/v1/users/{id}/role-assignments` | Grant a scoped role | `role.assign` + step-up | ✔ | – | | `DELETE /api/v1/users/{id}/role-assignments/{assignment_id}` | Revoke | `role.assign` + step-up | – | – | | `GET /api/v1/users/{id}/role-assignments` | Current + historical assignments | `role.read` | – | – | | `POST /api/v1/permissions/explain` | "Why can/can't user X do action Y on resource Z" — returns the decision path | `role.read` | – | – | **Request fields:** role — `key` (immutable after create), `label`, `description`, `is_system` (read-only), `permission_keys[]`. Assignment — `role_key`, `scope_type`, `scope_ref` (public_id or reference key), `valid_from`, `valid_to`, `reason`. `scope_type` takes its values from the **one** vocabulary of §1.15, `app.access_scope.scope_type` (`03` §7.3) — no subset, no rename: | `scope_type` | `scope_ref` names | Status | |---|---|---| | `global` | *omitted* | In force | | `business_unit` | `ref.business_unit.key` or `public_id` | In force. This is Part 1's "brand" (`_glossary.md`); the alias is dropped | | `department` | `ref.department.key` or `public_id` | In force | | `location` | `ref.location.key` or `public_id` | In force | | `job` | `job.public_id` | In force. **This is the requisition dimension** — the value is `job`, never `requisition` (§1.3, §2.6) | | `job_application` | `job_application.public_id` | In force | | `talent_pool` | `talent_pool.public_id` | In force | | `region` | `ref.region.key` | **Pending OPEN-05.** The table and the `access_scope.region_id` column exist (`03` §6, §7.3); rejected as `422 unknown_scope_type` until `scope_type`'s CHECK admits `'region'`; §1.15 | **Response fields:** role — `public_id`, `key`, `label`, `is_system`, `assigned_user_count`, `permissions[]`. Assignment — `public_id`, `role`, `scope_type`, `scope`, `origin` (always `role_grant` on this endpoint — see below), `valid_from`, `valid_to`, `assigned_by`, `reason`. **Validation:** system roles' `key` and permission set are not editable (`403 system_role_immutable`). A role may not be deleted while assigned (`409 role_in_use`) — deactivate instead. `scope_ref` must be omitted for `scope_type=global` and required otherwise; an unrecognised `scope_type` is `422 unknown_scope_type` with the in-force list in `detail.meta.allowed`, never a silent coercion. **This endpoint mints only `origin = role_grant` scopes.** The other three origins of §1.15 are not grantable here and must not be accepted: a `job`- or `job_application`-scope that should come from operational ownership belongs to §2.16 (`POST /jobs/{id}/assignments`), interview-derived scope is created by adding a panel member (§2.19) and can never be granted at all, and a time-boxed exception is `access_grant`, not a role assignment — each attempt is `422 wrong_scope_origin` naming the correct endpoint. Rationale: a scope granted here has no expiry ceiling, so allowing the grant path to imitate the derived paths would turn every temporary access into a permanent one, which is the exact failure `05` §2.3's 30-day `access_grant` ceiling exists to prevent. A grant may not include a permission the granting user does not themselves hold (`403 privilege_escalation_blocked`) — this is the single most important check in the group. `valid_to > valid_from`. Overlapping identical (user, role, scope) intervals are rejected by the same interval discipline used for assignments. **Idempotency:** required on grant/create. **Audit:** every role creation, permission-set change, grant and revoke, with `before` and `after` permission sets. Grants and revokes additionally record the step-up assertion reference. `POST /permissions/explain` is **not** audited as an access event (it returns no subject data) but is rate-limited. **Never exists:** any endpoint that returns the effective permissions of a *different* user in a form that reveals scope contents they hold over resources the caller cannot see — `/users/{id}/roles` returns role and scope *labels*, and scope membership is resolved against the caller's own visibility. --- ### 2.4 Departments **Module:** `config`, over `ref.department` and `ref.business_unit`. **Responsibility:** the organisational reference data every requisition, user and report filters by. Not a domain module and not a tenancy boundary. | Method + path | Purpose | Perm | Idem | Async | |---|---|---|---|---| | `GET /api/v1/departments` | List (`?business_unit=`, `?is_active=`) | authenticated | – | – | | `POST /api/v1/departments` | Create | `config.manage` | ✔ | – | | `GET /api/v1/departments/{id}` | Read | authenticated | – | – | | `PATCH /api/v1/departments/{id}` | Update label, head, parent, active flag | `config.manage` | – (`If-Match`) | – | | `GET /api/v1/business-units` | List | authenticated | – | – | | `POST /api/v1/business-units` | Create | `config.manage` | ✔ | – | | `PATCH /api/v1/business-units/{id}` | Update | `config.manage` | – (`If-Match`) | – | **Request fields:** `key` (stable, immutable, lowercase snake), `label`, `parent_department_key`, `business_unit_key`, `head_user_id`, `cost_centre_code`, `is_active`, `order_index`. **Response fields:** the above plus `public_id`, `open_requisition_count`, `active_user_count`, `path` (materialised ancestor keys for tree rendering). **Validation:** `key` unique and immutable; label unique within a business unit; parent must not create a cycle; a department may not be deactivated while it has open requisitions (`422 department_has_open_requisitions`) or active users; `head_user_id` must be an active user. Deletion is not offered — `is_active=false` only, because history and audit rows reference the row forever. **Idempotency:** required on create. **Audit:** all writes. Reads are not audited. **Phase 1 UI is the Django admin** — the whole Settings screen (`js/settings.js`) waits until Phase 4, which is why this group's write endpoints are thin. --- ### 2.5 Regions and locations **Module:** `config`, over `ref.location` and `ref.region`. **Responsibility:** where a job is based, where a candidate is, and which jurisdiction's retention and posting rules apply. > **This group is reference data, not tenancy.** There is one database, one master data > model, and no per-region partition. A `region` here is a grouping of locations that > regional desks are organised on, and a > jurisdiction is an input to a retention policy — never a routing or storage decision. > Any request to "add a region" that implies a separate data store is out of scope by > constraint, not by cost. **[API decision] — what `region` actually is, because §1.15 needs a straight answer.** **`region` is an entity in the schema and is not yet an authorization dimension**, and those are two separate facts that an earlier revision of this section treated as one. `03` §6 defines `ref.region` and gives `ref.location` a `region_id NULL` FK to it, **replacing** the free-text `region` column outright; both land in migration `002`, where `ref.location` is created (`03` §33.1). `03` §7.3 goes further and provisions `app.access_scope.region_id`, its branch in the `ck_access_scope_target` exclusive arc, and its entry in the `scope_key` `coalesce` list — all three in migration `011`, with the table. What is **not** built is one line: `'region'` in the `scope_type` CHECK. That single line is **OPEN-05**, and it is the whole of what remains open. So the two contract states are these, and this document commits to the first until OPEN-05 rules otherwise: | | Region as **reference data** (in force today) | Region as a **grantable dimension** (only if OPEN-05 says yes) | |---|---|---| | Storage | `ref.region` + `ref.location.region_id`, migration `002` (`03` §6) | Unchanged — this row does not move, which is the point | | `GET /regions` | A real collection with the ordinary reference-data profile of §2.4: `key`, `label`, `code`, `order_index`, `is_active`, `location_count`, `ETag` | Unchanged | | `POST /regions`, `PATCH /regions/{id}` | Exist, `config.manage`, idempotent create. Renaming a region is **one row**, not a `PATCH` on every affected location | Unchanged | | `scope_type = 'region'` | **Not a value.** `422 unknown_scope_type` (§1.15, §2.3). A regional desk is granted several `location` scope rows, which `access_scope` already supports | Joins the `access_scope.scope_type` enum — one `DROP CONSTRAINT` / `ADD CONSTRAINT` pair, because `03` §7.3 already built the column, the arc branch and the `scope_key` entry | Tradeoff, stated — and it is not the tradeoff an earlier revision of this section argued. That revision kept `region` as free text and declined to mint the table before OPEN-05, on the grounds that a scope dimension nobody grants is how `can()` becomes unreviewable. `03` overruled the storage half of that and was right to: `adr/0009` is **Accepted**, its `job_application` predicate reads `job__current_version__location__region_id IN region_ids`, and so an accepted ADR was resting on a column that did not exist (`08` GAP-27). The two halves separate cleanly. Minting the table costs nothing that an unused `ref` table does not already cost, and it buys the one-row rename plus a stable `id` for `adr/0009`'s predicate and `access_scope` to reference — neither of which a free-text column could give. Admitting `'region'` to the `scope_type` CHECK is the half that cannot be undone quietly, because from that moment a grant can exist that `can()` must honour — which is exactly the business question OPEN-05 asks, with a named owner and a gate. Provisioning the column, the arc branch and the `scope_key` entry *now* is the cheap ordering rather than the eager one: those three are the edits that are expensive later, and the residual cost of provisioning them is one unreachable CHECK branch plus an empty `region_ids` bucket, which `03` §7.3 requires a test to assert. Note what the table does **not** carry: no `parent_region_id`. `03` §6 gives `ref.region` `code` and `order_index` and nothing else, so a region hierarchy is still not expressible and no endpoint here pretends it is. | Method + path | Purpose | Perm | Idem | Async | |---|---|---|---|---| | `GET /api/v1/regions` | List (`?is_active=`) with location counts | authenticated | – | – | | `POST /api/v1/regions` | Create | `config.manage` | ✔ | – | | `PATCH /api/v1/regions/{id}` | Update | `config.manage` | – (`If-Match`) | – | | `GET /api/v1/locations` | List (`?region=`, `?country_code=`, `?q=`, `?is_active=`) | authenticated | – | – | | `POST /api/v1/locations` | Create | `config.manage` | ✔ | – | | `GET /api/v1/locations/{id}` | Read | authenticated | – | – | | `PATCH /api/v1/locations/{id}` | Update | `config.manage` | – (`If-Match`) | – | | `GET /api/v1/countries` | ISO-3166 list with default timezone and currency | authenticated | – | – | | `GET /api/v1/timezones` | IANA zone list (validated against `pg_timezone_names`) | authenticated | – | – | **Request fields:** location — `key`, `label`, `country_code` (ISO-3166-1 alpha-2), `region_key` (resolves to `ref.location.region_id`; nullable, because a location need not belong to a region), `city`, `address_lines[]`, `postal_code`, `default_timezone` (IANA), `default_currency_code` (ISO-4217), `jurisdiction_key`, `is_remote_eligible`, `is_active`. **Response fields:** the above plus `public_id`, `open_requisition_count`, `retention_policy_key` (derived from jurisdiction). **Validation:** `country_code` against the API's own ISO-3166 list — the one `GET /countries` serves — and **not** against a reference table, because there is no `ref.country`: `03` §6 stores `ref.location.country_code` as `char(2)` with a `~ '^[A-Z]{2}$'` CHECK and nothing more, so a well-formed but non-existent code is accepted by the database and must therefore be rejected by the API. `region_key` against `ref.region` where `is_active`; absent or explicit `null` clears `region_id` (§1.16). `default_timezone` against `pg_timezone_names` — an offset such as `+05:00` is rejected outright, because integrations will send `PST`, `IST` and `Asia/Calcutta`; `default_currency_code` against `ref.currency` where `is_active`. Deactivation blocked while referenced by an open requisition version. A region may not be deactivated while any active location references it (`422 region_has_active_locations`). **Idempotency:** required on create. **Audit:** all writes. A jurisdiction change on a location is a high-risk audit event because it changes which retention policy applies to future records — it does **not** retroactively re-derive `retention_due_on` for existing candidates, and the response says so explicitly in `meta.warnings`. --- ### 2.6 Requisitions **Module:** `requisition`. **Responsibility:** the *editorial and approval* surface — draft a new version of a job's content and requirements, route it through approval, publish it as the current version. > **[API decision] — the requisition/job naming collision.** `_decisions.md` Part 1 names > the module `requisition` with `RequisitionVersion` / `RequisitionRequirement`; Part 2 > names the tables `job`, `job_version`, `job_requirement`, `job_posting`. These are the > same aggregate. This document resolves it as: **one aggregate, whose canonical resource > path is `/jobs`** (matching the database, the prototype route `jobs`, and the > `JOB-1001` reference code at `js/data.js:95`), and **`/requisitions` does not exist as a > parallel resource**. The requisition *workflow* — draft, approval chain, publish — is > exposed as sub-resources under `/jobs/{id}/versions` and `/jobs/{id}/approvals`, which is > what this group covers. Tradeoff: recruiters say "requisition" and the API says "job", so > the UI must translate a label. That is cheaper than two resource names for one row, which > guarantees that half the client code fetches the wrong one. Flagged in §9.1. > > **The same call binds the authorization vocabulary.** The scope value for this aggregate is > **`job`** — `app.access_scope.scope_type = 'job'` (`03` §7.3) — because the scope enum names > the row, and the row is `app.job`. `requisition` is not a `scope_type` value; a > role-assignment request carrying `scope_type: "requisition"` is `422 unknown_scope_type` > (§2.3), not a synonym silently accepted. One row, one path (`/jobs`), one scope value > (`job`), one module name (`requisition`) — and the module name is the only one of the four > that never appears in a payload. | Method + path | Purpose | Perm | Idem | Async | |---|---|---|---|---| | `POST /api/v1/jobs/{id}/versions` | Create an immutable draft version (content + requirements) | `job.edit` | ✔ | – | | `GET /api/v1/jobs/{id}/versions` | Version list (`version_no`, `effective_from`, `created_by`, `change_reason`) | `job.read` | – | – | | `GET /api/v1/jobs/{id}/versions/{version_id}` | One version with its requirements | `job.read` | – | – | | `GET /api/v1/jobs/{id}/versions/{a}/diff/{b}` | Field-level diff between two versions | `job.read` | – | – | | `POST /api/v1/jobs/{id}/versions/{version_id}/submit-for-approval` | Start the approval chain | `job.edit` | ✔ | – | | `GET /api/v1/jobs/{id}/approvals` | Approval steps with state and actor | `job.read` | – | – | | `POST /api/v1/jobs/{id}/approvals/{step_id}/approve` | Approve a step | `job.approve` | ✔ | – | | `POST /api/v1/jobs/{id}/approvals/{step_id}/reject` | Reject with reason | `job.approve` | ✔ | – | | `POST /api/v1/jobs/{id}/versions/{version_id}/publish` | Make it the current version | `job.publish` | ✔ | ✔ (triggers rescore) | | `GET /api/v1/jobs/{id}/versions/{version_id}/requirements` | Weighted requirements | `job.read` | – | – | | `GET /api/v1/jobs/{id}/version-at?ts=` | The version in force at an instant | `job.read` | – | – | **Request fields (create version):** `title`, `description`, `employment_type_key`, `location_key`, `grade_key`, `vacancies`, `salary_min` and `salary_max` as `{amount, currency_code}` pairs, `custom_fields` (jsonb, non-queryable by contract), `effective_from`, `change_reason` (**required**, free text, min 10 chars), `requirements[]` each `{kind, skill_key|raw_label, operator, threshold_value, unit, is_mandatory, weight, display_order}`, `copy_from_version_id` (convenience: pre-fill from an existing version). **Response fields:** `public_id`, `job` (`public_id`, `reference_code`), `version_no`, `state` (`draft` \| `pending_approval` \| `approved` \| `published` \| `superseded` \| `rejected`), `content_hash`, `effective_from`, `superseded_at`, `created_by`, `change_reason`, `requirements[]` with `weight`, `approval_steps[]`. Salary fields are scope-filtered — omitted for interviewers and for roles without `job.read_compensation`. **Validation:** requirement weights must sum to 1.0 ± 0.0001 (`422 weights_must_sum_to_one`, `detail.meta.sum` carries the actual sum) — this is a deferrable constraint trigger, so the API must insert all requirements in one transaction, never one per request; `weight` in [0,1]; `salary_max ≥ salary_min` and both currency codes equal; amount scale must match the currency's `minor_unit` (JPY 500000.50 is rejected); at least one `is_mandatory` requirement; `vacancies ≥ 1`; `effective_from` not before the previous version's; `change_reason` required because a version without a stated reason is an unanswerable "what changed" later. Versions are **immutable** — there is deliberately no `PATCH /jobs/{id}/versions/{version_id}`; the database revokes `UPDATE` and a trigger raises, so an attempt is a `405`, documented as such. **Idempotency:** required on every `POST` here. A double-submitted draft creating two `version_no`s is a permanent, visible defect in a job's history. **Async:** `publish` returns a job handle because publishing may enqueue a rescore batch across every active application on the job (`kind: "requisition_publish"`, with a child `rescore_batch`). The version becomes current synchronously; the rescore is the async part, and the response makes that split explicit. **Audit:** version creation, each approval step decision (with actor and comment), publish (with previous and new `current_version_id`), and rejection. Approval decisions are high-risk events: the approver, the exact `content_hash` approved, and the step order are recorded, so "was the version that went live the one that was approved" is answerable. **Never exists:** an endpoint that edits a published version in place; an endpoint that publishes a version that has not completed its approval chain (bypass requires an audited `config`-level override, exposed to `admin` only, and it writes a distinct `approval_chain_overridden` audit action). --- ### 2.7 Jobs **Module:** `requisition`. **Responsibility:** the stable job identity — the row that applications point at, that assignments hang off, and that every list screen filters. Owns no content; content lives on versions (§2.6). | Method + path | Purpose | Perm | Idem | Async | |---|---|---|---|---| | `GET /api/v1/jobs` | List/search (`?q=`, `?status=`, `?department=`, `?business_unit=`, `?location=`, `?recruiter=`, `?hiring_manager=`, `?opened_from=`, `?opened_to=`; sort `-opened_at`, `title`, `-application_count`) | `job.read` | – | – | | `POST /api/v1/jobs` | Create the job identity plus its first draft version | `job.create` | ✔ | – | | `GET /api/v1/jobs/{id}` | Job with its current version inlined | `job.read` | – | – | | `PATCH /api/v1/jobs/{id}` | Identity-only fields (`department`, `business_unit`) | `job.edit` | – (`If-Match`) | – | | `POST /api/v1/jobs/{id}/status` | Transition (`open`, `on_hold`, `closed_filled`, `closed_cancelled`) | `job.edit` | ✔ | – | | `GET /api/v1/jobs/{id}/status-history` | Interval history with actor and reason | `job.read` | – | – | | `GET /api/v1/jobs/{id}/pipeline-summary` | Application counts per stage | `job.read` | – | – | | `GET /api/v1/jobs/{id}/applications` | Applications on this job (delegates to §2.12 filters) | `application.read` | – | – | | `GET /api/v1/jobs/{id}/scoring-assignment` | Which scoring config version is bound, and since when | `scoring.read` | – | – | | `PUT /api/v1/jobs/{id}/scoring-assignment` | Bind a scoring config version (interval-checked) | `scoring.configure` | – | ✔ | | `DELETE /api/v1/jobs/{id}` | **Does not exist** | – | – | – | **Request fields (create):** `department_key`, `business_unit_key`, plus the full first version payload from §2.6, plus `initial_primary_recruiter_user_id` (creates the `job_assignment` row in the same transaction) and `initial_hiring_manager_user_id`. **Response fields:** `public_id`, `reference_code` (`JOB-1001`), `status` (reference object), `department`, `business_unit`, `current_version` (inlined summary: `version_no`, `title`, `location`, `employment_type`, `vacancies`, salary pair), `current_primary_recruiter` (denormalised, so list screens need no temporal join), `hiring_managers[]`, `opened_at`, `closed_at`, `application_count`, `active_application_count`, `days_open`, `publication_count`, `version_count`. **Validation:** status transitions validated against a table, not a free enum — `open → on_hold → open`, `open|on_hold → closed_filled|closed_cancelled`, and a closed job does not reopen (create a new job; reopening would make "days open" and every funnel metric a lie). `closed_filled` requires at least one application in a `hired` status (`422 no_hire_for_filled_closure`) or an audited override. A job cannot be created without a first version — the two-step "create empty job then add content" flow is not offered, because an identity row with no version is a state no screen can render. Scoring-assignment binding must not overlap an existing active interval (the `EXCLUDE` constraint surfaces as `409 scoring_assignment_overlap`). **Idempotency:** required on create and status change. **Async:** binding a scoring config version returns a job handle — the binding is immediate, the rescore of open applications is queued. **Audit:** create, identity field changes, every status transition (actor + reason, and the trigger-written interval history row), and scoring-config binding changes. Read of a job is not audited. **Never exists:** `DELETE /jobs/{id}` — applications, scores, offers, audit and history all reference it permanently; the closest legal operation is `closed_cancelled`. No endpoint returns a job's applications without going through application scope filtering, even for a hiring manager who owns the job. --- ### 2.8 Job publications **Modules:** `requisition` (which version) + `integrations_outbound` (where it went). **Responsibility:** publish an exact `job_version` to an external surface, track its external state and cost, and preserve the text an applicant actually read. | Method + path | Purpose | Perm | Idem | Async | |---|---|---|---|---| | `GET /api/v1/job-publications` | List (`?job=`, `?platform=`, `?state=`, `?published_from=`) | `publication.read` | – | – | | `POST /api/v1/jobs/{id}/publications` | Publish a version to a platform | `publication.manage` (HR-admin: cost implications) | ✔ | ✔ | | `GET /api/v1/job-publications/{id}` | One publication with attempt history | `publication.read` | – | – | | `POST /api/v1/job-publications/{id}/unpublish` | Take down | `publication.manage` | ✔ | ✔ | | `POST /api/v1/job-publications/{id}/sync` | Reconcile state with the platform | `publication.manage` | ✔ | ✔ | | `GET /api/v1/job-publications/{id}/attempts` | Publish attempts with provider errors | `publication.read` | – | – | | `GET /api/v1/publication-platforms` | Configured platforms, health, credential status | `publication.read` | – | – | | `GET /api/v1/public/jobs/{publication_id}` | The public-facing rendered posting | anon (rate-limited) | – | – | **Request fields:** `job_version_id` (**required** — you publish a version, never "the job"), `platform_key`, `posting_title_override`, `apply_url_mode` (`hosted_portal` \| `external_ats`), `expires_on`, `budget_band`, `is_internal_only`. **Response fields:** `public_id`, `job`, `job_version` (`version_no`, `content_hash`), `platform`, `state` (`pending` \| `live` \| `expired` \| `removed` \| `failed`), `external_id`, `external_url`, `published_at`, `expires_on`, `cost_band`, `application_count_attributed`, `last_sync_at`, `last_error`. **Validation:** the referenced version must be `published` (not draft, not superseded) — `422 version_not_published`; the job status must be `open`; no duplicate `live` publication for the same (job, platform) — `409 publication_already_live`; `expires_on` in the future; `is_internal_only` publications refuse external platforms. Platform credentials must be present and healthy, else `503 platform_credentials_unavailable`. **Idempotency:** required on every write — a duplicate publish costs real money on a paid board and produces two external postings that both collect applications, which then arrive as two intake streams for one requisition. **Async:** publish, unpublish and sync are **all** async and return job handles. External platform APIs are slow, rate-limited and flaky; a synchronous publish endpoint would time out and leave the client unable to tell whether the posting exists. `state` on the domain row is the truth; the job handle only reports the attempt. **Audit:** publish, unpublish, each attempt outcome, cost-band changes, and credential rotation. Publication is one of the few actions with an external, public, money-spending effect, so the audit event carries the platform, the external id and the version's `content_hash`. **Public endpoint note:** `GET /public/jobs/{publication_id}` is the only anonymous read in the platform. It returns the **pinned version text only** — no internal ids, no recruiter names, no salary unless the publication was configured to show it, no requirement weights. It never accepts a `job` id, only a publication id, so an unpublished job cannot be fetched by guessing. **Never exists:** an endpoint that publishes the "current" version implicitly (which version was read is the difference between a defensible and an indefensible rejection); an anonymous endpoint that lists all publications for a job or enumerates publication ids. --- ### 2.9 Recruitment intake **Modules:** `intake` (the raw layer), `integrations_inbound` (channel adapters), `document_parsing` (worker). **Responsibility:** record every inbound submission **before** any candidate exists, keep the original payload immutable, run parse attempts as append-only retryable rows, and record a resolution decision with its own actor and reason. This is the group the prototype has no analogue for. Its inbox array is already pre-resolved — each row carries name, email, jobId, atsScore and recruiter directly (`js/data.js:284-300`) — so there is no representable state for "arrived and can never become a candidate". These endpoints make that state first-class. | Method + path | Purpose | Perm | Idem | Async | |---|---|---|---|---| | `GET /api/v1/intake` | List raw intake (`?state=`, `?channel=`, `?received_from=`, `?received_to=`, `?has_attachment=`, `?parse_status=`, `?q=`) | `intake.read` | – | – | | `GET /api/v1/intake/{id}` | One intake with attachments, parse attempts, resolution | `intake.read` | – | – | | `POST /api/v1/intake/manual` | Recruiter manual entry — **creates a `raw_intake` row on channel `manual_ui` first** | `intake.create` | ✔ | – | | `POST /api/v1/intake/uploads` | Upload one or more CVs (`multipart`) → one intake per file | `intake.create` | ✔ | ✔ | | `GET /api/v1/intake/{id}/payload` | The raw envelope/headers/form body (jsonb) | `intake.read_raw` | – | – | | `GET /api/v1/intake/{id}/attachments` | Attachment metadata (never bytes) | `intake.read` | – | – | | `POST /api/v1/intake/{id}/reparse` | Queue another parse attempt (e.g. after a parser upgrade) | `intake.manage` | ✔ | ✔ | | `GET /api/v1/intake/{id}/parse-attempts` | Append-only attempt history with parser + model versions | `intake.read` | – | – | | `GET /api/v1/intake/{id}/parse-attempts/{attempt_id}` | One attempt: `parsed` jsonb, per-field confidence, `error` | `intake.read` | – | – | | `POST /api/v1/intake/{id}/resolution` | The decision: create / attach / mark duplicate / reject / quarantine | `intake.resolve` | ✔ | – | | `GET /api/v1/intake/{id}/resolution` | The decision record | `intake.read` | – | – | | `POST /api/v1/intake/{id}/quarantine` | Move to quarantine (malware, unreadable, hostile) | `intake.resolve` | ✔ | – | | `GET /api/v1/intake-channels` | Configured channels with health, cursor position, last run | `intake.manage` | – | – | | `POST /api/v1/intake-channels` | Configure a channel | `admin` | ✔ | – | | `PATCH /api/v1/intake-channels/{id}` | Update / enable / disable | `admin` | – | – | | `POST /api/v1/intake-channels/{id}/test` | Connectivity + credential test | `admin` | ✔ | ✔ | | `GET /api/v1/intake-channels/{id}/runs` | Ingestion runs: items, failures, duration | `intake.manage` | – | – | | `GET /api/v1/intake/dead-letters` | Items that could not even be landed | `intake.manage` | – | – | | `POST /api/v1/intake/dead-letters/{id}/retry` | Replay | `intake.manage` | ✔ | ✔ | | `POST /api/v1/webhooks/intake/{channel_key}` | Provider webhook receiver | signed provider secret | provider key | ✔ | **Request fields.** `intake/manual` — `channel_key` fixed to `manual_ui`, `applicant_name`, `contact` (`{emails[], phones[]}`), `job_id` (optional — an intake may arrive with no target job), `source_note`, `attachments[]` (upload references), `consent` (`purpose`, `lawful_basis`, `granted_at`). `intake/uploads` — `files[]` (each ≤ 25 MB **(assumption)**; `application/pdf`, DOCX, DOC, RTF, TXT, and images for OCR), optional `job_id`, optional `source_channel_key`. `resolution` — `resolution_kind` (`create_candidate` \| `attach_to_existing_candidate` \| `mark_duplicate` \| `reject_unusable` \| `quarantine`), `decision_mode` (`human` \| `automatic`), `candidate_id` (for attach), `duplicate_of_candidate_id`, `reject_reason_key`, `field_overrides` (recruiter corrections to parsed values), `job_id` (create the application in the same transaction), `note`. **Response fields.** `public_id`, `channel`, `external_message_id`, `received_at`, `state` (`received` \| `parsing` \| `parsed` \| `needs_review` \| `resolved_new_candidate` \| `resolved_existing_candidate` \| `rejected_unusable` \| `quarantined`), `attachments[]` (`filename`, `mime_type`, `byte_size`, `sha256`, `virus_scan_status`), `latest_parse_attempt` (`status` ∈ `succeeded`/`partial`/`failed`, `parser_name`, `parser_version`, `confidence`, per-field confidences), `parse_attempt_count`, `resolution`, `suggested_candidate_matches[]` (from duplicate detection, each with `candidate_public_id`, `match_score`, `signals`), `resolved_at`, `age_hours`, `sla_breached`. `payload` and `parsed` are **omitted** unless the caller holds `intake.read_raw` — a raw email envelope contains headers, other recipients and attacker-controlled strings. **Validation.** `POST /intake/manual` and `POST /intake/uploads` may not create a candidate directly; they create intake rows, and candidate creation happens only through `POST /intake/{id}/resolution`. Redelivery is idempotent by `UNIQUE (channel_id, external_message_id)` and `UNIQUE (channel_id, payload_sha256)` — a duplicate delivery returns `200` with the existing intake, not `409`, because a provider retry is not a client error. Resolution requires `state ∈ {parsed, needs_review}` (`409 intake_not_resolvable_in_state`). `resolution_kind=create_candidate` with `decision_mode=automatic` requires `auto_create_evidence` — the schema `CHECK` enforces it, and the API surfaces it as `422 auto_create_requires_evidence`. `attach_to_existing_candidate` requires the caller to be able to see that candidate, else `404`. `reject_unusable` and `quarantine` are terminal and produce **no** candidate. Uploads: MIME sniffed server-side, not trusted from the client; extension mismatch is recorded, not rejected; files are virus-scanned before any parse attempt and a scan failure blocks parsing rather than falling through. **Idempotency.** Required on every write. Webhook receivers use the provider's message id plus the two database unique constraints as the durable backstop. **Async.** Uploads return a job handle per file (`kind: "intake_parse"`), and so do reparse, channel test, dead-letter retry and webhook receipt. Parsing a scanned CV is CPU-bound and multi-second and runs in the `worker` process (from Phase 2, the `worker-untrusted` queue under a restricted OS user with no outbound network). The API never parses in-request. **Audit.** Intake arrival (`actor_kind: integration` or `user`), every parse attempt outcome, every resolution decision (actor, mode, reason, and the evidence payload reference), quarantine, dead-letter retry, and every read of `payload` or `parsed` (these are audited access events — the raw payload is the least-filtered candidate data in the system). Channel credential changes are high-risk events. **Never exists.** No endpoint deletes or edits a `raw_intake` row or its payload — `DELETE /intake/{id}` is not implemented, and `PATCH` is not either; corrections are `field_overrides` on the resolution, so the arrival record stays pristine. No endpoint creates a candidate without an intake row. No endpoint returns attachment bytes from this group — that is §2.13, so file access is authorized in one place. --- ### 2.10 Recruitment inbox **Module:** `intake`, as a triage surface. **Responsibility:** the recruiter's working queue over the same aggregate as §2.9 — different shape, different permission, different default filters. Backs prototype routes `inbox` (FR-2) and `import` (FR-7), which are two screens onto one raw-intake domain. **[API decision]** The inbox is a **view over `intake`, not a separate resource with its own store.** Its endpoints live under `/inbox` for client clarity but resolve to `raw_intake` rows; there is no `inbox` table. Tradeoff: two paths reach the same rows, which needs a documented note (this one) so nobody adds an `inbox_item` table later. The alternative — a single `/intake` surface with a `?view=inbox` parameter — was rejected because the inbox carries genuinely different response fields (assignment, SLA age, suggested actions) that would bloat the raw intake representation for every caller. | Method + path | Purpose | Perm | Idem | Async | |---|---|---|---|---| | `GET /api/v1/inbox` | Triage queue: default `state ∈ {parsed, needs_review}`, sorted by `sla_risk` then `received_at` | `intake.read` | – | – | | `GET /api/v1/inbox/counts` | Badge counts per state and per channel, plus overdue count | `intake.read` | – | – | | `GET /api/v1/inbox/{id}` | Triage detail: parsed fields beside the source document, with confidence flags | `intake.read` | – | – | | `POST /api/v1/inbox/{id}/claim` | Claim for triage (soft lock, TTL 30 min) | `intake.resolve` | ✔ | – | | `POST /api/v1/inbox/{id}/release` | Release a claim | `intake.resolve` | ✔ | – | | `POST /api/v1/inbox/{id}/assign` | Assign triage to another recruiter | `intake.manage` | ✔ | – | | `POST /api/v1/inbox/{id}/snooze` | Defer until a timestamp, with reason | `intake.resolve` | ✔ | – | | `POST /api/v1/inbox/bulk-assign` | Assign up to 50 items (named bulk operation) | `intake.manage` | ✔ | ✔ | | `GET /api/v1/inbox/failed` | Parse failures and dead ends needing a human | `intake.read` | – | – | **Request fields.** `claim` — nothing. `assign` — `assignee_user_id`, `note`. `snooze` — `snooze_until`, `reason`. `bulk-assign` — `intake_ids[]` (≤50), `assignee_user_id`, `reason`. **Response fields.** Everything from §2.9's summary shape plus `claimed_by`, `claim_expires_at`, `assigned_to`, `snoozed_until`, `sla_risk` (`ok` \| `warning` \| `breached`), `age_hours`, `suggested_actions[]` (each `{kind, confidence, rationale, target_candidate_id?, target_job_id?, ai_run_id?}`), `low_confidence_fields[]`, `duplicate_candidate_matches[]`. **Validation.** Claiming an item claimed by someone else is `409 already_claimed` naming the holder and expiry — a soft lock, not a mutex; expiry is enforced server-side. `bulk-assign` is capped at 50 and is all-or-nothing within a transaction; partial success would leave the recruiter unable to tell what happened. Snooze must be in the future and within 30 days. **Idempotency.** Required on all writes, including bulk. **Async.** `bulk-assign` returns a job handle (it may touch 50 rows plus notifications). Everything else is synchronous. **Audit.** Claim, release, assign, snooze and bulk-assign are all state changes and are audited. `suggested_actions` are AI output and are **suggestions only** — accepting one goes through `POST /intake/{id}/resolution` with a human actor, and the audit event carries the `ai_run_id` that produced the suggestion. No suggestion is ever auto-applied. **Never exists.** No endpoint that auto-resolves the queue ("resolve all high-confidence items"). The constraint is that raw intake must exist before candidate creation *and* that resolution is a decision with an actor; a bulk auto-resolve endpoint would make `decision_mode=automatic` the default path rather than the audited exception. If the business later wants it, it arrives as an explicitly configured rule with `auto_create_evidence` per item and its own permission — not as a convenience button. --- ### 2.11 Candidates **Module:** `candidate`. **Responsibility:** person identity, independent of any application. This is the highest-value structural correction to the prototype, where one flat array carries `jobId`, `jobTitle`, `stage`, `aiScore` and `recruiter` directly on the candidate (`js/data.js:117-127`) so one person cannot hold two applications. | Method + path | Purpose | Perm | Idem | Async | |---|---|---|---|---| | `GET /api/v1/candidates` | List/search (`?q=`, `?skill=`, `?location=`, `?country=`, `?experience_months_min/max=`, `?education_level=`, `?tag=`, `?status=`, `?has_active_application=`, `?created_from/to=`, `?reference_code=`; sort `-created_at`, `display_name`, `-relevance`) | `candidate.read` | – | – | | `POST /api/v1/candidates/search` | Structured search with facets (POST because the filter body exceeds sane URL length) | `candidate.read` | – | – | | `GET /api/v1/candidates/{id}` | Full profile | `candidate.read` | – | – | | `PATCH /api/v1/candidates/{id}` | Update first-class fields | `candidate.edit` | – (`If-Match`) | – | | `GET /api/v1/candidates/{id}/applications` | All applications for this person | `application.read` | – | – | | `GET /api/v1/candidates/{id}/emails` / `POST` / `PATCH .../{email_id}` / `DELETE .../{email_id}` | Contact channels | `candidate.edit` | ✔ on POST | – | | `GET /api/v1/candidates/{id}/phones` / `POST` / `DELETE .../{phone_id}` | Contact channels | `candidate.edit` | ✔ on POST | – | | `GET /api/v1/candidates/{id}/skills` / `PUT` | Skills with provenance and confirmation | `candidate.edit` | – | – | | `GET /api/v1/candidates/{id}/employment` / `POST` / `PATCH` / `DELETE` | Employment history | `candidate.edit` | ✔ on POST | – | | `GET /api/v1/candidates/{id}/education` / `POST` / `PATCH` / `DELETE` | Education | `candidate.edit` | ✔ on POST | – | | `GET /api/v1/candidates/{id}/links` / `POST` / `DELETE` | LinkedIn, portfolio, GitHub | `candidate.edit` | ✔ on POST | – | | `GET /api/v1/candidates/{id}/documents` | CV revisions (metadata; bytes via §2.13) | `candidate.read` | – | – | | `GET /api/v1/candidates/{id}/notes` / `POST` / `PATCH` / `DELETE` | Recruiter notes | `candidate.note` | ✔ on POST | – | | `GET /api/v1/candidates/{id}/tags` / `PUT` | Tags | `candidate.edit` | – | – | | `GET /api/v1/candidates/{id}/timeline` | Merged activity feed (applications, stages, interviews, communications, documents, merges) | `candidate.read` | – | – | | `GET /api/v1/candidates/{id}/status-history` | Interval history | `candidate.read` | – | – | | `POST /api/v1/candidates/{id}/status` | Transition status | `candidate.edit` | ✔ | – | | `GET /api/v1/candidates/{id}/consents` / `POST` | Append-only consent record | `candidate.read` / `candidate.edit` | ✔ | – | | `GET /api/v1/candidates/{id}/duplicate-candidates` | Suspected duplicates for this person | `duplicate.read` | – | – | | `POST /api/v1/candidates/{id}/soft-delete` | Recruiter removal (**not** erasure) | `candidate.delete` | ✔ | – | | `POST /api/v1/candidates/{id}/restore` | Undo a soft delete | `candidate.delete` | ✔ | – | | `GET /api/v1/candidates/{id}/export` | Subject-access export (all held data, machine-readable) | `candidate.export` + step-up | – | ✔ | | `POST /api/v1/candidates/{id}/erasure-request` | Register an erasure request → pseudonymisation job | `candidate.erase` + step-up | ✔ | ✔ | | `POST /api/v1/candidates` | **Does not exist** | – | – | – | **Request fields (PATCH).** `display_name`, `full_name_original`, `location_text`, `location_key`, `country_code`, `current_title`, `current_employer_name`, `total_experience_months`, `highest_education_level_key`, `notice_period_days`, `expected_salary` (`{amount, currency_code}`), `willing_to_relocate`, `status_key`. Note **months, not years** — the prototype stores integer years (`js/data.js:121`) and rounding to years loses ordering. **Response fields.** `public_id`, `reference_code` (`CAN-5001`), `display_name`, `full_name_original`, `emails[]` (each `address_original`, `is_primary`, `verified_at`, `suppressed_by_merge_id`), `phones[]`, `location`, `country`, `current_title`, `current_employer_name`, `total_experience_months`, `highest_education_level`, `skills[]` (each `skill` reference or `raw_label`, `proficiency`, `years_months`, `source`, `confidence`, `is_confirmed_by_recruiter`), `employment[]`, `education[]`, `links[]`, `tags[]`, `status`, `source_channel`, `created_from_raw_intake_id`, `application_count`, `active_application_count`, `latest_application_summary`, `document_count`, `merged_into_candidate_id`, `retention_due_on`, `created_at`, `updated_at`, `meta.restricted_fields[]`. Scope-filtered / omitted by role: `expected_salary`, `current_salary`, `notes[]`, `documents[]` (and therefore extracted CV text), any ATS score reachable through `applications[]`, and `duplicate_candidates[]` signals. An **interviewer** sees only candidates attached to an interview they participate in, and sees name, current title, skills, the CV document for that interview, and nothing else — no contact details, no compensation, no notes, no scores, no other applications. **Validation.** There is **no `POST /candidates`** — creation is `POST /intake/{id}/resolution` with `resolution_kind=create_candidate`, because `candidate.created_from_raw_intake_id` is `NOT NULL` against a non-deferrable FK. An API that appears to create a candidate directly would either fail at the database or force a synthetic intake row behind the caller's back; making the absence explicit is the honest contract. Email: normalised address must satisfy the syntactic `CHECK`, `address_original` is stored exactly as it arrived, and the global partial unique index means a collision is `409 email_already_identifies_candidate` — the response includes the conflicting candidate's `public_id` **only if** the caller may see it, otherwise it says the item has been raised for duplicate review. Phone must be E.164. Removing the last email *and* phone fails at `COMMIT` via the deferrable contactability trigger → `422 contact_channel_required`. Employment `end_date ≥ start_date`. Skills must reference `ref.skill` or carry a `raw_label`; unmapped parser output is stored and reviewable, never dropped. `status` transitions validated against `ref.lifecycle_status` **domain `candidate`** (`active`, `passive`, `do_not_contact`, `merged`, `purged` — `03` §4.7 consolidated the six per-entity status tables into one table with a `domain` column, so there is no `ref.candidate_status`, and the composite FK `(status_id, status_domain)` is what keeps a `job` status from being set on a candidate). `GET /candidates/{merged_loser_id}` returns `301` to the survivor — the loser's `public_id` is already in sent emails and recruiter bookmarks. **Idempotency.** Required on all creating `POST`s. **Async.** `export` and `erasure-request` return job handles: an export assembles data across a dozen tables plus object storage; erasure is a pseudonymisation pass plus blob deletion, and it must interlock with merge reversibility (a purged candidate sets `reversal_blocked_reason` on any merge it participated in). **Audit.** Every write. **Reads that are audited:** profile detail view (`GET /candidates/{id}`), timeline view, notes read, export, erasure request, and any `include_deleted=true` list. List/search reads are not audited (§1.13.3). **Never exists.** `POST /candidates` (see above). `DELETE /candidates/{id}` as a hard delete — soft delete is explicitly **not** erasure and the API must never present it as such; the two operations are separate endpoints with separate permissions and different audit actions, precisely so nobody conflates them in a UI label. No endpoint returns another user's notes attributed anonymously; notes always carry their author. --- ### 2.12 Applications **Module:** `application` (with `pipeline` supplying the rules). **Responsibility:** the candidate ↔ `job_version` join. Owns stage and status, is the unit ATS scores attach to, and is where the reapplication rule lives. | Method + path | Purpose | Perm | Idem | Async | |---|---|---|---|---| | `GET /api/v1/applications` | List (`?job=`, `?candidate=`, `?stage=`, `?status=`, `?recruiter=`, `?score_min/max=`, `?score_band=`, `?applied_from/to=`, `?source_channel=`, `?sla=`, `?is_active=`; sort `-applied_at`, `-score`, `stage_order`, `-last_activity_at`) | `application.read` | – | – | | `POST /api/v1/applications` | Create an application | `application.create` | ✔ | ✔ (score) | | `GET /api/v1/applications/{id}` | Detail with candidate summary, pinned job version, current score | `application.read` | – | – | | `PATCH /api/v1/applications/{id}` | Editable fields (source note, expected salary as stated for this application) | `application.edit` | – (`If-Match`) | – | | `POST /api/v1/applications/{id}/stage` | Move stage | `application.transition` | ✔ | – | | `POST /api/v1/applications/{id}/status` | Set status (incl. `rejected`, `withdrawn`, `hired`) | `application.transition` | ✔ | – | | `GET /api/v1/applications/{id}/stage-history` | Interval transition history: from, to, actor, `actor_kind`, reason, duration | `application.read` | – | – | | `GET /api/v1/applications/{id}/status-history` | Interval status history | `application.read` | – | – | | `GET /api/v1/applications/{id}/allowed-transitions` | What this actor may do next, and why not for the rest | `application.read` | – | – | | `GET /api/v1/applications/{id}/scores` | Score history (current + superseded) | `scoring.read` | – | – | | `GET /api/v1/applications/{id}/timeline` | Everything that happened on this application | `application.read` | – | – | | `GET /api/v1/applications/{id}/interviews` | Interviews for this application | `interview.read` | – | – | | `GET /api/v1/applications/{id}/offers` | Offers for this application | `offer.read` | – | – | | `POST /api/v1/applications/{id}/withdraw` | Candidate-initiated withdrawal recorded by a recruiter | `application.transition` | ✔ | – | | `POST /api/v1/applications/bulk-stage` | Move up to 50 applications one stage | `application.transition` | ✔ | ✔ | | `POST /api/v1/applications/{id}/reject` | Reject with a reason (human only) | `application.transition` | ✔ | – | | `DELETE /api/v1/applications/{id}` | **Soft delete only**, distinct endpoint `POST .../soft-delete` | `application.delete` | ✔ | – | **Request fields (create).** `candidate_id`, `job_id`, `raw_intake_id` (**required** — `job_application.raw_intake_id` is `NOT NULL`), `source_channel_key`, `job_posting_id` (which publication the applicant read, if known), `applied_at`, `expected_salary` pair, `cover_letter_text`, `cooling_off_override_reason` (only with the override permission). The application pins `job_version_id` server-side from the job's current version at `applied_at` — **the client does not choose it**, because letting a client pick the version it is scored against is a correctness hole. **Request fields (stage/status).** `to_stage_key` or `to_status_key`, `reason_key` (from `ref.rejection_reason` for negative statuses), `note`, `effective_at` (defaults to now; backdating requires `application.backdate` and is audited distinctly). **Response fields.** `public_id`, `reference_code` (`APP-30001`), `candidate` (summary), `job` + `job_version` (`version_no`, `title`, `content_hash` — the exact text pinned), `job_posting` (what the applicant read), `attempt_no`, `state` (`active` \| `terminal`, generated), `current_stage` (reference object with `order_index`, `is_terminal`), `status`, `applied_at`, `terminal_at`, `days_in_current_stage`, `total_days_in_pipeline`, `current_score` (`overall_score`, `band`, `computed_at`, `is_current` — omitted entirely where score visibility is off for the role), `assignments[]`, `sla` (`due_at`, `breached`), `source_channel`, `raw_intake_id`, `last_activity_at`, `superseded_by_application_id`, `interview_count`, `offer_count`. **Validation.** At most **one** active application per (candidate, job) — `uq_application_live` surfaces as `409 active_application_exists` with the existing application's `public_id`. Uniqueness is per **job**, not per `job_version` and not per `job_posting`, so the same person arriving via LinkedIn and the careers page is caught. Reapplication requires the previous attempt to be terminal **and** the cooling-off window to have elapsed (default 90 days; 0 for `withdrawn_by_candidate`; shorter for `role_filled`) → `422 cooling_off_active` with `detail.meta.eligible_from`; an audited override exists (`cooling_off_override_by_user_id`, `override_reason`) because a hard block would be circumvented by recruiters creating duplicate candidate records, which is strictly worse. Stage transitions validated against `pipeline.is_allowed(from, to)` → `422 stage_transition_not_allowed` listing the allowed targets. **Terminal-negative transitions require a human actor** (the guard literal is exactly `actor_kind = 'user'`, settled by `_decisions.md` RULING-01; see §9.1 #2) — a request whose actor is a system or AI principal is rejected with `403 terminal_transition_requires_human_actor`; this is enforced by the service guard and by the `ats_result` `CHECK` on `review_outcome`/`reviewed_by_user_id`, not by policy. `reason_key` is mandatory for `rejected` and for `on_hold`. The job must be `open` to create an application unless the caller holds `application.create_on_closed`. **Idempotency.** Required on create and every transition. A double-clicked "Move to Interview" that writes two history intervals corrupts time-in-stage for every report. **Async.** Create returns `201` with the application **and** an `async_job` for the initial scoring pass (`kind: "score_application"`) — the application exists immediately, the score arrives later, and the response makes that explicit rather than returning a null score with no explanation. `bulk-stage` returns a job handle. **Audit.** Create, every stage and status transition (with actor, `actor_kind`, reason — also written to the typed history table by trigger), backdated transitions as a distinct action, bulk operations as one event per application plus one batch event, and soft delete. **Reads audited:** none in this group by default; the score explanation view under §2.14 is audited. **Never exists.** No endpoint sets `status = rejected` without an authenticated human actor — not for a scoring job, not for a rule, not for the assistant. No endpoint lets a client choose `job_version_id`. No endpoint hard-deletes an application. No endpoint returns an application list unscoped for "reporting convenience" — that is §2.23, which returns aggregates with a minimum group size. --- ### 2.13 Documents **Modules:** `files` (blobs, checksums, scan, signed URLs, retention), `candidate` (`candidate_document` revisions), `document_parsing` (extraction). **Responsibility:** one authorization chokepoint for every byte of candidate-supplied content. Access is always derived from the owning domain object; there are no public URLs. | Method + path | Purpose | Perm | Idem | Async | |---|---|---|---|---| | `POST /api/v1/documents` | Upload (`multipart`) attached to a subject (`candidate`, `application`, `offer`, `intake`) | `document.upload` | ✔ | ✔ (scan + parse) | | `GET /api/v1/documents/{id}` | Metadata only | derived from subject | – | – | | `GET /api/v1/documents/{id}/content` | **Redirects (302) to a short-lived signed URL** | derived + `document.download` | – | – | | `GET /api/v1/documents/{id}/extracted-text` | Parsed text (sensitive) | `document.read_extracted` | – | – | | `GET /api/v1/documents/{id}/parse` | Latest `ParsedDocument`: fields, per-field confidence, issues | `document.read_extracted` | – | – | | `POST /api/v1/documents/{id}/reparse` | New parse attempt with the current parser version | `document.manage` | ✔ | ✔ | | `GET /api/v1/documents/{id}/preview` | Rendered first-page image / text preview | derived | – | ✔ (first call) | | `GET /api/v1/candidates/{id}/documents` | CV revisions for a person | `candidate.read` | – | – | | `POST /api/v1/documents/{id}/soft-delete` | Recruiter removal | `document.delete` | ✔ | – | | `GET /api/v1/documents/{id}/versions` | Revision chain (`sha256` per revision) | `candidate.read` | – | – | | `GET /api/v1/document-types` | Reference list (CV, cover letter, certificate, offer letter, ID) | authenticated | – | – | **Request fields.** `subject_type`, `subject_id`, `document_type_key`, `file` (multipart), `replaces_document_id` (creates a revision rather than an unrelated row), `label`, `is_candidate_visible`. **Response fields.** `public_id`, `subject_type`, `subject_id`, `document_type`, `filename_original`, `mime_type_detected`, `byte_size`, `sha256`, `virus_scan_status` (`pending` \| `clean` \| `infected` \| `error`), `uploaded_by`, `uploaded_at`, `revision_no`, `replaces_document_id`, `parse_status`, `parse_confidence`, `page_count`, `is_candidate_visible`, `retention_class`, `deleted_at`. **Never returned:** the storage key, the bucket name, the internal path, or a long-lived URL. **Validation.** Size cap 25 MB **(assumption)**; MIME **sniffed server-side** and the client-declared type ignored for security decisions; extension/MIME mismatch recorded on the row, not silently trusted; allowlist by document type (a CV may be PDF/DOCX/DOC/RTF/TXT or an image for OCR; an offer letter is PDF only). `sha256` computed server-side — a re-upload of identical bytes for the same subject returns the existing document with `200` rather than creating a duplicate revision. **`virus_scan_status` must be `clean` before `GET /content`, `/extracted-text`, `/parse` or `/preview` returns anything**; a `pending` scan is `409 scan_pending` and `infected` is `403 scan_failed_infected` — permanently, with no override endpoint. Signed URLs: 5-minute TTL, single subject, `Content-Disposition: attachment`, response `Content-Type` forced to `application/octet-stream` for anything not on a render allowlist so an uploaded `.html` CV cannot execute in the recruiter's origin. **Idempotency.** Required on upload and reparse. **Async.** Upload returns a job handle covering virus scan → text extraction → parse; the document row exists immediately with `virus_scan_status: pending`. Reparse and first preview generation are async. **Audit.** Upload, soft delete, reparse, and **every** issue of a signed URL or read of extracted text — these are the highest-sensitivity access events in the platform (CV files and extracted text are classified `sensitive_personal`). The audit event records the document, the subject, the actor and the purpose parameter if supplied. A denied download is audited with `outcome: denied`. **Never exists.** No public or unsigned blob URL. No endpoint that returns bytes from a path containing the storage key. No endpoint that serves an uploaded document inline in the application's own origin. No hard delete except through the retention purge, which deletes blobs and records `retention_action` — and that is a scheduled job, not an HTTP endpoint callable per document. --- ### 2.14 ATS scoring **Modules:** `scoring`, plus `fairness_evaluation` as the activation gate. **Responsibility:** per-application match scores that are reproducible, explainable and version-pinned, and the configuration that produces them. The prototype has nothing to port: `aiScore: int(52,98)` (`js/data.js:123`) with a separate client-side relevance blend (`js/candidates.js:18`) — no components, no evidence, no model, no version. | Method + path | Purpose | Perm | Idem | Async | |---|---|---|---|---| | `GET /api/v1/applications/{id}/scores` | Current + superseded scores | `scoring.read` | – | – | | `GET /api/v1/scores/{id}` | One score with pinned versions | `scoring.read` | – | – | | `GET /api/v1/scores/{id}/explanation` | Per-criterion contributions with evidence | `scoring.read_explanation` | – | – | | `POST /api/v1/applications/{id}/rescore` | Rescore one application | `scoring.run` | ✔ | ✔ | | `POST /api/v1/jobs/{id}/rescore` | Rescore every active application on a job | `scoring.run` | ✔ | ✔ | | `POST /api/v1/scores/{id}/review` | Human review verdict on a score | `scoring.review` | ✔ | – | | `GET /api/v1/scoring-configs` | Config catalogue | `scoring.read_config` | – | – | | `POST /api/v1/scoring-configs` | Create a config identity | `scoring.configure` | ✔ | – | | `GET /api/v1/scoring-configs/{id}/versions` | Immutable version list | `scoring.read_config` | – | – | | `POST /api/v1/scoring-configs/{id}/versions` | Create an immutable version with criteria rows | `scoring.configure` | ✔ | – | | `GET /api/v1/scoring-config-versions/{id}` | One version with criteria and hyperparameters | `scoring.read_config` | – | – | | `POST /api/v1/scoring-config-versions/{id}/activate` | Activate — **gated on a passing fairness evaluation** | `scoring.configure` + step-up | ✔ | ✔ | | `POST /api/v1/scoring-config-versions/{id}/dry-run` | Score a sample set without persisting current scores | `scoring.configure` | ✔ | ✔ | | `GET /api/v1/scoring-config-versions/{id}/evaluations` | Fairness evaluation runs and metrics | `fairness.read` (business + legal, not only engineering) | – | – | | `POST /api/v1/scoring-config-versions/{id}/evaluations` | Run a disparate-impact evaluation | `fairness.run` | ✔ | ✔ | | `GET /api/v1/jobs/{id}/score-distribution` | Band histogram for a job | `scoring.read` | – | – | **Request fields.** Config version — `algorithm_key`, `algorithm_code_version`, `aggregation_method`, `band_thresholds`, `hyperparameters` (jsonb, algorithm-specific), `criteria[]` each `{criterion_key, weight, scale_min, scale_max, transform, is_mandatory_gate}`, `change_reason`. Review — `review_outcome` (`agree` \| `disagree_too_high` \| `disagree_too_low` \| `invalid_evidence`), `review_note`. Rescore — `reason`, `force` (bypass the `input_fingerprint` short-circuit). **Response fields (score).** `public_id`, `application_id`, `overall_score` (numeric 0–100), `band`, `computed_at`, `is_current`, `superseded_by_id`, and the **full pin set**: `job_version_id` + `version_no`, `scoring_config_version_id` + `version_no`, `candidate_document_id`, `parse_attempt_id`, `algorithm_code_version`, `ai_model_id`, `ai_model_version`, `prompt_template_version`, `input_fingerprint`. Plus `reviewed_by`, `reviewed_at`, `review_outcome`, `review_note`. **Response fields (explanation).** `criteria[]` each `{criterion_key, label, job_requirement_id, requirement_text, raw_value, normalised_score, weight_applied, contribution, is_mandatory_gate, gate_passed, matched_evidence}` where `matched_evidence` cites the source (document id, page, text span, or the candidate field), plus `skill_match` (`matched[]`, `missing[]`), `sum_of_contributions` (which must equal `overall_score` — the arithmetic is on disk, not recomputed), `ai_run_id`, and `caveats[]` (low parse confidence, missing CV, stale document). **Validation.** Criterion weights and gates validated on version creation; versions are **immutable** (`UPDATE`/`DELETE` revoked plus a trigger) so `PATCH` does not exist and returns `405`. Activation requires a completed `EvaluationRun` with a passing verdict for that exact version → `422 activation_blocked_pending_fairness_evaluation`; the response names the evaluation to run. Binding to jobs is via §2.7's `PUT /jobs/{id}/scoring-assignment` and must not overlap an existing active interval. Rescore refuses if `input_fingerprint` is unchanged and `force` is absent → `200` with `{"skipped": true, "reason": "inputs_unchanged"}`, so an impatient recruiter cannot generate score churn. A score cannot be written for a terminal application without `scoring.run_on_terminal`. **Idempotency.** Required on every write, including all four batch triggers. **Async.** **All** scoring runs are async: `POST /applications/{id}/rescore`, `POST /jobs/{id}/rescore`, `dry-run`, `activate` (the activation is synchronous, the consequent rescore batch is not), and every fairness evaluation. Scoring involves parsing lookups and a model call; a synchronous endpoint would time out and, worse, would put model latency on a recruiter's request thread. Reads (`/scores`, `/explanation`) are synchronous. **Audit.** Every score insert (`actor_kind: system` or `ai_agent`, with `on_behalf_of_user_id` when a human triggered it), every rescore trigger with its reason, every config version creation, every activation (high-risk, with the evaluation reference), every human review verdict, and **every read of `/scores/{id}/explanation`** — the explanation contains AI evidence classified `sensitive_personal`. **Scope filtering.** Score visibility is a **configurable role setting**: where it is off, `current_score` is omitted from application responses, `score` is not a sortable field, and `/scores/*` returns `403`. Explanations are a separate, narrower permission than scores — seeing a number and seeing the evidence that produced it are different disclosures. **Never exists.** No endpoint where a scoring result can set an application's status. No endpoint that returns a score without its pin set (a bare number with no provenance is exactly the prototype's failure). No endpoint that mutates `overall_score` in place — rescoring inserts a new row and flips `is_current`. No endpoint that recomputes historical contributions on read. No "auto-reject below threshold" endpoint, batch or otherwise, under any permission. --- ### 2.15 Duplicate review **Module:** `duplicate_review`. **Responsibility:** surface suspected duplicate candidates with per-signal evidence, support a human decision, and perform an **additive, reversible** merge with a per-row undo log. | Method + path | Purpose | Perm | Idem | Async | |---|---|---|---|---| | `GET /api/v1/duplicate-pairs` | Review queue (`?state=`, `?min_score=`, `?signal=`, `?detected_from=`; sort `-match_score`) | `duplicate.read` | – | – | | `GET /api/v1/duplicate-pairs/{id}` | Side-by-side comparison with per-signal values | `duplicate.read` | – | – | | `POST /api/v1/duplicate-pairs/{id}/confirm-distinct` | "Not a duplicate" — persistent, suppresses re-flagging | `duplicate.review` | ✔ | – | | `POST /api/v1/duplicate-pairs/{id}/confirm-duplicate` | Mark as duplicate without merging yet | `duplicate.review` | ✔ | – | | `POST /api/v1/duplicate-pairs/{id}/merge-preview` | Exactly what a merge would do, row by row | `duplicate.merge` | – | – | | `POST /api/v1/candidate-merges` | Execute a merge | `duplicate.merge` (HR-admin) + step-up | ✔ | ✔ | | `GET /api/v1/candidate-merges` | Merge history | `duplicate.read` | – | – | | `GET /api/v1/candidate-merges/{id}` | One merge with its operation log | `duplicate.read` | – | – | | `GET /api/v1/candidate-merges/{id}/operations` | The per-row undo log (`op_kind`, target, `previous_value`) | `duplicate.merge` | – | – | | `GET /api/v1/candidate-merges/{id}/reversal-preview` | What reversal would restore, and what stays | `duplicate.merge` | – | – | | `POST /api/v1/candidate-merges/{id}/reverse` | Unmerge | `duplicate.merge` + step-up | ✔ | ✔ | | `POST /api/v1/duplicate-scan` | Trigger a detection scan | `duplicate.manage` | ✔ | ✔ | | `GET /api/v1/duplicate-configs` / `POST .../versions` | Versioned matching config (thresholds, signal weights) | `duplicate.manage` | ✔ | – | **Request fields.** Merge — `surviving_candidate_id`, `merged_candidate_id`, `duplicate_pair_id`, `reason` (**required**), `field_choices` (per-field: keep survivor's or take the loser's — each becomes a `set_field` operation with `previous_value`), `acknowledge_preview_hash` (the hash of the preview the recruiter actually saw). Reverse — `reversal_reason`, `acknowledge_rows_that_stay` (the preview hash again). **Response fields (pair).** `public_id`, `candidate_a` / `candidate_b` summaries, `match_score`, `signals[]` each `{signal_key, value_a, value_b, similarity, weight, contributed}` — exact normalised email, exact E.164 phone, name trigram similarity, employer + title + employment-date overlap, identical document `sha256`, identical normalised LinkedIn URL — plus `detector_name`, `detector_version`, `matching_config_version`, `detected_at`, `state`, `reviewed_by`, `note`. **Response fields (merge).** `public_id`, `surviving_candidate`, `merged_candidate`, `performed_by`, `performed_at`, `reason`, `operation_count`, `operations_summary` (by `op_kind`), `is_reversible`, `reversal_blocked_reason`, `reversed_at`, `reversed_by`, `rows_that_would_stay[]`. **Validation.** Nothing merges automatically, ever — `performed_by_user_id` is `NOT NULL` and there is no endpoint with an automatic decision mode. Survivor ≠ loser. Both candidates must be visible to the caller. A pair in `confirmed_distinct` is not re-flagged and a merge against it requires an explicit state change first. Colliding applications to the same job: the earlier application stays live and the other is set `superseded_by_merge` — this is returned in the preview, not discovered afterwards. Colliding emails/phones/links are **suppressed**, never deleted (the partial unique index exists precisely to allow this). Reversal enforces **stack discipline**: a merge may be reversed only if no later unreversed merge touched any row it touched, else `409 merge_reversal_blocked_by_later_merge` **naming the blocking merge** so the recruiter knows what to reverse first. Rows created after the merge **stay with the survivor**, and `reversal-preview` must list them; the API refuses a reversal whose `acknowledge_rows_that_stay` hash does not match the current preview (`409 preview_stale`). If a retention purge has pseudonymised either candidate, reversal is refused with `reversal_blocked_reason: retention_purge`. There is no time limit otherwise. **Idempotency.** Required, and both merge and reverse additionally require the preview acknowledgement hash — this is a second, semantic idempotency layer, because a merge is the single most destructive-looking operation a recruiter performs and a stale-preview replay would apply choices they never saw. **Async.** Merge and reverse return job handles: re-parenting touches every table carrying `candidate_id`, plus search index refresh and notification side effects. Detection scans are async by nature. **Audit.** Detection runs (with detector and config versions), every review verdict, the merge itself (with the full operation count and the survivor/loser public_ids), and the reversal. The `candidate_merge_operation` undo log is **separate** from `audit_event` and is not an audit substitute — audit must stay append-only and hash-chained, while the undo log is operational data the feature reads and writes. **Never exists.** No auto-merge endpoint at any similarity threshold. No endpoint that hard deletes the losing candidate — its `public_id` is in sent emails and its `reference_code` may be on a paper interview note, so `GET /candidates/{loser}` `301`-redirects forever. No endpoint that reverses a merge out of order. No endpoint that returns duplicate signals to a caller who cannot see both candidates. --- ### 2.16 Recruiter assignments **Module:** `assignment`. **Responsibility:** flexible, historical ownership of jobs and applications. Replaces the prototype's single scalar `recruiter` / `recruiterId` (`js/data.js:96`, `js/data.js:123`), which cannot answer "who owned this in March". Two concrete resource families, not one polymorphic one, mirroring the two concrete tables (`job_assignment`, `job_application_assignment`) — a polymorphic subject FK cannot be enforced by the database at all. | Method + path | Purpose | Perm | Idem | Async | |---|---|---|---|---| | `GET /api/v1/jobs/{id}/assignments` | Current + historical (`?at=`, `?role=`, `?include_history=`) | `assignment.read` | – | – | | `POST /api/v1/jobs/{id}/assignments` | Assign a user to a role on a job | `assignment.manage` | ✔ | – | | `DELETE /api/v1/jobs/{id}/assignments/{assignment_id}` | End an assignment (sets `valid_to`, never deletes) | `assignment.manage` | – | – | | `POST /api/v1/jobs/{id}/assignments/transfer` | Atomically end one and start another in the same role | `assignment.manage` | ✔ | – | | `GET /api/v1/applications/{id}/assignments` | Same, per application | `assignment.read` | – | – | | `POST /api/v1/applications/{id}/assignments` | Assign on an application | `assignment.manage` | ✔ | – | | `DELETE /api/v1/applications/{id}/assignments/{assignment_id}` | End | `assignment.manage` | – | – | | `GET /api/v1/assignments/me` | My current jobs and applications by role | authenticated | – | – | | `GET /api/v1/users/{id}/workload` | Counts by role, stage and SLA state, over a window | `assignment.read` | – | – | | `GET /api/v1/assignment-roles` | Reference list (`primary_recruiter`, `supporting_recruiter`, `sourcer`, `coordinator`, `hiring_manager`, `interviewer`) | authenticated | – | – | | `POST /api/v1/assignments/bulk-reassign` | Reassign a departing recruiter's whole book | `assignment.manage` + step-up | ✔ | ✔ | | `GET /api/v1/assignments/owners-at?subject=&ts=` | Point-in-time ownership lookup | `assignment.read` | – | – | **Request fields.** `user_id`, `role_key`, `valid_from` (defaults to now), `valid_to` (optional; `null` = current), `reason`. Transfer — `from_user_id`, `to_user_id`, `role_key`, `effective_at`, `reason`, `notify` (boolean). Bulk reassign — `from_user_id`, `to_user_id`, `scope` (`all` \| `open_jobs` \| `active_applications`), `effective_at`, `reason`. **Response fields.** `public_id`, `subject_type`, `subject` (summary), `user` (summary), `role`, `valid_from`, `valid_to`, `is_current`, `assigned_by`, `reason`, `duration_days`. **Validation.** Exactly **one current `primary_recruiter` per job** — the partial unique index surfaces as `409 primary_recruiter_already_assigned` naming the incumbent; the correct call is `transfer`, and the API says so in the error message. No overlapping identical (subject, user, role) intervals — the GiST `EXCLUDE` constraint surfaces as `409 assignment_interval_overlap`. `valid_to > valid_from`. Assignee must be an active user holding a role compatible with the assignment role (an interviewer cannot be assigned `primary_recruiter`) → `422 role_incompatible`. Backdating an assignment beyond 30 days requires `assignment.backdate`, because assignment history feeds recruiter performance reporting. Ending the last `primary_recruiter` on an open job is refused → `422 job_would_have_no_primary_recruiter`. **Idempotency.** Required on create, transfer and bulk reassign. **Async.** `bulk-reassign` returns a job handle — it may touch hundreds of intervals plus notifications. Everything else is synchronous. **Audit.** Every assign, end, transfer and bulk operation, with actor and reason. The denormalised `job.current_primary_recruiter_id` is maintained by trigger, and the API never writes it directly — an endpoint that sets it would let the cache diverge from the interval table. **Never exists.** No `PATCH` that edits a historical interval's `valid_from` (history is not editable; a wrong assignment is ended and re-created, both audited). No polymorphic `POST /assignments` taking `subject_type` — the two families stay separate so the FK stays enforceable. No endpoint that returns another recruiter's full book to a peer recruiter without `assignment.read` at the appropriate scope. --- ### 2.17 Pipeline **Modules:** `pipeline` (the rules) + `application` (the state). **Responsibility:** stage definitions, per-job stage configuration, allowed transitions, and the board read model. Kept separate from applications deliberately: stage *configuration* is versioned reference data with a slow change cadence and a `configure` permission, while stage *state* changes constantly under an `edit` permission. Merging them would put a configuration screen behind a recruiter permission. | Method + path | Purpose | Perm | Idem | Async | |---|---|---|---|---| | `GET /api/v1/pipeline-stages` | Stage reference list (`key`, `label`, `order_index`, `is_terminal`, `job_family`, `colour`) | authenticated | – | – | | `POST /api/v1/pipeline-stages` | Create a stage | `pipeline.configure` | ✔ | – | | `PATCH /api/v1/pipeline-stages/{id}` | Rename, recolour, reorder, deactivate | `pipeline.configure` | – | – | | `GET /api/v1/pipeline-configs` | Configurations (default + per-job) | `pipeline.read` | – | – | | `GET /api/v1/pipeline-configs/{id}` | Stage sequence and transition rules | `pipeline.read` | – | – | | `PUT /api/v1/jobs/{id}/pipeline-config` | Bind or override a job's pipeline | `pipeline.configure` | – (`If-Match`) | – | | `GET /api/v1/jobs/{id}/board` | Board read model: stage columns with paginated cards | `application.read` | – | – | | `GET /api/v1/jobs/{id}/board/stages/{stage_key}` | One column, paginated (columns are independently paginated) | `application.read` | – | – | | `GET /api/v1/jobs/{id}/pipeline-metrics` | Per-stage counts, median time-in-stage, conversion, stalled count | `analytics.read` | – | – | | `GET /api/v1/pipeline-transition-rules` | Allowed transitions with required permission per edge | `pipeline.read` | – | – | | `GET /api/v1/applications/{id}/allowed-transitions` | Per-application, per-actor (also listed in §2.12) | `application.read` | – | – | **Request fields.** Stage — `key` (immutable), `label`, `order_index`, `is_terminal`, `job_family_key`, `colour_token` (must be an existing `var(--…)` design token, not a hex value — the design system is the contract). Pipeline config — `stages[]` (ordered stage keys), `transition_rules[]` each `{from_stage_key, to_stage_key, required_permission, requires_reason, requires_scorecard, auto_notify_template_key}`, `sla_days_per_stage`. **Response fields (board).** `stages[]` each `{stage, count, wip_limit, applications: {data: [...card...], page: {next_cursor}}}` where a card is `{application_public_id, candidate_display_name, current_title, score_band?, days_in_stage, avatar_url, assignee, next_interview_at, flags[]}` — deliberately **thin**: a board renders 200 cards and must not carry full candidate objects. `score_band` (not the number) appears only where score visibility is on. **Validation.** A stage may not be deleted once any application has occupied it — deactivate instead (`409 stage_in_use`). Reordering is allowed and does not rewrite history, because history stores stage references with their own intervals, not positions. A pipeline config must contain at least one terminal stage and exactly one entry stage. Changing a job's pipeline while applications are in flight requires a **mapping** for every occupied stage that is being removed (`422 stage_mapping_required`, `detail` lists the occupied stages) — silently orphaning in-flight applications is the failure mode this check exists for. Board columns are paginated **independently**; a single `?limit=` across the whole board is not offered because one column routinely holds 10× another. **Idempotency.** Required on stage creation. **Async.** None in this group. A pipeline re-map that must move applications enqueues per §2.12's bulk transition and returns that job handle. **Audit.** Stage catalogue changes, pipeline config changes (with `before`/`after` stage sequences), and any stage mapping applied during a re-map. Board reads are not audited. **Never exists.** No endpoint that moves an application as a side effect of a configuration change without an explicit, audited mapping. No endpoint that returns a board across all jobs unscoped. --- ### 2.18 Communications **Modules:** `notifications` (in-app + transactional outbound), `config` (templates). **Responsibility:** everything the platform sends, and the record of what was sent to whom. Phase 1 scope is deliberately narrow: write an in-app row; templated email lands in Phase 2. | Method + path | Purpose | Perm | Idem | Async | |---|---|---|---|---| | `GET /api/v1/notifications` | Own in-app notifications (`?unread=`, `?kind=`) | authenticated | – | – | | `POST /api/v1/notifications/{id}/read` | Mark read | authenticated (own only) | ✔ | – | | `POST /api/v1/notifications/read-all` | Mark all read | authenticated (own only) | ✔ | – | | `GET /api/v1/notifications/unread-count` | Badge count | authenticated | – | – | | `GET /api/v1/notification-preferences` / `PUT` | Own channel preferences per event kind | authenticated | – | – | | `GET /api/v1/message-templates` | Template catalogue with versions | `communication.read` | – | – | | `POST /api/v1/message-templates/{id}/versions` | New immutable template version | `config.manage` | ✔ | – | | `POST /api/v1/message-templates/{id}/preview` | Render with sample or real merge data | `communication.send` | – | – | | `GET /api/v1/messages` | Outbound message log (`?candidate=`, `?application=`, `?status=`, `?template=`) | `communication.read` | – | – | | `GET /api/v1/messages/{id}` | One message with delivery events | `communication.read` | – | – | | `POST /api/v1/messages` | Send a templated message to a candidate | `communication.send` | ✔ | ✔ | | `POST /api/v1/messages/bulk` | Send to ≤50 recipients from one template | `communication.send_bulk` | ✔ | ✔ | | `POST /api/v1/messages/{id}/resend` | Resend after a delivery failure | `communication.send` | ✔ | ✔ | | `GET /api/v1/candidates/{id}/communications` | Full thread with one candidate | `communication.read` | – | – | | `POST /api/v1/webhooks/email-events` | Provider delivery/bounce/complaint callbacks | signed provider secret | provider event id | – | | `GET /api/v1/suppression-list` / `POST` / `DELETE` | Do-not-contact entries | `communication.manage` | ✔ | – | **Request fields.** `template_key`, `template_version_id` (optional; defaults to current and is **pinned onto the message row**), `subject_type` + `subject_id` (`application` or `candidate`), `recipient_email_id` (a `candidate_email` reference, never a free-typed address), `merge_overrides`, `attachments[]` (document references), `send_at` (schedule), `reply_to_user_id`. **Response fields.** `public_id`, `template` + `template_version`, `subject`, `recipient` (masked to `j***@example.com` unless the caller holds `candidate.read` on that candidate), `status` (`queued` \| `sent` \| `delivered` \| `bounced` \| `complained` \| `failed` \| `suppressed`), `provider_ref`, `sent_at`, `delivered_at`, `failure_reason`, `rendered_subject`, `rendered_body_preview` (first 500 chars), `sent_by`, `delivery_events[]`. **Validation.** The recipient must be an existing, non-suppressed, non-merge-suppressed `candidate_email` row — **free-text recipient addresses are not accepted**, because an API that will email an arbitrary address on a recruiter's behalf is an open relay with an audit trail. Sending to a suppression-listed address is `422 recipient_suppressed`. Bulk cap 50 per request and rate-limited platform-wide. Template rendering is strictly escaped; merge fields are allowlisted per template and an unknown merge field is a `422 unknown_merge_field`, not a silently blank substitution. `send_at` within 30 days. Attachments must belong to the same candidate and be `virus_scan_status: clean`. **Idempotency.** Required on every send — this is the group where a duplicate is visible to the candidate. **Async.** All sends return job handles. Delivery is a provider round trip with retries; the message row exists immediately with `status: queued`. **Audit.** Every send (recipient, template version, subject_id, actor), every bulk send (one event per recipient plus a batch event), every suppression-list change, every delivery failure and complaint, and every read of `/candidates/{id}/communications` (it is a PII-bearing thread). Template version creation is audited; template *preview* with real merge data is audited as an access event because it renders candidate data. **Never exists.** No endpoint that sends to a free-form address. No endpoint that sends a rejection message automatically on a status transition without an explicit, separately audited send — an automatic-rejection-email rule would make the platform capable of rejecting a candidate without a human in the loop through the back door. No endpoint that returns another user's notification list. No unauthenticated unsubscribe endpoint that accepts a candidate id — unsubscribe uses a `candidate_access_token`. --- ### 2.19 Interviews **Module:** `interview`. **Responsibility:** scheduling with real timezone discipline, panel participants, and reschedule history. The prototype has none of this: plain JS `Date`s, `toLocaleDateString` for display and a hardcoded today (`js/data.js:54`, `js/data.js:237`). | Method + path | Purpose | Perm | Idem | Async | |---|---|---|---|---| | `GET /api/v1/interviews` | List (`?application=`, `?job=`, `?interviewer=`, `?status=`, `?starts_from/to=`, `?mode=`, `?mine=true`) | `interview.read` | – | – | | `POST /api/v1/interviews` | Schedule | `interview.schedule` | ✔ | ✔ (invites) | | `GET /api/v1/interviews/{id}` | Detail with participants and scorecard state | `interview.read` | – | – | | `PATCH /api/v1/interviews/{id}` | Non-time fields (mode, location, agenda, notes) | `interview.schedule` | – (`If-Match`) | – | | `POST /api/v1/interviews/{id}/reschedule` | New slot, retaining the previous as history | `interview.schedule` | ✔ | ✔ | | `POST /api/v1/interviews/{id}/cancel` | Cancel with reason | `interview.schedule` | ✔ | ✔ | | `POST /api/v1/interviews/{id}/status` | `confirmed`, `completed`, `no_show` | `interview.schedule` | ✔ | – | | `GET /api/v1/interviews/{id}/slots` | Reschedule history (each slot with its wall-clock intent) | `interview.read` | – | – | | `GET /api/v1/interviews/{id}/participants` / `POST` / `DELETE .../{participant_id}` | Panel management | `interview.schedule` | ✔ on POST | – | | `POST /api/v1/interviews/{id}/participants/{id}/respond` | Accept / decline / tentative (own only) | authenticated | ✔ | – | | `GET /api/v1/interviews/availability` | Free/busy for interviewers over a window | `interview.schedule` | – | – | | `POST /api/v1/interviews/suggest-slots` | Candidate slot suggestions honouring wall-clock working hours | `interview.schedule` | – | ✔ | | `GET /api/v1/users/{id}/availability-rules` / `PUT` | Wall-clock working hours (`weekday`, `local_start_time`, `local_end_time`, `timezone`) | own or `interview.manage` | – | – | | `GET /api/v1/calendar` | Unified read view over interviews + tasks (`?from=`, `?to=`, `?scope=mine\|team`) | authenticated | – | – | | `POST /api/v1/interviews/{id}/candidate-invite` | Issue a candidate-facing confirmation link | `interview.schedule` | ✔ | ✔ | | `GET /api/v1/public/interviews/confirm?t=` | Candidate confirmation (token-gated) | `candidate_access_token` | – | – | **Request fields.** `application_id`, `interview_type_key`, `mode` (`onsite` \| `video` \| `phone`), `starts_at` **or** (`local_start_wall` + `scheduling_timezone`), `duration_minutes`, `location_key` or `meeting_url`, `participants[]` each `{user_id, role (`interviewer` \| `panel_chair` \| `observer`), is_required}`, `scorecard_template_version_id`, `agenda`, `candidate_visible_note`, `send_invites`. **Response fields.** `public_id`, `application` (summary), `candidate` (summary), `interview_type`, `mode`, `starts_at` (UTC), `ends_at`, `scheduling_timezone`, `local_start_wall`, `status`, `participants[]` (with `response_status` and `scorecard_status`), `slot_no`, `previous_slots[]`, `scorecard_summary` (`submitted_count`/`required_count`, never the contents), `meeting_url` (omitted for callers who are not participants), `location`, `created_by`, `cancelled_reason`. **Validation.** `ends_at > starts_at`. `scheduling_timezone` must be a valid IANA name from `pg_timezone_names` — `PST`, `IST` and `+05:00` are rejected outright, because integrations will send exactly those. **Both** `starts_at` and `local_start_wall` are stored and the API is the single writer that keeps them consistent; a nightly reconciliation job reports divergence. Participant double-booking is refused by the GiST `EXCLUDE` constraint on overlapping slots for `scheduled`/`confirmed` participants → `409 participant_double_booked` naming the conflicting interview **only if** the caller may see it, otherwise naming only the participant and the window. At least one `is_required` interviewer. Interviews cannot be scheduled on a terminal application (`422 application_terminal`) or in the past beyond a 24-hour grace window. Rescheduling a `completed` interview is refused. Availability rules are stored as wall-clock rules with a timezone, never as instants — "available 09:00–17:00 local" converted to UTC is wrong twice a year. **Idempotency.** Required on schedule, reschedule, cancel, status change and invite issue. A duplicate calendar invite to a panel of five is five confused people. **Async.** Scheduling, rescheduling, cancelling and invite issue return job handles because each fans out to calendar sync (Microsoft Graph), participant notifications and a candidate-facing email. The interview row and its slot exist synchronously — the client can render the booking immediately. **Audit.** Create, every reschedule (with both slots), cancel, status change, participant add/remove, and candidate-invite issue. **Reads audited:** none by default; scorecard contents are §2.20 and are audited there. **Scope filtering.** An **interviewer** sees only interviews they participate in (`?mine=true` is implicit and enforced, not a client-supplied filter), and within those sees the candidate's name, title, skills and the CV attached to that interview — not contact details, compensation, scores, notes or other applications. `meeting_url` is omitted for non-participants. `/interviews/availability` returns **free/busy only** — never meeting titles, candidate names or subjects. **Never exists.** No endpoint that stores a UTC offset as a timezone. No endpoint that schedules by overwriting `starts_at` in place (reschedules create a new slot row so history survives). No unauthenticated endpoint that lists a candidate's interviews. No endpoint that returns another interviewer's calendar detail rather than free/busy. --- ### 2.20 Feedback **Module:** `interview` (scorecards). **Responsibility:** structured interview feedback against a versioned scorecard template, locked on submit. This is the "feedback" group of assignment §26.7 — scorecards *are* the feedback surface, and there is no separate feedback store. | Method + path | Purpose | Perm | Idem | Async | |---|---|---|---|---| | `GET /api/v1/interviews/{id}/scorecards` | Scorecards for an interview (**visibility rules below**) | `feedback.read` | – | – | | `GET /api/v1/scorecards/{id}` | One scorecard | `feedback.read` | – | – | | `POST /api/v1/interviews/{id}/scorecards` | Create own draft | participant only | ✔ | – | | `PATCH /api/v1/scorecards/{id}` | Edit own draft | author only | – (`If-Match`) | – | | `POST /api/v1/scorecards/{id}/submit` | Submit — **locks the scorecard** | author only | ✔ | – | | `POST /api/v1/scorecards/{id}/request-unlock` | Ask for an unlock, with reason | author only | ✔ | – | | `POST /api/v1/scorecards/{id}/unlock` | Grant an unlock (audited, time-boxed) | `feedback.unlock` (HR-admin) + step-up | ✔ | – | | `GET /api/v1/applications/{id}/feedback-summary` | Aggregated recommendations across interviews | `feedback.read_summary` | – | – | | `GET /api/v1/scorecard-templates` | Template catalogue | `feedback.read` | – | – | | `GET /api/v1/scorecard-templates/{id}/versions` | Immutable versions | `feedback.read` | – | – | | `POST /api/v1/scorecard-templates/{id}/versions` | New version (criteria, scales, required flags) | `config.manage` | ✔ | – | | `GET /api/v1/scorecards/pending/me` | My outstanding scorecards, with due dates | authenticated | – | – | **Request fields.** `scorecard_template_version_id` (pinned at creation), `criterion_scores[]` each `{criterion_key, score, note}`, `overall_recommendation` (`strong_yes` \| `yes` \| `no` \| `strong_no` \| `no_decision`), `strengths`, `concerns`, `competency_evidence`, `is_draft`. **Response fields.** `public_id`, `interview`, `interviewer` (author), `scorecard_template_version` (`version_no`), `criterion_scores[]` (with the template's labels, scale bounds and weights), `overall_recommendation`, `strengths`, `concerns`, `submitted_at`, `is_locked`, `unlocked_by`, `unlock_expires_at`, `edit_count`. **Validation.** Only a **participant** of the interview may create a scorecard, and only for themselves — `interviewer_user_id` is taken from the session, never from the request body. One scorecard per (interview, interviewer) → `409 scorecard_exists`. Submit requires every `is_required` criterion to be scored and an `overall_recommendation` (`422 required_criteria_missing`, `detail.fields` lists each). **Locked scorecards reject `PATCH` with `409 scorecard_locked`** — the unlock path is a separate, permissioned, time-boxed, audited grant, because "I'll just fix my rating after seeing the others" is the exact behaviour locking exists to prevent. Scores must fall inside the template version's scale bounds. Templates are immutable versions, so a submitted scorecard's criteria and scale can never be reinterpreted later. **Idempotency.** Required on create, submit and unlock. **Async.** None. Feedback is small, synchronous and interactive. **Audit.** Create, every draft save, submit (with the pinned template version), unlock request, unlock grant (high-risk, with reason and grantor), and **every read of another interviewer's scorecard**. Scorecards are classified `sensitive_personal`, so `before`/`after` payloads store hashes rather than free-text values. **Scope filtering — the important rule in this group.** An interviewer sees **their own** scorecards always, and other participants' scorecards **only after they have submitted their own** (configurable per `config` setting, default on). Rationale: visible peer ratings before submission produce anchoring, and the platform should not manufacture consensus. Recruiters and hiring managers on the application see all submitted scorecards. `feedback-summary` returns aggregate recommendations and, for callers without `feedback.read`, suppresses free-text and any breakdown that would identify a single interviewer's view. **Never exists.** No endpoint that edits a submitted scorecard in place without the audited unlock. No endpoint through which one interviewer reads another's unsubmitted draft — not for recruiters, not for hiring managers, not for admins; a draft is not a record. No endpoint that lets the AI write or amend a scorecard: AI-generated interview summaries, if they ever ship, are a distinct `suggestion` resource referencing an `ai_run_id`, attributed to the model, and never merged into a human's scorecard row. --- ### 2.21 Assessments **Module:** `assessment`. **Responsibility:** assign structured tests to an application and record results. Phase 3. | Method + path | Purpose | Perm | Idem | Async | |---|---|---|---|---| | `GET /api/v1/assessment-templates` | Catalogue (`?type=`, `?is_active=`) | `assessment.read` | – | – | | `GET /api/v1/assessment-templates/{id}/versions` | Immutable versions | `assessment.read` | – | – | | `POST /api/v1/assessment-templates/{id}/versions` | New version (sections, scoring, pass mark, duration) | `assessment.manage` | ✔ | – | | `GET /api/v1/assessments` | Assignments (`?application=`, `?status=`, `?template=`, `?due_from/to=`) | `assessment.read` | – | – | | `POST /api/v1/assessments` | Assign to an application | `assessment.assign` | ✔ | ✔ (invite) | | `GET /api/v1/assessments/{id}` | Detail | `assessment.read` | – | – | | `POST /api/v1/assessments/{id}/cancel` | Cancel with reason | `assessment.assign` | ✔ | – | | `POST /api/v1/assessments/{id}/extend` | Extend the deadline | `assessment.assign` | ✔ | – | | `POST /api/v1/assessments/{id}/resend-invite` | Reissue the candidate link | `assessment.assign` | ✔ | ✔ | | `POST /api/v1/assessments/{id}/results` | Record a result (manual entry or provider callback) | `assessment.record` | ✔ | – | | `GET /api/v1/assessments/{id}/results` | Result with section breakdown | `assessment.read_results` | – | – | | `GET /api/v1/public/assessments?t=` | Candidate's own assessment (token-gated) | `candidate_access_token` | – | – | | `POST /api/v1/public/assessments/submit?t=` | Candidate submission | `candidate_access_token` | ✔ | ✔ (grading) | | `POST /api/v1/webhooks/assessment-providers/{provider_key}` | External provider result callback | signed provider secret | provider event id | ✔ | **Request fields.** Assign — `application_id`, `assessment_template_version_id`, `due_at`, `timezone`, `proctoring_required`, `notify_candidate`, `note`. Result — `raw_score`, `max_score`, `percentage`, `passed`, `section_scores[]`, `provider_ref`, `completed_at`, `time_taken_minutes`, `evidence_document_id`. **Response fields.** `public_id`, `application`, `candidate` (summary), `assessment_template_version` (`version_no`, `pass_mark`), `status` (`assigned` \| `invited` \| `in_progress` \| `submitted` \| `graded` \| `expired` \| `cancelled`), `assigned_by`, `assigned_at`, `due_at`, `invite_sent_at`, `started_at`, `submitted_at`, `result` (scope-filtered), `attempt_no`, `extension_count`. **Validation.** Application must be active and in a stage where the template is permitted (`422 assessment_not_permitted_in_stage`). One active assignment per (application, template) → `409 assessment_already_assigned`; re-assignment after a terminal outcome increments `attempt_no`. `due_at` in the future, ≤ 60 days out. `max_extension_count` (2) enforced. Results: `raw_score ≤ max_score`, `percentage` derived server-side and never trusted from the client, `passed` derived from the pinned template version's pass mark — **not** supplied by the caller, because a client-supplied pass flag makes the pass mark decorative. Candidate submission is rejected after `due_at` unless a grace window is configured, and the token is `consumed_at`-stamped so a submission link is single-use. **Idempotency.** Required on assign, cancel, extend, resend, result recording and candidate submission. **Async.** Assign (invite email), resend, candidate submission (grading), and provider webhooks return job handles. **Audit.** Assign, cancel, extend, invite issue, result recording (with source: manual vs provider), and **every read of `/assessments/{id}/results`** — results are `sensitive_personal`. Provider callbacks are audited with `actor_kind: integration`. **Scope filtering.** Results are visible to the recruiter and hiring manager on the application; interviewers see `passed` and nothing else, and only for applications they are interviewing. Candidates see their own status through the token endpoint and see a score only if the template version is marked `is_result_candidate_visible`. **Never exists.** No endpoint that rejects an application because an assessment was failed — the failure is data, the rejection is a human transition under §2.12. No endpoint that returns another candidate's results through a candidate token. No endpoint that accepts a `passed` flag from the client. --- ### 2.22 Offers **Module:** `offer`. **Responsibility:** offer lifecycle with mandatory human approval and a human-confirmed issue step. Amounts are immutable `offer_version` rows, because a revised offer is a distinct document with its own approval, not a field edit. | Method + path | Purpose | Perm | Idem | Async | |---|---|---|---|---| | `GET /api/v1/offers` | List (`?application=`, `?job=`, `?status=`, `?created_from/to=`, `?expiring_within_days=`) | `offer.read` | – | – | | `POST /api/v1/offers` | Create an offer (draft version 1) | `offer.draft` | ✔ | – | | `GET /api/v1/offers/{id}` | Detail with current version and approval chain | `offer.read` | – | – | | `POST /api/v1/offers/{id}/versions` | New immutable revision | `offer.draft` | ✔ | – | | `GET /api/v1/offers/{id}/versions` | Revision history | `offer.read` | – | – | | `POST /api/v1/offers/{id}/versions/{version_id}/submit-for-approval` | Route to approvers | `offer.draft` | ✔ | ✔ | | `GET /api/v1/offers/{id}/approvals` | Approval steps with state, actor, comment | `offer.read` | – | – | | `POST /api/v1/offers/{id}/approvals/{step_id}/approve` | Approve | `offer.approve` (dept head / HR-admin) + step-up | ✔ | – | | `POST /api/v1/offers/{id}/approvals/{step_id}/reject` | Reject with reason | `offer.approve` | ✔ | – | | `POST /api/v1/offers/{id}/issue` | **Issue to the candidate — human confirmation required** | `offer.issue` + step-up | ✔ | ✔ | | `POST /api/v1/offers/{id}/withdraw` | Withdraw an issued offer | `offer.issue` + step-up | ✔ | ✔ | | `POST /api/v1/offers/{id}/record-response` | Record acceptance / decline / negotiation | `offer.edit` | ✔ | – | | `POST /api/v1/offers/{id}/extend-expiry` | Extend the deadline | `offer.edit` | ✔ | – | | `GET /api/v1/offers/{id}/letter` | The generated letter document (metadata; bytes via §2.13) | `offer.read` | – | – | | `POST /api/v1/offers/{id}/letter` | Generate the letter from a template version | `offer.draft` | ✔ | ✔ | | `GET /api/v1/offers/{id}/status-history` | Interval status history | `offer.read` | – | – | | `GET /api/v1/public/offers?t=` | Candidate's own offer (token-gated) | `candidate_access_token` | – | – | | `POST /api/v1/public/offers/respond?t=` | Candidate accept/decline | `candidate_access_token` | ✔ | ✔ | **Request fields (version).** `base_salary` (`{amount, currency_code}`), `components[]` each `{component_key, amount, currency_code, frequency, is_guaranteed}` (bonus, allowances, equity, relocation), `start_date`, `probation_months`, `notice_period_days`, `employment_type_key`, `grade_key`, `location_key`, `reports_to_user_id`, `expiry_at`, `special_terms`, `change_reason` (**required** on any revision), `reporting_currency_code` (optional — triggers an fx conversion pinned to an `fx_rate_id`). **Response fields.** `public_id`, `application`, `candidate` (summary), `job`, `current_version` (`version_no`, all money as amount+currency pairs, plus `total_annual_cost` with its own currency), `status` (`draft` \| `pending_approval` \| `approved` \| `issued` \| `accepted` \| `declined` \| `negotiating` \| `withdrawn` \| `expired`), `approval_steps[]`, `issued_at`, `issued_by`, `responded_at`, `response_note`, `expiry_at`, `letter_document_id`, `version_count`, `reporting_amounts` (with `fx_rate_id` and `as_of_date`). **Validation.** Every monetary field is an **amount + currency pair**; an amount without a currency is `422 currency_required_with_amount` (the schema `CHECK` makes it impossible anyway). Amount scale must match the currency's `minor_unit`. `salary_max ≥ salary_min` on ranges and both currencies equal. The offer's base salary outside the pinned `job_version`'s salary band requires `offer.exceed_band` and a stated justification → `422 salary_outside_band` with the band in `detail.meta`. The application must be in a pre-offer or offer stage and must not be terminal. **Issue requires:** status `approved`, every required approval step approved, a generated letter, an unexpired `expiry_at`, a step-up assertion no older than 5 minutes, and an explicit `confirm_issue: true` in the body. Approvals are sequential by `step_order` and an approver may not approve their own draft (`403 self_approval_blocked`). Recording a candidate response requires the offer to be `issued`. **Idempotency.** Required on **every** write in this group, and issue additionally requires the step-up assertion — a duplicate issue means two offer letters with possibly different numbers in a candidate's inbox, which is the worst single failure this API can produce. **Async.** Submit-for-approval (notifications), letter generation (PDF rendering), issue (letter delivery + candidate token issue), withdraw (notification), and candidate response (notifications) return job handles. Approval decisions themselves are synchronous. **Audit.** Every version creation with its `change_reason`, every approval decision with the exact version approved, issue (with the letter document `sha256`), withdrawal, candidate response (`actor_kind: integration` — the tokenised candidate-response endpoint is a service principal exactly as the careers form is; `actor_user_id` and `on_behalf_of` are both NULL and the token reference plus the candidate id are recorded in the event payload. **Not `actor_kind: candidate`** — `candidate` is not a member of the enum, per `_decisions.md` RULING-01), and every read of an offer or its letter. Compensation is `sensitive_personal`, so audit `before`/`after` store hashes, not amounts — the amounts live in the immutable `offer_version` rows where the retention purge can reach them. **Scope filtering.** Offer amounts are visible to the recruiter on the application, the hiring manager, approvers in the chain, and HR-admin. Interviewers never see offers at all. Analytics sees compensation only as aggregates above the minimum group size. **Never exists.** No endpoint that issues an offer without a human step-up confirmation — not a scheduled auto-issue, not a bulk issue, not an AI-triggered issue. No endpoint that edits an issued offer's amounts in place. No endpoint that returns an offer to a candidate token belonging to a different candidate. No bulk offer endpoint of any kind. --- ### 2.23 Talent pool **Module:** `talent_pool`. **Responsibility:** re-surface previously sourced candidates. Implemented as pools, tags and saved segments over `candidate`/`application` — **not** a new store, because re-surfacing is a query problem. | Method + path | Purpose | Perm | Idem | Async | |---|---|---|---|---| | `GET /api/v1/talent-pools` | Pools (`?owner=`, `?is_shared=`) | `pool.read` | – | – | | `POST /api/v1/talent-pools` | Create a pool | `pool.manage` | ✔ | – | | `GET /api/v1/talent-pools/{id}` | Pool with member count and criteria | `pool.read` | – | – | | `PATCH /api/v1/talent-pools/{id}` | Rename, share, archive | `pool.manage` | – (`If-Match`) | – | | `GET /api/v1/talent-pools/{id}/members` | Members (`?added_from/to=`, `?reason=`) | `pool.read` | – | – | | `POST /api/v1/talent-pools/{id}/members` | Add candidates (≤50 per call), with a reason | `pool.manage` | ✔ | – | | `DELETE /api/v1/talent-pools/{id}/members/{candidate_id}` | Remove | `pool.manage` | – | – | | `POST /api/v1/talent-pools/{id}/rematch` | Match the pool against a `job_version` | `pool.manage` | ✔ | ✔ | | `GET /api/v1/talent-pools/{id}/rematch-results/{run_id}` | Ranked matches with score bands | `scoring.read` | – | – | | `GET /api/v1/saved-segments` / `POST` / `PATCH` / `DELETE` | Saved candidate search definitions | `pool.manage` | ✔ on POST | – | | `POST /api/v1/saved-segments/{id}/run` | Execute a segment | `candidate.read` | – | ✔ if > 5k rows | | `GET /api/v1/talent-pools/{id}/export` | CSV/XLSX export of members | `pool.export` + step-up | – | ✔ | | `POST /api/v1/candidates/{id}/pools` | Add one candidate to pools from their profile | `pool.manage` | ✔ | – | **Request fields.** Pool — `name`, `description`, `is_shared`, `shared_with_role_keys[]`, `auto_criteria` (a saved-segment reference for a dynamic pool), `archive_after_days`. Members — `candidate_ids[]`, `reason_key` (`silver_medalist`, `future_fit`, `declined_offer`, `sourced_not_applied`, `internal_mobility`), `note`. Rematch — `job_version_id`, `scoring_config_version_id` (defaults to the job's binding), `limit`. **Response fields.** `public_id`, `name`, `owner`, `is_shared`, `member_count`, `is_dynamic`, `auto_criteria`, `created_at`, `last_rematch_at`. Members carry the candidate summary plus `added_by`, `added_at`, `reason`, `note`, `has_active_application`, `latest_application_outcome`. **Validation.** A candidate may be in many pools; one row per (pool, candidate) → duplicate add is a `200` no-op, not a `409`, because adding an already-present candidate is a benign recruiter action. Members must be visible to the caller — an add of an out-of-scope candidate is `404` for that id and the whole request fails (all-or-nothing). Pool membership does **not** grant visibility: a shared pool shows only the candidates the *viewer* may see, and `member_count` is the viewer-filtered count with `meta.total_hidden` when it differs. Adding a candidate to a pool requires a valid consent record for the retention/marketing purpose where the pool's purpose demands one (`422 consent_required_for_pool_purpose`). Dynamic pools may not also have manual members. **Idempotency.** Required on create, add and rematch. **Async.** Rematch (it is a scoring batch), export, and segment runs over large result sets. **Audit.** Pool creation and sharing changes, every member add and remove with reason, rematch runs (with the pinned job version and scoring config version), and **every export** — a talent-pool export is a bulk PII egress and is the single most likely route for candidate data to leave the platform. **Never exists.** No endpoint that adds every candidate matching a filter to a pool without an explicit id list or a saved segment with a stated purpose. No endpoint that exports candidate contact details without step-up and audit. No endpoint that returns pool members a caller could not see individually. --- ### 2.24 Analytics **Module:** `analytics`. **Responsibility:** read-only reporting over materialised read models. The only module permitted to read across boundaries, and it does so through read-only SQL views declared in migrations. | Method + path | Purpose | Perm | Idem | Async | |---|---|---|---|---| | `GET /api/v1/analytics/kpis` | Dashboard headline metrics (`?from=`, `?to=`, `?department=`, `?business_unit=`, `?recruiter=`) | `analytics.read` | – | – | | `GET /api/v1/analytics/funnel` | Stage-to-stage conversion | `analytics.read` | – | – | | `GET /api/v1/analytics/time-to-hire` | Distribution and medians (not just means) | `analytics.read` | – | – | | `GET /api/v1/analytics/time-in-stage` | Per-stage duration distribution | `analytics.read` | – | – | | `GET /api/v1/analytics/source-performance` | Volume, conversion and quality by channel | `analytics.read` | – | – | | `GET /api/v1/analytics/recruiter-performance` | Per-recruiter throughput and SLA | `analytics.read_team` | – | – | | `GET /api/v1/analytics/requisition-ageing` | Open requisitions by age band | `analytics.read` | – | – | | `GET /api/v1/analytics/score-distribution` | ATS band distribution (`?job=`, `?config_version=`) | `analytics.read` + `scoring.read` | – | – | | `GET /api/v1/analytics/offer-outcomes` | Accept/decline rates, compensation aggregates | `analytics.read_compensation` | – | – | | `GET /api/v1/analytics/intake-throughput` | Intake volume, parse success rate, triage latency | `analytics.read` | – | – | | `GET /api/v1/analytics/ai-usage` | Invocation counts, cost, latency, review outcomes by capability | `ai.read_usage` | – | – | | `GET /api/v1/reports` | Saved report catalogue | `analytics.read` | – | – | | `POST /api/v1/reports` | Save a report definition | `analytics.manage_reports` | ✔ | – | | `POST /api/v1/reports/{id}/run` | Run a saved report | `analytics.read` | ✔ | ✔ | | `GET /api/v1/report-runs/{id}` | Run status and result reference | `analytics.read` | – | – | | `GET /api/v1/report-runs/{id}/download` | Download output (CSV/XLSX) | `analytics.export` | – | – | | `POST /api/v1/reports/{id}/schedule` | Schedule recurring delivery | `analytics.manage_reports` | ✔ | – | | `GET /api/v1/analytics/meta` | Available dimensions, measures, freshness timestamps | `analytics.read` | – | – | **Request fields.** Every metric endpoint takes the same shape: `from`, `to`, `granularity` (`day` \| `week` \| `month` \| `quarter`), `group_by[]` (from a published closed dimension list), and the standard scope filters. Saved report — `name`, `metric_key`, `dimensions[]`, `filters`, `chart_type`, `is_shared`. **Response fields.** Every metric response carries the same envelope: ```json { "metric": "time_to_hire", "period": { "from": "2026-04-01", "to": "2026-06-30", "granularity": "month" }, "series": [ { "group": { "department": "engineering" }, "points": [ { "bucket": "2026-04", "value": 41.5, "n": 12 } ] } ], "totals": { "median": 39.0, "p90": 78.0, "n": 64 }, "meta": { "as_of": "2026-07-30T03:00:00Z", "is_stale": false, "read_model": "mv_application_funnel", "refreshed_at": "2026-07-30T03:00:00Z", "suppressed_groups": 2, "minimum_group_size": 5, "scope_applied": "department:engineering,platform" } } ``` `meta.as_of` and `refreshed_at` are **mandatory** on every analytics response — a dashboard number with no freshness stamp is how "the numbers don't match" arguments start. Nightly materialised-view refresh means analytics is deliberately not real-time, and the API says so rather than implying otherwise. **Validation.** `group_by` values must come from the published dimension list (`400 unknown_dimension`) — there is no free-form dimension grammar. `to ≥ from`; window capped at 3 years per request. `granularity` must be coarse enough for the window (`422 granularity_too_fine_for_window`). **Idempotency.** Required on report save and run (a run creates a `report_run` row). **Async.** Saved report runs and every export return job handles. The inline metric endpoints are synchronous because they read materialised views. **Audit.** Report definition changes, every report run, and **every download/export** — exports are audited PII egress even when aggregated, because a fine-grained breakdown is re-identifiable. Inline metric reads are **not** audited (§1.13.3). **Scope filtering — the load-bearing rule in this group.** A recruiter sees their own metrics; a hiring manager their requisitions; a department head their department; an executive aggregate figures across the platform. Scope is applied to the **read model query**, not to the response object, and `meta.scope_applied` states what was applied so a recruiter never mistakes their own funnel for the company's. **Minimum group size 5**: any group with fewer subjects returns `{"value": null, "suppressed": true, "reason": "below_minimum_group_size"}` and increments `meta.suppressed_groups`. Compensation aggregates require `analytics.read_compensation` and are additionally suppressed below a group size of 10 **(assumption — pending a legal review)**. **Never exists.** No analytics endpoint that returns row-level candidate or application records — that is §2.11/§2.12 with their own scope filtering. No endpoint that accepts a SQL fragment, a `WHERE` clause, a raw view name, or a client-supplied `ORDER BY` expression. No endpoint that bypasses the minimum-group-size floor "for admins" — an admin who needs a row-level answer uses the domain APIs where the access is audited per record. No cross-department recruiter-performance view for peer recruiters. --- ### 2.25 Chatbot **Modules:** `assistant` + `ai_orchestration`. **Responsibility:** a conversational surface over data the asking user can already see. Phase 2 read-only; Phase 4 tool-using. > **The single hardest constraint in this document.** The chatbot must never bypass access > controls, and that is only a real guarantee if something other than the prompt enforces > it. Three structural mechanisms, none of which is a prompt instruction: > 1. **No principal of its own.** `ai_orchestration.invoke()` takes the *human* actor and > calls `identity.can()` with that actor. There is no service account, no system > principal, and no elevated retrieval role. An endpoint that creates one is a design > violation, not a configuration choice. > 2. **No SQL, ever.** The assistant reaches data only through a **whitelisted set of > parameterised, typed query intents** that call the same module service facades the REST > API calls. Text-to-SQL against any application role is prohibited. > 3. **Every answer is an audited access event** with `actor_kind: ai_agent` and > `on_behalf_of_user_id` set to the asking user, plus the tool invocations it made. | Method + path | Purpose | Perm | Idem | Async | |---|---|---|---|---| | `GET /api/v1/assistant/conversations` | Own conversations only | authenticated | – | – | | `POST /api/v1/assistant/conversations` | Start a conversation | `assistant.use` | ✔ | – | | `GET /api/v1/assistant/conversations/{id}` | Own conversation with messages | authenticated (own only) | – | – | | `DELETE /api/v1/assistant/conversations/{id}` | Soft-delete own conversation | authenticated (own only) | – | – | | `POST /api/v1/assistant/conversations/{id}/messages` | Ask a question (streaming response) | `assistant.use` | ✔ | streaming | | `GET /api/v1/assistant/conversations/{id}/messages/{id}/citations` | The records an answer was built from | authenticated (own only) | – | – | | `GET /api/v1/assistant/capabilities` | What the assistant can answer, and what it cannot | `assistant.use` | – | – | | `POST /api/v1/assistant/messages/{id}/feedback` | Thumbs up/down plus a note | `assistant.use` | ✔ | – | | `GET /api/v1/ai/runs` | Invocation ledger (`?capability=`, `?status=`, `?actor=`) | `ai.read_runs` (admin + compliance) | – | – | | `GET /api/v1/ai/runs/{id}` | One run: model id + version, prompt template version, input ref, output, tokens, cost, latency | `ai.read_runs` | – | – | | `POST /api/v1/ai/runs/{id}/review` | Human review verdict on an AI output | `ai.review` | ✔ | – | | `GET /api/v1/ai/capabilities` | Capability registry with true per-capability availability | `ai.read_config` | – | – | | `PATCH /api/v1/ai/capabilities/{id}` | Enable/disable a capability, bind a prompt/model version | `ai.configure` + step-up | – | – | | `GET /api/v1/ai/prompt-templates/{id}/versions` | Immutable prompt versions | `ai.read_config` | – | – | | `POST /api/v1/ai/prompt-templates/{id}/versions` | New prompt version | `ai.configure` | ✔ | – | | `GET /api/v1/ai/suggestions` | Pending suggestions awaiting human acceptance (`?subject_type=`, `?status=`) | per subject module | – | – | | `POST /api/v1/ai/suggestions/{id}/accept` | Accept — **applies via the owning domain service** | the domain permission for the change | ✔ | – | | `POST /api/v1/ai/suggestions/{id}/dismiss` | Dismiss with a reason | per subject module | ✔ | – | **Request fields (message).** `content` (≤4000 chars), `screen_context` (`{route, subject_type, subject_id}` — so "summarise this candidate" works without the user retyping ids), `stream` (default true), `allow_tools` (Phase 4). **Never accepted:** `system_prompt`, `model`, `temperature`, `max_tokens`, `tools`, `sql`, `raw_query`, or any field that lets a caller reshape the model call. Model and prompt selection is server-side from the pinned capability configuration. **Response fields (message).** `public_id`, `role` (`user` \| `assistant`), `content`, `created_at`, `ai_run_id`, `model_id`, `model_version`, `prompt_template_version`, `citations[]` each `{entity_type, entity_public_id, label, field_path?}`, `tool_invocations[]` each `{tool_key, args_summary, result_count}`, `confidence_note`, `token_usage`, `latency_ms`, `refusal_reason` (when the assistant declined), `degraded` (true when the provider circuit breaker is open and a cached or reduced answer was served). **Streaming:** Server-Sent Events on the same endpoint when `Accept: text/event-stream`. Event types: `token`, `tool_start`, `tool_end`, `citation`, `done`, `error`. Served by the one async Django view under uvicorn — the only streaming endpoint in the platform. **Validation.** `content` length and a per-user daily quota (§1.11). `screen_context` subject must be visible to the caller — an out-of-scope `subject_id` is `404`, and the assistant is never told the row exists. Tool invocations are checked **individually** against `identity.can()` with the human actor; a tool that would return out-of-scope rows returns an empty set and the answer says the user does not have access, rather than the model hallucinating around the gap. If the provider is unavailable, `503 ai_provider_unavailable` with a plain-language message — TalentFlow degrades with AI absent rather than failing. **Idempotency.** Required on `POST .../messages` because it creates a message row and spends money. A replayed key returns the stored answer rather than re-invoking the model. **Audit.** Every message: an access `audit_event` with `actor_kind: ai_agent`, `on_behalf_of_user_id`, the `ai_run_id`, the model and prompt versions, and every tool invocation with its argument summary and result count. Every capability enable/disable, prompt version creation, and suggestion acceptance or dismissal. Reading the AI run ledger is itself an audited access event (it contains model inputs, which may contain candidate data). **Scope filtering.** Conversations are **strictly own-only** — there is no admin endpoint to read another user's conversation, because a conversation contains that user's questions and the answers are already audited at the access-event level. The run ledger is admin + compliance and shows run metadata; run *payloads* containing `sensitive_personal` data are further restricted to `ai.read_run_payloads`. **Never exists — the explicit prohibition list for this group.** - No endpoint accepting free-form SQL, a `WHERE` clause, a table or view name, a raw query, or a "query" string interpreted against the database. Not under an admin permission, not behind a feature flag, not in a debug namespace, not in staging. - No endpoint that runs the assistant as a service account, a system user, or with "elevated retrieval". - No endpoint where an assistant action writes domain state directly. AI output is a `suggestion` row referencing an `ai_run_id`; `POST /ai/suggestions/{id}/accept` applies it through the owning domain service under the **human's** permission, and the dependency graph makes the reverse impossible — `intelligence` modules may not import `core domain` write paths. - No endpoint through which the assistant sets an application status to `rejected`, or any terminal-negative value, under any circumstance. - No endpoint that returns another user's conversation, another user's raw audit log, or the full audit log filtered to a named individual for a non-compliance caller. - No prompt-injection escape hatch: `screen_context` is a typed reference, not free text, so candidate-supplied CV content can never arrive as instructions in a system position. --- ## 3. Endpoints returning sensitive fields (scope-filtered) Every row here is scope-filtered per §1.15 — row scope, then field omission (never masked nulls), then the aggregate floor. "Audited read" means the access is written to `audit_event` even though it is a `GET`. | Endpoint | Sensitive content | Class | Audited read | Notes | |---|---|---|---|---| | `GET /candidates/{id}` | contact details, expected/current salary, notes | personal + sensitive_personal | ✔ | interviewer tier sees name, title, skills only | | `GET /candidates/{id}/emails` \| `/phones` | contact channels | personal | ✔ | omitted entirely for interviewers | | `GET /candidates/{id}/notes` | recruiter free text about a person | sensitive_personal | ✔ | author always attributed | | `GET /candidates/{id}/timeline` | cross-module activity | personal | ✔ | | | `GET /candidates/{id}/export` | everything held | all | ✔ + step-up | async; bulk PII egress | | `GET /candidates/{id}/duplicate-candidates` | per-signal comparison values | personal | – | requires visibility of **both** candidates | | `GET /documents/{id}/content` | the CV itself | sensitive_personal | ✔ | 302 to a 5-min signed URL | | `GET /documents/{id}/extracted-text` \| `/parse` | full CV text, parsed fields | sensitive_personal | ✔ | separate permission from metadata | | `GET /intake/{id}/payload` | raw email envelope, headers, other recipients | personal, unfiltered | ✔ | `intake.read_raw` only | | `GET /intake/{id}/parse-attempts/{id}` | parsed jsonb | sensitive_personal | ✔ | | | `GET /applications/{id}` | current score, expected salary | sensitive_personal | – | score omitted where visibility is off | | `GET /applications/{id}/scores`, `GET /scores/{id}` | score + full pin set | sensitive_personal | – | `scoring.read` | | `GET /scores/{id}/explanation` | per-criterion evidence, AI evidence citations | sensitive_personal | ✔ | narrower permission than the score itself | | `GET /interviews/{id}` | meeting URL, candidate summary | personal | – | `meeting_url` omitted for non-participants | | `GET /interviews/availability` | interviewer calendars | internal | – | **free/busy only**, never titles or subjects | | `GET /interviews/{id}/scorecards`, `GET /scorecards/{id}` | ratings and free text | sensitive_personal | ✔ (others' cards) | own always; others' only after own submit | | `GET /applications/{id}/feedback-summary` | aggregated recommendations | sensitive_personal | – | free text suppressed below `feedback.read` | | `GET /assessments/{id}/results` | scores, section breakdown | sensitive_personal | ✔ | interviewers see `passed` only | | `GET /offers/{id}`, `/versions`, `/letter` | full compensation | sensitive_personal | ✔ | interviewers never see offers | | `GET /analytics/offer-outcomes` | compensation aggregates | sensitive_personal | – | min group size 10 (assumption) | | `GET /analytics/recruiter-performance` | individual staff performance | internal, staff-personal | – | `analytics.read_team`; no peer access | | `GET /report-runs/{id}/download`, `/talent-pools/{id}/export` | bulk PII | all | ✔ + step-up | the likeliest egress route | | `GET /ai/runs/{id}` | model inputs, which may contain candidate data | sensitive_personal | ✔ | payloads behind `ai.read_run_payloads` | | `GET /assistant/conversations/{id}` | the user's own questions | internal | – | strictly own-only, no admin read | | `GET /messages/{id}`, `/candidates/{id}/communications` | what was sent to a candidate | personal | ✔ | recipient masked without `candidate.read` | | `GET /audit-events` | everything, by design | all | ✔ | admin + compliance only; see §5 | --- ## 4. Platform endpoints not enumerated in assignment §26.7 Five surfaces the assignment list omits but the design requires — `audit`, async jobs, reference data, health, and `worklist`. Listed here so nobody invents an ad-hoc convention for them later. §4.5 is a real domain group, not a platform utility: it is here because §26.7 has no entry for it, and it needs a named home rather than being inferred from the prototype-route table in §7. ### 4.1 Audit (`audit` module) | Method + path | Purpose | Perm | |---|---|---| | `GET /api/v1/audit-events` | Query the log (`?entity_table=`, `?entity_public_id=`, `?actor=`, `?action=`, `?outcome=`, `?occurred_from/to=`) | `audit.read` (admin + compliance) | | `GET /api/v1/audit-events/{id}?occurred_at=` | One event — the partition key is **required** in the query, because the primary key is `(id, occurred_at)` | `audit.read` | | `GET /api/v1/candidates/{id}/audit-trail` | Everything that happened to one candidate | `audit.read` | | `GET /api/v1/audit-events/verify?partition=` | Recompute the hash chain for a partition and report | `audit.verify` | | `POST /api/v1/audit-events/export` | Compliance export for a window | `audit.export` + step-up, async | | `POST /api/v1/audit-redactions` | The narrow, logged erasure path for `personal`-class values in audit payloads | `audit.redact` + step-up | Cursor-paginated, capped at a 90-day window per request. The composite primary key `(id, occurred_at)` is a documented API characteristic, not an implementation detail — a client that assumes a single-id lookup will get a `400 partition_key_required`. There is no `PATCH` and no `DELETE`; the application role holds `INSERT` and `SELECT` only. ### 4.2 Async jobs (§1.14) `GET /api/v1/async-jobs`, `GET /api/v1/async-jobs/{id}`, `POST /api/v1/async-jobs/{id}/cancel`, `POST /api/v1/async-jobs/{id}/retry` (`worker.manage` for another user's job; own jobs otherwise). Plus `GET /api/v1/async-jobs/dead-letters` for `admin`. A job handle is a receipt, never a capability to read the result. ### 4.3 Reference data and configuration (`config` module) `GET /api/v1/reference/{vocabulary_key}` for each controlled vocabulary (`pipeline_stage`, `rejection_reason`, `source_channel`, `currency`, `employment_type`, `grade`, `education_level`, `skill`, `assignment_role`, `application_status`, `interview_type`, `document_type`), with `?include_inactive=`. Writes go through `POST/PATCH /api/v1/reference/{vocabulary_key}` under `config.manage` — **Phase 1's UI for these is the Django admin**, which is what lets the Settings screen wait until Phase 4. Plus `GET /api/v1/settings` / `PATCH` (workspace settings, `config.manage`) and `GET /api/v1/reference/bundle` — one cached call returning every vocabulary for SPA boot, `ETag`-validated, so the client does not make twelve requests on load. ### 4.4 Health and metadata (unversioned) `GET /healthz` (liveness), `GET /readyz` (database, queue, cache, storage, model provider circuit-breaker state), `GET /metrics` (Prometheus text). Not internet-reachable, no candidate data, never authenticated by a user session. ### 4.5 Worklist (`worklist` module) **Module:** `worklist` (`02` §4.4 module 25). **Responsibility:** recruiter tasks and SLA items, including AI next-best-action suggestions. **This is the API group for REQ-WRK-01 and REQ-WRK-02** — they are not served by `analytics` (§2.24), which owns no task endpoint. | Method + path | Purpose | Perm | Idem | Async | |---|---|---|---|---| | `GET /api/v1/worklist/tasks` | The task list (`?assignee=`, `?status=`, `?due_before=`, `?origin=`) | `worklist.read` | – | – | | `POST /api/v1/worklist/tasks` | Create a manual task | `worklist.create` | ✔ | – | | `POST /api/v1/worklist/tasks/{id}/complete` | Complete a task — always a human action | `worklist.complete` | ✔ | – | | `POST /api/v1/worklist/tasks/{id}/reassign` | Move a task to another assignee | `worklist.reassign` | ✔ | – | | `GET /api/v1/worklist/tasks/counts` | Open/overdue counts for the navigation badge | `worklist.read` | – | – | The four permission names map onto `05` §2.9 row 25's verbs: `read` = `V`, `create` = `C`, `reassign` = `E`, `complete` = `T`. That is why an `interviewer` (`V T [S]`) can complete their own scorecard-due task but cannot create or reassign one. **`worklist` contract note** (since §26.7 omits it): `Task(subject_ref, assignee, due_at, status, origin ∈ manual|rule|ai_suggestion)`. `origin: ai_suggestion` tasks carry an `ai_run_id` and a `rationale`, are visibly badged as AI-originated, and completing one is a human action under the assignee's permission — a task is never a state change in another module by itself. Permission: assignee and their manager. Idempotency required on create, complete and reassign. Audited on every write. **Why the path is `/worklist/tasks` and not `/tasks`.** The module name is the prefix, as it is for `/analytics/*` and `/assistant/*`, and a bare `/tasks` would collide with the async-job vocabulary of §4.2 the first time someone reads "task" as "queue task". No endpoint at `/api/v1/tasks` exists. **Never exists.** No endpoint that lets a rule or an AI capability transition an application — `05` §2 T-14 holds, and a rule may only create a task here asking a human to decide. No bulk complete-all. No cross-assignee write without `worklist.reassign`. --- ## 5. Asynchronous endpoints — the complete register Every endpoint here returns `202` with a job handle per §1.14 and **never** an inline result. If a client sees a synchronous result from one of these, that is a defect. | Endpoint | Job kind | Why it cannot be synchronous | |---|---|---| | `POST /intake/uploads` | `intake_parse` | Virus scan + PDF/OCR extraction is CPU-bound and multi-second per file; runs in the `worker` (untrusted queue from Phase 2) | | `POST /intake/{id}/reparse` | `intake_parse` | Same | | `POST /intake-channels/{id}/test` | `channel_test` | External round trip to Graph or a job board | | `POST /intake/dead-letters/{id}/retry` | `intake_replay` | Re-runs the full ingest chain | | `POST /webhooks/intake/{channel_key}` | `intake_ingest` | Provider needs a fast `202`; work happens after | | `POST /inbox/bulk-assign` | `inbox_bulk_assign` | Up to 50 rows plus notifications | | `POST /applications` | `score_application` (alongside a synchronous `201`) | The application is immediate; the first score is not | | `POST /applications/bulk-stage` | `application_bulk_transition` | Up to 50 transitions plus history plus notifications | | `POST /applications/{id}/rescore` | `score_application` | Parse lookups + a model call | | `POST /jobs/{id}/rescore` | `rescore_batch` | Fan-out across every active application | | `POST /jobs/{id}/versions/{id}/publish` | `requisition_publish` → `rescore_batch` | Version becomes current synchronously; the rescore does not | | `PUT /jobs/{id}/scoring-assignment` | `rescore_batch` | Same split | | `POST /scoring-config-versions/{id}/activate` | `rescore_batch` | Same split | | `POST /scoring-config-versions/{id}/dry-run` | `scoring_dry_run` | Sample-set scoring | | `POST /scoring-config-versions/{id}/evaluations` | `fairness_evaluation` | Statistical batch over historical outcomes | | `POST /candidate-merges` | `candidate_merge` | Re-parents every table carrying `candidate_id`, plus search reindex | | `POST /candidate-merges/{id}/reverse` | `candidate_merge_reverse` | Descending replay of the operation log | | `POST /duplicate-scan` | `duplicate_scan` | Trigram scan across the candidate base | | `POST /assignments/bulk-reassign` | `assignment_bulk_reassign` | Hundreds of intervals plus notifications | | `POST /documents` | `document_scan_and_parse` | Scan → extract → parse | | `POST /documents/{id}/reparse` | `document_parse` | Same | | `GET /documents/{id}/preview` (first call) | `document_preview` | Render then cache | | `POST /messages`, `/messages/bulk`, `/messages/{id}/resend` | `message_send` | Provider round trip with retries | | `POST /interviews`, `/reschedule`, `/cancel`, `/candidate-invite` | `interview_sync` | Calendar sync (Graph) + notifications + candidate email | | `POST /interviews/suggest-slots` | `slot_suggestion` | Free/busy queries across a panel | | `POST /assessments`, `/resend-invite` | `assessment_invite` | Invite delivery | | `POST /public/assessments/submit` | `assessment_grade` | Grading, possibly provider-side | | `POST /webhooks/assessment-providers/{key}` | `assessment_result_ingest` | Fast `202` to the provider | | `POST /offers/{id}/versions/{id}/submit-for-approval` | `offer_approval_notify` | Notification fan-out | | `POST /offers/{id}/letter` | `offer_letter_render` | PDF render from a template version | | `POST /offers/{id}/issue`, `/withdraw` | `offer_issue` / `offer_withdraw` | Letter delivery + candidate token issue | | `POST /public/offers/respond` | `offer_response_notify` | Notification fan-out | | `POST /jobs/{id}/publications`, `/unpublish`, `/sync` | `publication_*` | Slow, rate-limited, flaky external platform APIs | | `POST /talent-pools/{id}/rematch` | `pool_rematch` | A scoring batch | | `GET /talent-pools/{id}/export`, `GET /candidates/{id}/export` | `export` | Multi-table assembly plus blob packaging | | `POST /candidates/{id}/erasure-request` | `retention_pseudonymise` | Column pseudonymisation plus blob deletion plus merge-reversibility interlock | | `POST /reports/{id}/run`, `POST /audit-events/export` | `report_run` / `audit_export` | Large scans | | `POST /saved-segments/{id}/run` (>5k rows) | `segment_run` | Large result set | **Streaming, not async-job:** `POST /assistant/conversations/{id}/messages` streams SSE tokens. It is the only streaming endpoint, served by the one async Django view — deliberately one streaming mechanism, not a general one. --- ## 6. Endpoints that must never exist Each row is a prohibition with a reason, not a backlog item. "Never" means: not under an admin permission, not behind a feature flag, not in a debug namespace, and not in staging. | Must never exist | Why | What to use instead | |---|---|---| | Any endpoint accepting SQL, a `WHERE` clause, a table/view name, or a query DSL from the client — **especially** on the chatbot | An unbounded read capability that no prompt-level guard reliably constrains; it defeats every scope filter in one move | Whitelisted parameterised query intents through the module service facades (§2.25) | | `POST /candidates` (direct candidate creation) | `candidate.created_from_raw_intake_id` is `NOT NULL` against a non-deferrable FK; raw intake must exist first, and manual entry is not an exception | `POST /intake/manual` then `POST /intake/{id}/resolution` | | Any endpoint that returns another user's raw audit log, or the full audit log filtered to a named individual for a non-compliance caller | Audit is a forensic record, not a management surveillance feature; per-person filtering by a peer is exactly the misuse | `GET /audit-events` under `audit.read` (admin + compliance); `GET /candidates/{id}/audit-trail` for a candidate subject | | Any endpoint that sets an application status to `rejected` (or any terminal-negative value) without an authenticated **human** actor | "AI must never auto-reject" is enforced by a service guard and a `CHECK` constraint, and a bypass endpoint would silently undo both | `POST /applications/{id}/reject` with a human session and a `reason_key` | | An auto-merge endpoint, or a merge with `performed_by_user_id` null | A false-positive auto-merge cross-contaminates two real people's application and compensation history — a data-protection incident, not a bug | `POST /candidate-merges` with step-up and a preview acknowledgement | | A bulk auto-resolve endpoint for the inbox ("resolve all high-confidence") | Makes `decision_mode = automatic` the default path rather than the audited exception, and hollows out raw-intake-before-candidate | Per-item `POST /intake/{id}/resolution`; if the business wants automation it arrives as a configured rule with per-item `auto_create_evidence` | | Hard delete on `candidate`, `job`, `job_application`, `raw_intake`, `audit_event`, any `*_history`, any `*_version`, `candidate_merge`, `candidate_consent`, `ats_result` | Append-only and history tables are the requirement; deletion tears holes in funnels, breaks FKs and makes merge reversal unreplayable | Soft delete (distinct endpoint, distinct permission) or the retention pseudonymisation job | | An endpoint that presents soft delete as erasure, or one endpoint doing both | They have different legal meanings; conflating them in one call or one UI label is how a platform claims compliance it does not have | `POST /candidates/{id}/soft-delete` vs `POST /candidates/{id}/erasure-request` | | `PATCH` on any `*_version` row (job, scoring config, offer, prompt, scorecard template, assessment template) | Immutability is enforced by revoked `UPDATE` grants **and** a trigger; an endpoint would 500 at best and corrupt score reproducibility at worst | Create a new version | | An endpoint that lets a client choose `job_version_id` when creating an application | Letting a client pick the version it is scored against is a correctness hole | Server pins the current version at `applied_at` | | An endpoint that mutates `overall_score` in place | Destroys the historical score, which is the exact requirement | Insert a new `ats_result` and flip `is_current` | | An endpoint returning a score without its pin set | A bare number with no provenance is precisely the prototype's `aiScore: int(52,98)` failure (`js/data.js:123`) | `GET /scores/{id}` always includes the five pinned versions | | A public or unsigned blob URL, or any URL containing the storage key | Emailed and forwarded links leak; a storage key is a permanent capability with no expiry or revocation | `GET /documents/{id}/content` → 5-minute signed URL, audited | | An endpoint that serves an uploaded document inline in the application origin | An uploaded `.html` or SVG CV executing in a recruiter session is stored XSS with full application privileges (§E) | Forced `Content-Disposition: attachment` and a render allowlist | | An override that returns document content while `virus_scan_status` is `infected` | There is no legitimate reason, and the endpoint would exist solely to be misused under pressure | Re-request the document from the candidate | | An endpoint that emails a free-text recipient address | An API that emails arbitrary addresses on a recruiter's behalf is an open relay with an audit trail | `recipient_email_id` referencing a `candidate_email` row | | An automatic rejection email triggered by a status transition | Would let the platform reject a candidate without a human in the loop through the back door | An explicit, separately audited `POST /messages` | | An endpoint through which one interviewer reads another's **unsubmitted** scorecard draft | A draft is not a record, and visible peer ratings before submission manufacture consensus | Submitted scorecards, after the reader has submitted their own | | An endpoint that lets AI write or amend a human's scorecard | Attribution of a human judgement must stay intact | A distinct `suggestion` resource attributed to the model, with an `ai_run_id` | | An offer issue path without human step-up confirmation — scheduled, bulk, or AI-triggered | Two offer letters with different numbers in a candidate's inbox is the worst failure this API can produce | `POST /offers/{id}/issue` with `confirm_issue: true` and a fresh step-up assertion | | Any bulk offer endpoint | Offers are individually approved documents; bulk anything defeats the approval chain | One offer at a time | | An assistant service account, system principal, or "elevated retrieval" role | The access-control guarantee only holds because the assistant has no capability the asking user lacks | `ai_orchestration.invoke(actor=human)` | | An assistant endpoint accepting `system_prompt`, `model`, `temperature`, or `tools` from the client | Lets a caller reshape the model call and defeat the pinned, versioned capability configuration | Server-side selection from the capability registry | | An analytics endpoint returning row-level candidate or application records | Analytics scope and the minimum-group-size floor are its whole safety story | The domain APIs, where per-record access is audited | | An analytics "admin bypass" of the minimum-group-size floor | A fine-grained breakdown is re-identifiable; an admin needing a row-level answer should be audited per record | Domain APIs | | A generic `POST /batch` request multiplexer | Breaks per-request auditing, authorization and idempotency simultaneously | The named bulk endpoints, each capped and audited | | A generic `?filter[field][op]=value` grammar | An unbounded query surface (same risk class as text-to-SQL) that also defeats index planning | Enumerated per-endpoint filter params | | `POST /auth/impersonate` or any endpoint returning a session for another user | Destroys attribution in every history and audit row; "who did this" becomes unanswerable | Screen-share, or a scoped read permission | | An endpoint returning a password hash, or a `GET` login with credentials in the query string | Credentials in URLs land in access logs, referrers and browser history | POST body over TLS only | | An endpoint that sets `job.current_primary_recruiter_id` or any trigger-maintained denormalised column directly | Lets the cache diverge from the interval table that is the source of truth | Assignment endpoints; the trigger maintains the column | | An endpoint that edits a historical interval's `valid_from`/`valid_to` | History is not editable; an editable history is not a history | End the assignment and create a new one, both audited | | An unauthenticated endpoint that lists publications for a job, enumerates publication ids, or lists a candidate's interviews | Anonymous enumeration of hiring activity | `GET /public/jobs/{publication_id}` by exact id only | | An unauthenticated unsubscribe endpoint accepting a candidate id | Turns an identifier into a capability | A `candidate_access_token` | | Any endpoint whose write path does not carry `SET LOCAL app.actor_user_id` | History triggers would attribute the change to `system` with `actor_unknown = true` — a silent provenance gap | Middleware-wrapped transactions; the `actor_unknown` count is a monitored alarm | --- ## 7. The 23 prototype routes → API contract targets Source: the `ROUTES` map at `js/app.js:7-16`, rendered by a hash router with view functions returning HTML strings plus an `onMount` hook (`js/app.js:20-57`). The 23-route information architecture is a validated UX artefact (§G) and is preserved as the React route table, so this table is the frontend's contract target: **for each screen, which endpoints must exist before it can be built against real data.** "Boot" endpoints (`GET /auth/session`, `GET /reference/bundle`, `GET /notifications/unread-count`) are called by the shell on every route and are not repeated per row. | # | Route | Disposition | Primary endpoints it consumes | Phase | |---|---|---|---|---| | 1 | `dashboard` | Retained | `GET /analytics/kpis`, `/analytics/funnel`, `/analytics/requisition-ageing`, `GET /inbox/counts`, `GET /worklist/tasks?assignee=me`, `GET /interviews?mine=true&starts_from=today` | 2 | | 2 | `inbox` (FR-2) | Retained as a **view over `intake`** (§2.10) | `GET /inbox`, `/inbox/counts`, `/inbox/{id}`, `POST /inbox/{id}/claim`, `POST /intake/{id}/resolution`, `GET /intake/{id}/parse-attempts`, `GET /documents/{id}/preview` | 1 | | 3 | `jobs` | Retained | `GET /jobs`, `GET /jobs/{id}`, `POST /jobs`, `POST /jobs/{id}/versions`, `POST .../publish`, `GET /jobs/{id}/pipeline-summary`, `GET /jobs/{id}/assignments` | 1 | | 4 | `candidates` | Retained — **the screen most changed by the candidate/application split** | `GET /candidates`, `POST /candidates/search`, `GET /candidates/{id}`, `GET /candidates/{id}/applications`, `GET /candidates/{id}/documents`, `GET /candidates/{id}/timeline` | 1 | | 5 | `talentpool` (FR-5) | Retained as tags + saved segments over `candidate`/`application`, **not a new store** | `GET /talent-pools`, `/talent-pools/{id}/members`, `POST /talent-pools/{id}/rematch`, `GET /saved-segments` | 3 | | 6 | `pipeline` | Retained | `GET /jobs/{id}/board`, `GET /jobs/{id}/board/stages/{key}` (independent column pagination), `POST /applications/{id}/stage`, `GET /applications/{id}/allowed-transitions`, `GET /jobs/{id}/pipeline-metrics` | 2 | | 7 | `interviews` | Retained | `GET /interviews`, `POST /interviews`, `POST /interviews/{id}/reschedule`, `GET /interviews/availability`, `POST /interviews/suggest-slots`, `GET /scorecards/pending/me` | 2 | | 8 | `assessments` | Retained | `GET /assessments`, `POST /assessments`, `GET /assessment-templates`, `POST /assessments/{id}/results` | 3 | | 9 | `offers` | Retained | `GET /offers`, `POST /offers`, `POST /offers/{id}/versions`, `POST .../approve`, `POST /offers/{id}/issue`, `POST /offers/{id}/letter` | 3 | | 10 | `managers` (FR-15) | **Dissolved.** No Manager entity — a manager is an `app_user` with a role plus `assignment` rows | `GET /users?role=hiring_manager`, `GET /users/{id}/workload`, `GET /jobs?hiring_manager={id}`, `GET /offers?status=pending_approval` | 1 | | 11 | `calendar` (FR-16) | **Dissolved** into a read view over `interview` + `worklist`. No Calendar table | `GET /calendar?from=&to=&scope=mine\|team`, `GET /interviews`, `GET /worklist/tasks` | 2 | | 12 | `reports` | Retained | `GET /reports`, `POST /reports/{id}/run`, `GET /report-runs/{id}`, `GET /report-runs/{id}/download` | 3 | | 13 | `analytics` | Retained | the `GET /analytics/*` family, plus `GET /analytics/meta` for available dimensions and freshness | 2 | | 14 | `notifications` (FR-20) | Retained; Phase 1 writes an in-app row only, email templating lives in `config` | `GET /notifications`, `POST /notifications/{id}/read`, `GET/PUT /notification-preferences` | 2 | | 15 | `settings` (FR-22) | **Split.** security/users/roles → `identity`; vocabularies/templates/branding → `config`. **Phase 1 UI is the Django admin**, so this screen is deferred | `GET/PATCH /settings`, `GET/POST /reference/{vocab}`, `GET /message-templates`, `GET /users`, `GET /roles` | 4 | | 16 | `help` (FR-23) | **Dropped from the platform.** Static docs plus client-side search — no backend | none | 4 | | 17 | `import` (FR-7) | Retained as the second surface over `intake` (§2.9/§2.10) | `POST /intake/uploads`, `GET /async-jobs/{id}`, `GET /intake?state=parsing`, `GET /intake/{id}/parse-attempts`, `POST /intake/{id}/resolution` | 1 | | 18 | `jobboard` (FR-8) | Retained as a UI surface over `integrations_outbound` | `GET /job-publications`, `POST /jobs/{id}/publications`, `POST /job-publications/{id}/sync`, `GET /publication-platforms` | 4 | | 19 | `recruiterhub` (FR-9) | **Dissolved** into `assignment` (workload, history) + `analytics` (efficiency, SLA) | `GET /assignments/me`, `GET /users/{id}/workload`, `GET /analytics/recruiter-performance`, `GET /worklist/tasks?assignee=me` | 2 | | 20 | `aiassistant` | Retained | `POST /assistant/conversations`, `POST /assistant/conversations/{id}/messages` (SSE), `GET .../citations`, `POST /assistant/messages/{id}/feedback` | 2 | | 21 | `aistudio` (FR-19) | **Dissolved** into `ai_orchestration`'s admin surface. Must show **true per-capability availability**, not a coming-soon grid | `GET /ai/capabilities`, `PATCH /ai/capabilities/{id}`, `GET /ai/runs`, `GET /ai/prompt-templates/{id}/versions`, `GET /analytics/ai-usage` | 1 (framework) / 2-4 | | 22 | `rbac` | Retained, and **now functional** — the prototype's matrix is a display widget whose clicks mutate an in-memory array nothing reads (`js/rbac.js:78`, `js/rbac.js:83-85`, `js/rbac.js:111-112`) | `GET /permission-matrix`, `GET /roles`, `PUT /roles/{id}/permissions`, `POST /users/{id}/role-assignments`, `POST /permissions/explain` | 1 | | 23 | `tasks` | Retained, backed by the `worklist` API group — **not in assignment §26.7, so its contract is defined in §4.5** | `GET /worklist/tasks` (`?assignee=`, `?status=`, `?due_before=`, `?origin=`), `POST /worklist/tasks`, `POST /worklist/tasks/{id}/complete`, `POST /worklist/tasks/{id}/reassign`, `GET /worklist/tasks/counts` — full table and contract note in **§4.5** | 2 | **Three frontend consequences worth stating plainly:** 1. **Every list screen changes shape.** `UI.dataTable` sorts and paginates client-side over an in-memory array (`js/ui.js:251`); against these endpoints, sort and pagination are server-side query parameters with a closed allowlist and opaque cursors. 2. **The candidates screen splits.** The prototype's flat candidate carries `jobId`, `jobTitle`, `stage`, `aiScore` and `recruiter` (`js/data.js:117-127`). Against this API a candidate has an `applications[]` collection, each with its own stage, score and assignments, and the profile screen needs an application selector it does not currently have. 3. **Scores, salaries, notes and documents can be absent by permission, not by value.** Every screen showing them must handle field omission plus `meta.restricted_fields`, and must render a lock state rather than an em dash — `null` and "you may not see this" are different facts (§1.15). --- ## 8. CONFIRMED requirements vs UNCONFIRMED ideas Per the instruction to keep assignment §3 (confirmed) separate from §4 (unconfirmed). The API surfaces above are **not** all equally warranted, and the split matters for sequencing. **CONFIRMED — assignment §3. These API groups are required and their contracts are settled enough to build against:** authentication; users; roles and permissions; departments; regions and locations (as reference data); requisitions/jobs/job publications; recruitment intake; recruitment inbox; candidates; applications; documents; ATS scoring; duplicate review; recruiter assignments; pipeline; interviews; feedback; offers; analytics; audit; async jobs; reference data; worklist (§4.5 — FR-10, confirmed even though §26.7 omits the group). **UNCONFIRMED — assignment §4. Designed here so the boundary is not accidentally violated later, but not committed to Phase 1-2 and subject to change on business input:** | Unconfirmed area | What is speculative | Consequence for the API | |---|---|---| | Chatbot beyond read-only Q&A | The Phase 4 tool-using assistant, and which tools it may call | Only the read-only intent set is contracted; every write remains a `suggestion` requiring human acceptance. Adding tools must not add a principal. | | Assessments | Whether tests are built in-platform or bought from a provider; whether candidates take them through our portal | The provider-webhook and `public/assessments` endpoints are shaped as adapters precisely because this is unresolved | | Communications beyond transactional email | Sequences, nurture campaigns, SMS, WhatsApp | Only single templated sends and named bulk (≤50) are contracted. No campaign resource exists. | | Talent pool automation | Automatic pool membership from rules, automatic re-engagement | Dynamic pools are contracted; automatic outbound from a pool is not, and would need a consent gate | | Fairness evaluation thresholds | What "passing" means, and against what historical outcome data (BRD OQ-2 — no confirmed historic hiring outcome data exists) | The activation gate endpoint exists; the pass criteria are configuration, not API | | Job-board publishing | Which of the 8 platforms, at what cost, with what contracts | Platform adapters are behind one `platform_key`; no platform-specific endpoint exists | | Candidate self-service portal | Whether candidates get accounts, a status page, or only tokenised links | Contracted as **tokenised links only**. An account model would be a new authentication surface, not an extension of this one. | | Score visibility per role | BRD OQ-5 is open | Contracted as a configurable role setting with field omission, so either answer is a config change rather than an API change | | Model hosting | BRD OQ-1 — contracted API provider under a DPA is an **assumption** | If legal requires self-hosting, `/ai/*` contracts survive but latency, cost and the streaming endpoint's deployment change | --- ## 9. Inconsistencies found in `_decisions.md`, assumptions, and risks ### 9.1 Inconsistencies between Part 1 (Architecture) and Part 2 (Database) These are flagged rather than silently resolved. Where this document had to pick one, the pick is stated. Every one of them needs a one-line ruling before implementation, because an API contract cannot be written twice. > **Those rulings now exist, in `_open-items.md`.** This table is the evidence and the pick this > document made; the ruling is there, and the three rows that said "**Needs a ruling**" have one. > 1 → **RULING-06**: `/jobs` is the only resource tree, `/requisitions` is not minted, and > assignment §26.7's two groups survive as **two OpenAPI tags over one tree** (`requisitions` = the > versioning/approval workflow sub-resources, `jobs` = the record and its lifecycle) — add that tag > table to §2.6. 2 → RULING-01, already settled and already correct here. 3 → RULING-04. > 4 → RULING-02, and it confirms this row's diagnosis: the CI gate becomes "the introspected schema > matches the models", not "autogenerate produces no diff". 5, 6 → C-04 (Part 2's state sets win; > the renames are load-bearing and are recorded in `_glossary.md` §3). 7 → C-05. 8 → C-06. > 9 → OPEN-05 (owner: Talent Lead + Talha). 10 → **RULING-05**: the table is `app.stored_file` > (migration `005`); one row **can** be referenced by two subjects, which is the point, and retention > deletion has one place to look — so the resource shape this document chose is correct. > 11 → RULING-03. 12, 13 → C-07: `03` §22, §24 and §26.1 now model all of them, so the gap is filled. | # | Inconsistency | Part 1 says | Part 2 says | What this document did | |---|---|---|---|---| | 1 | **Aggregate name: requisition vs job** | Module `requisition`; entities `Requisition`, `RequisitionVersion`, `RequisitionRequirement` | Tables `job`, `job_version`, `job_requirement`, `job_posting`; reference code `JOB-1001` | Canonical resource path `/jobs`, with the requisition *workflow* as sub-resources (§2.6). `/requisitions` deliberately does not exist. **Needs a ruling** — assignment §26.7 lists "requisitions" and "jobs" as *separate* API groups, which would otherwise produce two resources for one row. | | 2 | **`actor_kind` enum values — load-bearing. RESOLVED** | `actor_type ∈ human/system/ai`, and the "AI never auto-rejects" guard is literally `actor_type != human` | `actor_kind ∈ user/system/integration/ai_agent` — **there is no `human` value** | Used Part 2's column name and value set; the guard is `actor_kind = 'user'`. This was the single most important naming inconsistency in the set, because a guard comparing against a value that does not exist in the enum fails closed on every legitimate human rejection or throws. **Settled by `_decisions.md` RULING-01 in favour of Part 2:** the column is `actor_kind`, the value set is exactly `('user','system','integration','ai_agent')`, and the guard literal is `actor_kind = 'user'`. Part 1's `actor_type`/`human` spelling has been corrected at source rather than adopted here — `user` is the only value that carries an `actor_user_id`, and the four-value set distinguishes `integration` (a service principal such as the careers-form endpoint) from `system` (a timer or unattributed trigger write), a distinction the `human/system/ai` triple cannot express. An earlier revision of this row recommended standardising on `human`; that recommendation is **withdrawn** — it would have required changing the one `CHECK` constraint that actually exists. | | 3 | **Assignment table shape — direct contradiction** | One table: `Assignment(subject_type ∈ requisition/application, subject_id, …)` — polymorphic | Explicitly **rejects** a polymorphic assignment table: "a polymorphic FK cannot be enforced by the database at all"; mandates **two** concrete tables `job_assignment` and `job_application_assignment` | Followed Part 2 → two resource families under `/jobs/{id}/assignments` and `/applications/{id}/assignments`, and no `POST /assignments` taking `subject_type` (§2.16). Part 1's module table should be corrected. | | 4 | **Migrations: ORM-first vs plain SQL** | Django 5 chosen partly *because* "migrations are built in and there is no migration tooling to inherit"; CI includes a `makemigrations --check` gate | Ordered plain-SQL migrations under `db/migrations`, "the ORM maps to the schema; it never generates it"; explicitly rejects Django autogenerate | Not an API question, but it **is** an API-contract risk: the invariants this document maps to `422` codes (deferrable triggers, partial unique indexes, `EXCLUDE` constraints, column-level `GRANT`s) live in SQL that Django does not model, so `makemigrations --check` cannot be the gate for them. Part 2 already flags this at its own risk list. **Needs a ruling**; the workable middle is Django models as read/write mappers over a hand-written schema, with the CI gate being "the ORM's inspected schema matches the migrated database", not "autogenerate produces no diff". | | 5 | **Intake entity names and state set** | `InboundSubmission`, `SubmissionAttachment`, `ProcessingAttempt`; states `received/parsing/parsed/failed/needs_review/discarded` | `raw_intake`, `raw_intake_attachment`, `intake_parse_attempt`, `intake_resolution`; states `received/parsing/parsed/needs_review/resolved_new_candidate/resolved_existing_candidate/rejected_unusable/quarantined` | Followed Part 2. `failed` became a **parse-attempt** status rather than an intake state, and `discarded` maps to `rejected_unusable`. Part 1's `failed` intake state has no equivalent and should be dropped — an intake whose parse failed is `needs_review`, which is the whole point of the layer. | | 6 | **Duplicate-review entity names and states** | `DuplicateCandidateLink(state ∈ suspected/confirmed/rejected)`, `MergeOperation` | `duplicate_candidate_pair(state ∈ open/confirmed_duplicate/confirmed_distinct/merged)`, `candidate_merge` + `candidate_merge_operation` | Followed Part 2. Part 1's `rejected` is Part 2's `confirmed_distinct`, and the rename matters: `confirmed_distinct` is load-bearing (it suppresses re-flagging forever), whereas `rejected` reads like a dismissed queue item. | | 7 | **Score entity: pin set** | `ApplicationScore(application_id, config_version, model_version, score, band, computed_at, superseded_by)` — **no `job_version_id`, no document or parse pin** | `ats_result` pins `job_version_id`, `scoring_config_version_id`, `candidate_document_id`, `parse_attempt_id`, `algorithm_code_version`, model and prompt versions, plus `input_fingerprint` | Followed Part 2 and made the full pin set a **mandatory response field** (§2.14). Part 1's shorter entity would not satisfy its own reproducibility requirement. | | 8 | **Pipeline configuration binding** | `PipelineConfig(requisition_version_id nullable = default)` — bound to a **job version** | `ref.pipeline_stage` keyed by `job_family_id`; stage config is reference data | Bound the pipeline config to the **job**, not the job version (`PUT /jobs/{id}/pipeline-config`). Binding it to a version would mint a fake job revision on every pipeline tweak — the exact failure Part 2 avoids for scoring configs by keeping `job_scoring_assignment` orthogonal. Part 1's shape should adopt the same orthogonality. | | 9 | **Role-assignment scope vocabulary — three value sets for one enum. PARTLY RESOLVED** | `RoleAssignment(scope: brand/department/requisition)` — three dimensions, one of which ("brand") names no table | `app.access_scope.scope_type` CHECK in (`global`, `business_unit`, `department`, `location`, `job`, `job_application`, `talent_pool`) — seven values, `job` for the requisition dimension, no `region`, no `brand`. `05` §2.3 and `adr/0009` add a *third* spelling: eight "dimensions" including `region`, `application`, `interview` and `explicit_grant` | **Adopted Part 2's enum as the single vocabulary — this document no longer carries its own.** An earlier revision of §2.3 listed `global/business_unit/department/requisition`, which was wrong three ways: it was a four-value subset of a seven-value CHECK, it spelled the requisition dimension `requisition` where the enum says `job`, and it dropped `location`, `job_application` and `talent_pool` — so a legitimate pool-scoped or application-scoped grant had no representable `scope_type`. Corrected: §1.15 reproduces `03` §7.3's seven values verbatim and §2.3 enumerates them per endpoint; §1.3 and §2.6 state once that `/jobs`, `scope_type = 'job'` and the `requisition` module are one aggregate, so `requisition` is `422 unknown_scope_type`, not a synonym. `05`'s eight *dimensions* are not a competing enum and are not treated as one: a dimension is a `(scope_type, origin)` pair over `v_user_effective_scope.origin` (`03` §7.5), and §1.15 publishes the one-to-one mapping — `interview` is `job_application` with `origin = interview_participation`, `explicit_grant` is a fourth `origin`, and neither is grantable through §2.3. **"brand" is `business_unit`**, per `_glossary.md`; the alias is dropped rather than aliased, because "brand" reads as tenancy in a system that has none. **Still open — `region` only, and narrower than it was.** `03` §6 and §7.3 now define `ref.region`, `ref.location.region_id` and `app.access_scope.region_id` with its exclusive-arc branch and its `scope_key` entry (migrations `002` and `011`), which closes the half of this that was a missing-schema defect — an earlier revision of this document recorded `region` as having no home in `03` at all, and that is stale. What is still open is only whether `region` is *grantable*: `'region'` in the `scope_type` CHECK, `_open-items.md` **OPEN-05** and `08` GAP-27. So it is documented as an eighth value *pending* adoption and rejected by the API as `422 unknown_scope_type` until the CHECK admits it, with §2.5 stating the reference-data-vs-grantable-dimension consequences on both sides. | | 10 | **Does a `StoredFile` / blob-metadata table exist? RESOLVED** | `files` module owns `StoredFile(sha256, mime, size, storage_key, scan_status, retention_class)` | `candidate_document` and `raw_intake_attachment` carry `object_store_key`, `sha256` and `virus_scan_status` **directly** | **Resolved — it is `app.stored_file`**, defined in `03-database-design.md` **§8.1** and ruled on in `03` **§32.1 row 2** ("adopts Part 1's registry **and** keeps Part 2's columns as denormalised domain data"). Both questions this row raised are answered there: (a) **yes**, one row can be referenced by two subjects — `uq_stored_file_sha256` makes content-addressing a hard rule, so two candidates who submit byte-identical CVs share one row and one virus scan, and `03` §8.1 states the retention consequence explicitly (a blob is deleted only when no non-purged row references it, tested by `NOT EXISTS`, never by a stored refcount); (b) **yes**, retention deletion has exactly one place to look, which is `files.delete_for_subject()` over `app.stored_file`. Note the schema: it is **`app.stored_file`**, not `files.stored_object` as `04` §9.1 #4 originally proposed — there is no `files` schema (`03` §1.1). `/documents/{id}` as modelled here is unaffected. | | 11 | Interview column names | `starts_at_utc`, `tz`, `local_time` | `starts_at`, `ends_at`, `scheduling_timezone`, `local_start_wall`, generated `slot` | Followed Part 2 (naming only; the semantics agree). | | 12 | Communications tables — **RESOLVED** | `notifications` module owns `Notification`, `NotificationPreference`, `OutboundMessage(status, provider_ref)`; templates in `config` | `_decisions.md` Part 2 does not enumerate them, but **`03-database-design.md` §22 does** | **Closed by `03` §22**, which models eight Communications tables: `message_template`, `message_template_version`, `message_thread`, `outbound_message` (with the pinned `message_template_version_id` this row asked for, plus immutable `subject_snapshot`/`body_snapshot`/`to_address_snapshot`), `outbound_message_event`, `notification`, `notification_preference` and **`communication_suppression`** — the last added specifically to back this document's `GET/POST/DELETE /api/v1/suppression-list` (§2.18) and `04` §2.11's bounce/unsubscribe handling. **One naming correction on this document's side:** the delivery-event table is **`outbound_message_event`**, not `message_delivery_event`; §2.18 and the audited-read list use the `03` name. Nothing remains open here. | | 13 | Assessments and talent-pool tables — **RESOLVED** | Modules 14 and 16 name `AssessmentTemplateVersion`, `AssessmentAssignment`, `AssessmentResult`, `Pool`, `TalentPoolMembership`, `SavedSegment` | `_decisions.md` Part 2 does not enumerate them, but **`03` §24 and §26.1 do** | **Closed.** `03` §24 models `assessment_template`, `assessment_template_version`, `assessment_assignment` and `assessment_result`; `03` §26.1 models `talent_pool`, `talent_pool_member` and `saved_search` (Part 1's `SavedSegment` is `saved_search` with `entity_kind = 'candidate'`, plus `talent_pool.is_dynamic` + `criteria` for a stored-query pool). §2.21 and §2.23 already assume exactly the conventions `03` applied — immutable versions, soft delete, append-only results — so no contract change is needed. Two `03` details the API must reflect: `assessment_result` is **insert-only** (the row is written when the result is known, and the pending state lives on `assessment_assignment.status_id`), and `talent_pool_member` uses `removed_at` rather than `deleted_at`. | ### 9.2 Where this document extends `_decisions.md` rather than contradicting it Stated so a reviewer can see what is genuinely new and therefore unratified: the two pagination modes and the no-`total`-on-cursor rule; the error envelope and validation-error shapes with the database-constraint → error-code mapping; the required-idempotency rule on creating `POST`s and the preview-acknowledgement hash on merge; `If-Match` on the six mutable aggregates; the `403`-vs-`404` scope rule; field **omission** rather than masking plus `meta.restricted_fields`; the analytics minimum-group-size floor; the async job-handle shape and its `partial` status; and the audited-read closed list. **`api_idempotency_record` is no longer an extension:** `03-database-design.md` §28.4 now models it as `app.api_idempotency_record` (unique on `(actor_user_id, method, path, idempotency_key)`, storing the response hash and the created resource reference, with a 24-hour TTL and a `maintenance`-queue sweep), created in Phase 1 migration `003`. The key is scoped **per actor**, which is stricter than §1.9 required and is deliberate — an unscoped key would let one user's retry collide with another's request. ### 9.3 Assumptions (all explicitly labelled) | Assumption | Why it matters | How to kill it | |---|---|---| | ~66 named seats, peak concurrency ~20–25 | Per-user rate limits rather than per-IP; no read replica; offset pagination acceptable on reference data | Confirm the seat count and the largest expected concurrent list-screen load | | 20k–60k applications/year, 200–600 documents/day at peak | Cursor pagination and async parsing are sized for this; a 10× miss changes the worker sizing, not the contracts | Ask the Talent Lead for last year's volumes | | 25 MB document upload cap, PDF/DOCX/DOC/RTF/TXT/images | Validation rules in §2.13 | Sample the real inbound mailbox | | Client polls `GET /async-jobs/{id}` in Phase 1; SSE for job progress is Phase 2 | One streaming mechanism only | Product decision on how live the intake screen must feel | | Compensation aggregates suppressed below a group size of 10 (vs 5 elsewhere) | §2.24 | Legal review | | Role names (`recruiter`, `hr_admin`, `hiring_manager`, `department_head`, `interviewer`, `executive`, `compliance`, `admin`) | Every permission column in §2 | The authoritative role list from `identity` / the BRD's 66 seats | | A contracted model API provider under a DPA (inherited from Part 1, BRD OQ-1) | The one streaming endpoint's deployment, and AI latency budgets | Legal ruling on model hosting | | Microsoft Graph is the Phase 1 mail channel and Entra ID the IdP | §1.2, §2.9's channel adapters | Corporate IT confirming the app registration and a dedicated recruiting mailbox | | Bulk caps of 50 (inbox assign, application stage, messages, pool members) | §1.16, several groups | Watch the first month's real recruiter batch sizes | ### 9.4 Risks this API surface carries - **Field omission is enforced only in the application layer in Phase 1.** Row-level security is deferred to Phase 2, so every `sensitive_personal` omission rule in §1.15 and §3 rests on serializer code in a codebase that today has no authorization at all (§D). Mitigation: one centralised authorization module, no direct model access from views, and a test asserting that **every** candidate-reading endpoint passes through `identity.can()`. Without that test this section is documentation, not a control. - **The audited-read list is a judgement call and will be wrong at the edges.** Auditing every list render is unusable; auditing too little fails a compliance question later. The closed list in §1.13.3 should be reviewed with whoever owns compliance before Phase 2, not after an incident. - **`403`-vs-`404` discipline is easy to lose.** One DRF view that raises `PermissionDenied` instead of `NotFound` on an out-of-scope object leaks existence. Mitigation: the scope filter runs on the queryset, so an out-of-scope row is genuinely absent and `404` is the natural outcome — plus a test per scoped endpoint. - **Required idempotency keys will be forgotten by the first integration written under time pressure.** The server rejecting the request is the mitigation, but it means an integration can fail closed on day one. Document it in the generated client's README, and make the generated TS client attach a key automatically for creating `POST`s. - **Async-everywhere makes the UI harder, not easier.** Fourteen distinct job kinds means the React app needs one shared job-handle component; if each screen rolls its own polling, the client becomes the least reliable part of the platform. This is a named piece of the junior's frontend workstream, with a Talha review checkpoint, not an incidental detail. - **The chatbot prohibition list is only as strong as code review.** Nothing in the architecture physically prevents someone adding `POST /assistant/query` with a SQL body; what prevents it is the dependency rule (`intelligence` may not import domain write paths), the absence of a service principal, and `import-linter` in CI. Add an explicit test asserting no view accepts a parameter named `sql`, `query`, `where` or `raw`. - **Two frontends will consume different contracts for 6–12 months.** The hardened prototype reads generated in-memory data (`js/data.js:8-10`); the React app reads this API. The rule from Part 1 holds: real data is only ever wired to the React app, and each migrated screen deletes its prototype counterpart in the same PR. An API that the prototype also starts calling would reintroduce the XSS exposure in §E on a live data path. - **`SET LOCAL app.actor_user_id` is a per-request contract, not a per-endpoint one.** Any API path that writes without it silently degrades history attribution to `actor_unknown`. Middleware plus a monitored `actor_unknown` count is the mitigation; the metric must be an alarm, not a dashboard nobody opens. The same holds for `app.source_service` (§1.12): its failure mode is quieter, because the row is still written and still attributed to an actor — only `'unknown'` where the writing tier should be — which is why `03` §31 makes the `source_service = 'unknown'` count part of the *same* alarm rather than a second one. - **Requirement-weight validation crosses a request boundary.** The weights-sum-to-1.0 check is a deferrable constraint trigger firing at `COMMIT`, so requirements **must** be written in one transaction. An API that offered `POST /jobs/{id}/versions/{id}/requirements` per-row would be unbuildable; §2.6 therefore has no such endpoint, and anyone adding one later will discover this the hard way.