2724 lines
222 KiB
Markdown
2724 lines
222 KiB
Markdown
# 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: <RFC 9110 date>` 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=<token>` 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
|
||
`<module>.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: <client-generated UUIDv4>`. 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=<ts>`, `?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.
|