222 KiB
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]=valuegrammar 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/_tofor time and date,_min/_maxfor 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 acceptq. - Unknown query parameters are a
400validation error, not ignored. Silently ignoring a misspelled?stauts=hiredreturns unfiltered candidate data to a screen that believes it is filtered. - Soft-deleted rows are excluded by default (reads go through
v_*_liveviews).?include_deleted=trueexists only on endpoints where the permission<module>.read_deletedis 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).
{
"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:
{
"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:
- First request with a key executes and stores the response.
- Replay with the same key and the same
request_body_sha256returns the stored response verbatim withIdempotency-Replayed: true. - Replay with the same key and a different body is
409 idempotency_key_reused_with_different_body. - A concurrent second request while the first is in flight is
409 idempotency_key_in_flight. - 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 POSTs — 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.
- Every state-changing request (
POST,PATCH,PUT,DELETE) writes at least oneaudit.audit_eventrow, in the same transaction as the change. There is no code path that mutates domain state outside a transaction carrying an actor. - 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.
- 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=trueread, 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. - Denials are audited:
403and412on state-changing endpoints, and every403on the audited-read list, writeoutcome: deniedwithdenial_reason. - 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 withouton_behalf_of_user_id. before/afterpayloads for columns classifiedsensitive_personalor above store a hash and the fact of change, not the value. Compensation, CV text, scorecards and AI evidence aresensitive_personal.- There is no API that updates or deletes an audit row. The application role holds
INSERTandSELECTonly. The single exception is the narrow, loggedaudit_event_redactionpath 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:
- 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 are404. - Field scope — a field the caller may not see is omitted from the response
object, never returned as
nulland never returned masked-but-present.nullis indistinguishable from "no value", which makes a UI render "Salary: —" for a restricted field and "Salary: —" for an unset one. Omission plus ameta.restricted_fields: ["expected_salary_amount"]array on the object tells the UI to render a lock icon truthfully. - 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:
requisitionis not a scope value (§1.3). The requisition dimension isjob.interviewis not a scope value. An interviewer's visibility is ajob_applicationscope withorigin = 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 aninterviewscope is a defect.explicit_grantis not a scope value either. It is a fourthoriginthat produces a scope row of whatever type the granted subject implies (candidate,job,job_application,interview,offer→ the correspondingscope_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.
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:
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 POSTs; 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
regionhere 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.mdPart 1 names the modulerequisitionwithRequisitionVersion/RequisitionRequirement; Part 2 names the tablesjob,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 routejobs, and theJOB-1001reference code atjs/data.js:95), and/requisitionsdoes not exist as a parallel resource. The requisition workflow — draft, approval chain, publish — is exposed as sub-resources under/jobs/{id}/versionsand/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 isapp.job.requisitionis not ascope_typevalue; a role-assignment request carryingscope_type: "requisition"is422 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_nos 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 POSTs.
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 Dates,
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:
{ "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:
- No principal of its own.
ai_orchestration.invoke()takes the human actor and callsidentity.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.- 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.
- Every answer is an audited access event with
actor_kind: ai_agentandon_behalf_of_user_idset 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
WHEREclause, 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
suggestionrow referencing anai_run_id;POST /ai/suggestions/{id}/acceptapplies it through the owning domain service under the human's permission, and the dependency graph makes the reverse impossible —intelligencemodules may not importcore domainwrite 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_contextis 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:
- Every list screen changes shape.
UI.dataTablesorts 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. - The candidates screen splits. The prototype's flat candidate carries
jobId,jobTitle,stage,aiScoreandrecruiter(js/data.js:117-127). Against this API a candidate has anapplications[]collection, each with its own stage, score and assignments, and the profile screen needs an application selector it does not currently have. - 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 —nulland "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:/jobsis the only resource tree,/requisitionsis 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 isapp.stored_file(migration005); 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 GRANTs) 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 POSTs 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_personalomission 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 throughidentity.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-404discipline is easy to lose. One DRF view that raisesPermissionDeniedinstead ofNotFoundon an out-of-scope object leaks existence. Mitigation: the scope filter runs on the queryset, so an out-of-scope row is genuinely absent and404is 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
POSTs. - 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/querywith a SQL body; what prevents it is the dependency rule (intelligencemay not import domain write paths), the absence of a service principal, andimport-linterin CI. Add an explicit test asserting no view accepts a parameter namedsql,query,whereorraw. - 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_idis a per-request contract, not a per-endpoint one. Any API path that writes without it silently degrades history attribution toactor_unknown. Middleware plus a monitoredactor_unknowncount is the mitigation; the metric must be an alarm, not a dashboard nobody opens. The same holds forapp.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 why03§31 makes thesource_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 offeredPOST /jobs/{id}/versions/{id}/requirementsper-row would be unbuildable; §2.6 therefore has no such endpoint, and anyone adding one later will discover this the hard way.