HR-ATS-Portal/docs/architecture/06-api-boundaries.md

222 KiB
Raw Permalink Blame History

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= (1100, default 25), ?cursor= (opaque, base64 of the sort tuple) { "data": [...], "page": { "next_cursor": "…"|null, "prev_cursor": "…"|null, "limit": 25 } }
Offset with total (only where a UI genuinely needs a page count) reference-data lists, roles, departments, saved reports ?limit=, ?offset= { "data": [...], "page": { "limit": 25, "offset": 0, "total": 412 } }

Rules: total is never returned on cursor endpoints — an exact count over a filtered candidate set is a full scan and the list screens do not need it; where a UI wants a sense of size it calls the group's …/count or facet endpoint, which may return {"count": n, "is_estimate": true}. Cursors are opaque and unstable across sort changes; changing sort invalidates a cursor and the API returns 400 invalid_cursor rather than silently reinterpreting it. limit > 100 is a validation error, not a silent clamp.

[API decision] Cursor-first. Tradeoff: cursor pagination cannot jump to page 7, and UI.dataTable in the prototype is a client-side sort/paginate widget over an in-memory array (js/ui.js:251) that assumes it holds everything. That widget is already slated for replacement by TanStack Table against server-side pagination, so the cost is already budgeted, and stable pagination over a table receiving continuous intake is worth more than page numbers.

1.5 Filtering

  • Filters are explicit, enumerated query parameters per endpoint. There is no generic ?filter[field][op]=value grammar and no ?q= that is interpreted as a predicate language. Rationale: a generic grammar is an unbounded query surface — the same class of risk as text-to-SQL — and it defeats index planning.
  • Equality: ?stage=screening. Multi-value OR: repeat the param, ?stage=screening&stage=interview.
  • Ranges: ?applied_from=, ?applied_to=, ?score_min=, ?score_max=, ?experience_months_min=. Suffix convention _from/_to for time and date, _min/_max for numbers.
  • Reference values are filtered by stable text key, never by numeric id (?rejection_reason=role_filled).
  • Free text: ?q= is a search parameter only, routed to the FTS + trigram path, never a field predicate. Groups that do not support search do not accept q.
  • Unknown query parameters are a 400 validation error, not ignored. Silently ignoring a misspelled ?stauts=hired returns unfiltered candidate data to a screen that believes it is filtered.
  • Soft-deleted rows are excluded by default (reads go through v_*_live views). ?include_deleted=true exists only on endpoints where the permission <module>.read_deleted is held.

1.6 Sorting

?sort=field ascending, ?sort=-field descending, multi-key comma-separated (?sort=-score,applied_at). Each endpoint publishes a closed allowlist of sortable fields in the OpenAPI schema; anything else is 400. Every sort is made total by appending public_id as the final tiebreaker, because a non-total sort makes cursor pagination skip and duplicate rows. Sorting on a scope-filtered field the caller cannot see (e.g. score for a role where score visibility is off) is 403, not a silent downgrade — a silent downgrade would let a caller infer ordering they may not see.

1.7 Error envelope

One shape, every non-2xx response, every endpoint, including framework-generated errors (DRF's default exception handler is replaced).

{
  "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_emailemail_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_liveactive_application_exists.

1.9 Idempotency keys

Header: Idempotency-Key: <client-generated UUIDv4>. Storage: app.api_idempotency_record (03-database-design.md §28.4, Phase 1 migration 003) — idempotency_key, actor_user_id, method, path, request_body_sha256, response_status, response_body_sha256, created_resource_table/_pk, state ∈ in_flight/completed/failed, first_seen_at, completed_at, expires_at — with UNIQUE (actor_user_id, method, path, idempotency_key) and 24-hour retention swept on the maintenance queue. The uniqueness key includes method and path as well as the actor, which is stricter than a bare (actor_user_id, key): it means a client reusing one key across two different endpoints gets two independent records rather than a false replay of the wrong response.

Semantics:

  1. First request with a key executes and stores the response.
  2. Replay with the same key and the same request_body_sha256 returns the stored response verbatim with Idempotency-Replayed: true.
  3. Replay with the same key and a different body is 409 idempotency_key_reused_with_different_body.
  4. A concurrent second request while the first is in flight is 409 idempotency_key_in_flight.
  5. The record row and the domain write commit in the same transaction, which is possible only because the queue and the data live in one Postgres — the same property that makes transactional enqueue work.
Verb / operation class Idempotency key Why
GET, HEAD, OPTIONS Not accepted Already idempotent; accepting a key would imply caching semantics we do not provide.
PUT, DELETE on a known resource Not required Idempotent by definition. DELETE of an already-deleted row is 204, not 404.
PATCH Not required, but If-Match is required (see §1.10) Concurrency, not duplication, is the risk on a partial update.
POST that creates a domain row Required Every one of these is a retry-on-timeout candidate from a browser or an integration, and a duplicate is a real defect: a duplicate application inflates every funnel metric, a duplicate candidate lands in the dedupe queue, a duplicate intake row breaks UNIQUE (channel_id, external_message_id) accounting.
POST that sends something irreversible outside the system (offer issue, message send, publication) Required, and step-up auth The failure is not a stray row, it is two offer letters in a candidate's inbox.
POST that enqueues an async job (rescore batch, reparse, publish, bulk import) Required Prevents N identical batches from one impatient double-click; the key also becomes the dedupe key on the queue via procrastinate's queueing lock.
POST that is a pure query (/candidates/search, /analytics/*/query, /assistant/messages retrieval) Not required No state change. Note /assistant/messages does create a message row and therefore does require a key.
Webhook receivers (/api/v1/webhooks/*) Provider key or provider message id, whichever exists; channel_id + external_message_id is the durable backstop Providers retry by design; the database unique constraints are the last line.

[API decision] Required, not optional, on creating 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 ~2025) sit behind a small number of office egress IPs.

Bucket Limit Notes
Global authenticated 600 req/min/user Generous; catches runaway client loops.
Login / token endpoints 10 attempts / 15 min / (username + IP), exponential backoff Plus account lockout counter.
Candidate-facing /public/* 20 req/min/token, 60 req/min/IP Untrusted callers.
POST /assistant/messages 20/min, 300/day/user Real per-call money. Returns 429 with Retry-After and detail.quota_reset_at.
POST /*/rescore, POST /intake/*/reparse (async batch triggers) 5/hour/user, 20/hour platform-wide Batch fan-out; guarded by queueing locks as well.
Document upload 30/min/user, 200 MB/hour/user Parser CPU is the scarce resource.
Search (?q=, /candidates/search) 120/min/user FTS + trigram cost.
Export endpoints 5/hour/user Every export is an audited PII egress event.

Responses carry RateLimit-Limit, RateLimit-Remaining, RateLimit-Reset and, on 429, Retry-After. Rate-limit rejections are audited when they occur on a PII-reading or export endpoint (outcome: denied, denial_reason: rate_limited) so a scraping attempt is visible; ordinary limit hits are metrics only.

1.12 Correlation ids

Header / field Rule
X-Request-Id Accepted from the client if it is a valid UUID, otherwise generated. Echoed on every response including errors. Stored on audit_event.request_id.
Transaction propagation Middleware issues SET LOCAL app.request_id, app.actor_user_id, app.actor_kind, app.change_reason, app.source_service at transaction start — the five GUCs 03 §31 requires, no subset. This is the bridge the history and audit triggers read; if they are missing, history rows are written with actor_kind='system' and actor_unknown=true, and audit.audit_event.source_service takes its 'unknown' default (03 §28.1). Any API path that writes domain state without these settings is a defect, and the actor_unknown count and the source_service = 'unknown' count are one monitored data-quality alarm, not noise.
app.source_service Names the code path, not the actor, and it is the one GUC whose value is a property of the process rather than the request: the API tier always sets web, the worker sets worker, the per-channel integration adapters set integration_adapter, the assistant's own write paths set assistant, migrations set migration and the Django admin sets admin. The values are 03 §28.1's CHECK list exactly — web, worker, integration_adapter, migration, admin, assistant, unknown — and unknown is never set deliberately; it is the default that raises the alarm. It is set once in the process's request/job transaction wrapper and never derived per endpoint, because a per-view value is a per-view omission. Without it audit_event answers "which code path did this" with nothing, and request_id cannot substitute: one recruiter action propagates a single request_id through the API layer, a queue job and an ai_model_invocation, which is why it correlates them and why it cannot discriminate between them.
Async continuity The enqueued job inherits request_id as parent_request_id and gets its own request_id, and re-issues all five GUCs in its own transaction with app.source_service = 'worker' — the job runs in a different process from the request that enqueued it, so nothing is inherited implicitly. So an intake → parse → score chain is one query in the audit log, with the tier that wrote each row named on each row.
AI continuity ai_model_invocation rows carry request_id; ats_result and every suggestion carry the invocation reference. "Which HTTP request caused this score" is a join, not an investigation.
Client exposure X-Request-Id is safe to surface in the UI (it is in the error envelope already). Internal span/trace ids are not returned.

1.13 The audit rule

Stated once, and every group section below only says what is extra.

  1. Every state-changing request (POST, PATCH, PUT, DELETE) writes at least one audit.audit_event row, in the same transaction as the change. There is no code path that mutates domain state outside a transaction carrying an actor.
  2. Data-change events are written by the generic trigger on classified tables. The API layer does not hand-roll them and must not duplicate them.
  3. Access events cannot be observed by a trigger and are therefore explicit API-layer writes. The closed list of audited reads: candidate profile view, CV/document download or signed-URL issue, ATS score explanation view, interview scorecard view, offer view, any export/report download, any assistant answer, any subject-access export, any include_deleted=true read, and any candidate-facing token redemption. Ordinary list-screen reads are not audited — auditing every list render produces a log nobody can query and a write amplification nobody budgeted.
  4. Denials are audited: 403 and 412 on state-changing endpoints, and every 403 on the audited-read list, write outcome: denied with denial_reason.
  5. Every AI-influenced decision writes an event carrying the invocation reference, model id and version, prompt template version, and the human actor. There is no actor_kind='ai_agent' event without on_behalf_of_user_id.
  6. before/after payloads for columns classified sensitive_personal or above store a hash and the fact of change, not the value. Compensation, CV text, scorecards and AI evidence are sensitive_personal.
  7. There is no API that updates or deletes an audit row. The application role holds INSERT and SELECT only. The single exception is the narrow, logged audit_event_redaction path used for erasure requests, which is an admin+compliance operation and is itself audited.

1.14 Asynchronous work: the job-handle convention

Any operation that cannot reliably finish inside a request returns a job handle, never a result. One shape for all of them.

POST /api/v1/jobs/{id}/rescore        →  202 Accepted
Location: /api/v1/async-jobs/018f9a…
{ "async_job": { "id": "018f9a…", "kind": "rescore_batch", "status": "queued",
                 "submitted_at": "…", "progress": null, "result_url": null } }

GET /api/v1/async-jobs/{id}           →  200
{ "async_job": { "id": "018f9a…", "kind": "rescore_batch", "status": "running",
                 "progress": { "done": 41, "total": 260, "percent": 15.8 },
                 "submitted_at": "…", "started_at": "…", "finished_at": null,
                 "result_url": null, "error": null,
                 "attempts": 1, "max_attempts": 5 } }
Rule Value
Status set queued, running, succeeded, partial, failed, cancelled, dead_letter. partial is a first-class outcome for batch operations and for parsing — a parse that extracted a name but no employment history is partial, not failed.
Terminal shape On succeeded/partial, result_url points at the domain resource (/applications/{id}/scores, /intake/{id}), not at an inline blob. The job handle is a receipt; the domain is the source of truth.
Failure shape error uses the same {type, code, message, detail} object as §1.7, so one client renderer handles sync and async failures.
Never silent A failed or dead_letter job surfaces in the owning module's UI queue (intake needs_review, failed state). There is no drop.
Cancellation POST /async-jobs/{id}/cancel — permitted only while queued, and only for the submitter or an admin. A running parse or model call is not cancellable in Phase 1; the endpoint returns 409 job_not_cancellable. Stated as a limitation rather than faked.
Permission Visible to the submitter, to holders of the permission that created it, and to admins. A job handle is not a capability to read the result — the result_url is authorized independently.
No polling-only design The React client polls GET /async-jobs/{id} with backoff (1 s → 5 s → 15 s) in Phase 1. (assumption) Server-Sent Events on /async-jobs/{id}/events is a Phase 2 addition; WebSockets are not planned. Chosen because one streaming endpoint (the assistant) is enough streaming infrastructure for two developers.

1.15 Sensitive fields and the scope-filtering rule

Three orthogonal mechanisms, applied in this order on every read:

  1. Row scopeidentity.scopes_for(user) narrows the queryset before any filter the client sent. A recruiter sees candidates and applications within their assignments, department and business unit; an interviewer sees only candidates attached to an interview they participate in; a hiring manager sees their requisitions; a department head sees their department; executives see aggregates. Out-of-scope rows are 404.
  2. Field scope — a field the caller may not see is omitted from the response object, never returned as null and never returned masked-but-present. null is indistinguishable from "no value", which makes a UI render "Salary: —" for a restricted field and "Salary: —" for an unset one. Omission plus a meta.restricted_fields: ["expected_salary_amount"] array on the object tells the UI to render a lock icon truthfully.
  3. Aggregate floor — analytics responses suppress any group with fewer than 5 subjects ({"count": null, "suppressed": true, "reason": "below_minimum_group_size"}), so a "time to hire by department" breakdown cannot be used to infer one person's outcome.

The authorization scope vocabulary — one value set, reproduced not restated

scope_type is the load-bearing input to identity.can() and to every 403/404 decision in this document, so it may have exactly one value set. That set is app.access_scope.scope_type (03 §7.3) and nothing else. Seven values today:

global · business_unit · department · location · job · job_application · talent_pool

05 §2.3 and adr/0009 describe eight dimensions, which is a different thing and not a competing enum: a dimension is a (scope_type, origin) pair, where origin is the v_user_effective_scope column (03 §7.5) recording how the scope arose. The mapping is one-to-one and is the reconciliation this document uses everywhere:

05 §2.3 / adr/0009 dimension scope_type origin Resolved from
global global role_grant role_assignmentaccess_scope
business_unit business_unit role_grant role_assignmentaccess_scope
department department role_grant role_assignmentaccess_scope
region regionconditional, see below role_grant role_assignmentaccess_scoperef.region
— (no 05 dimension) location role_grant role_assignmentaccess_scoperef.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_assignmentaccess_scope
explicit_grant the subject's own scope_type access_grant [additive] access_grant (05 §2.3)

Three consequences the API must honour:

  • requisition is not a scope value (§1.3). The requisition dimension is job.
  • interview is not a scope value. An interviewer's visibility is a job_application scope with origin = interview_participation, derived by existence rather than granted — which is precisely why removing someone from a panel removes their access in the same statement (03 §7.5). An endpoint that tries to grant an interview scope is a defect.
  • explicit_grant is not a scope value either. It is a fourth origin that produces a scope row of whatever type the granted subject implies (candidate, job, job_application, interview, offer → the corresponding scope_type).

region is conditional and this document does not pretend otherwise — but only in one respect. The schema for it exists: 03 §6 defines ref.region and gives ref.location a region_id FK to it in migration 002, and 03 §7.3 provisions app.access_scope.region_id together with its branch in the ck_access_scope_target exclusive arc and its entry in the scope_key generated column's coalesce list, all three in migration 011 (08 GAP-27 is what forced them). What is conditional is whether region is grantable'region' in the access_scope.scope_type CHECK — and that is the open question _open-items.md OPEN-05 (owner: Talent Lead + Talha), tracked in 08 GAP-27. Until it is answered this document treats region as an eighth value pending adoption, not as a value in force, and every scope_type enumeration below carries it marked as such. If OPEN-05 answers no, nothing in this contract changes shape: a regional desk is expressed as several location scope rows, because location already exists in the enum, and the standing cost of the provisioning is one unreachable CHECK branch and an empty region_ids bucket. If it answers yes, adoption is one line — a DROP CONSTRAINT / ADD CONSTRAINT pair on scope_type — precisely because the three edits that would otherwise have to be coordinated with it are already done. That ordering is deliberate: those three are the ones that are expensive later (tightening a live CHECK against existing rows, and redefining a generated column on the table every authenticated request reads), and missing the third is how two different scopes collide on one unique key.

sensitive_personal field classes that are scope-filtered everywhere they appear: compensation (current, expected, offer amounts and components), CV files and extracted text, interview scorecards and free-text feedback, ATS score and its evidence (score visibility is a configurable role setting), AI evidence payloads, duplicate-detection signals, and notes. No special_category data exists in Phase 1 — no diversity, health or accommodation fields are stored, and therefore none are exposed.

1.16 Request/response mechanics

Concern Rule
Content type application/json; charset=utf-8. Uploads use multipart/form-data on the two documented upload endpoints only.
Casing snake_case in JSON, matching the database and the Python layer. The generated TS client keeps snake_case rather than transforming — one less mapping to be wrong.
Timestamps RFC 3339 UTC with Z and millisecond precision (2026-07-30T09:14:22.118Z). Date-only fields are YYYY-MM-DD. The client renders in the viewer's zone; the API never returns a localised string. This replaces toLocaleDateString display logic and the hardcoded new Date('2026-07-09') (js/data.js:237, js/candidates.js:18, js/jobboard.js:167).
Money Always a pair, never a bare number: {"amount": "125000.00", "currency_code": "AED"}. amount is a string to survive JSON float parsing.
Timezones IANA names only (Asia/Karachi). A +05:00 offset is rejected as a timezone value.
Nulls Absent means "not provided" on PATCH; explicit null means "clear this field". These are different requests and the API must not conflate them.
Response wrapping Collections: {"data": [...], "page": {...}}. Single resources: the object at the top level with no envelope, plus optional meta. Rationale: an envelope on singletons buys nothing and doubles client unwrapping.
Enum output Every reference value is an object, never a bare string: {"key": "screening", "label": "Screening", "order_index": 2, "is_terminal": false}. Filters accept the bare key.
Output escaping The API returns raw stored values — full_name_original, address_original, parser output and raw payloads are deliberately preserved unsanitised so the original is recoverable. Escaping is the rendering layer's job, enforced by JSX plus an ESLint react/no-danger error, and by UI.esc() + CSP in the hardened prototype. The API must not sanitise on write, and no consumer may assume it did.
Compression, caching gzip/br; Cache-Control: private, no-store on everything carrying candidate PII; short max-age with ETag on reference data only.
Bulk operations Only where named in a group. A generic POST /batch multiplexer does not exist — it breaks per-request auditing, per-request authorization and per-request idempotency all at once.

1.17 The request lifecycle, stated once

Every state-changing request follows this path. No endpoint may skip a step, and the ordering matters: scope filtering happens before the client's filters are applied, and the transaction-local actor settings are established before any domain write, because the history triggers read them.

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/startsso/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-loginusername, password. mfa/verifycode. step-upreason (merge, offer_issue, scoring_activation, role_assignment, retention_purge) plus IdP assertion or TOTP code. Response fields: sessionuser (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_typeglobal 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 region here is a grouping of locations that regional desks are organised on, and a jurisdiction is an input to a retention policy — never a routing or storage decision. Any request to "add a region" that implies a separate data store is out of scope by constraint, not by cost.

[API decision] — what region actually is, because §1.15 needs a straight answer. region is an entity in the schema and is not yet an authorization dimension, and those are two separate facts that an earlier revision of this section treated as one. 03 §6 defines ref.region and gives ref.location a region_id NULL FK to it, replacing the free-text region column outright; both land in migration 002, where ref.location is created (03 §33.1). 03 §7.3 goes further and provisions app.access_scope.region_id, its branch in the ck_access_scope_target exclusive arc, and its entry in the scope_key coalesce list — all three in migration 011, with the table. What is not built is one line: 'region' in the scope_type CHECK. That single line is OPEN-05, and it is the whole of what remains open. So the two contract states are these, and this document commits to the first until OPEN-05 rules otherwise:

Region as reference data (in force today) Region as a grantable dimension (only if OPEN-05 says yes)
Storage ref.region + ref.location.region_id, migration 002 (03 §6) Unchanged — this row does not move, which is the point
GET /regions A real collection with the ordinary reference-data profile of §2.4: key, label, code, order_index, is_active, location_count, ETag Unchanged
POST /regions, PATCH /regions/{id} Exist, config.manage, idempotent create. Renaming a region is one row, not a PATCH on every affected location Unchanged
scope_type = 'region' Not a value. 422 unknown_scope_type (§1.15, §2.3). A regional desk is granted several location scope rows, which access_scope already supports Joins the access_scope.scope_type enum — one DROP CONSTRAINT / ADD CONSTRAINT pair, because 03 §7.3 already built the column, the arc branch and the scope_key entry

Tradeoff, stated — and it is not the tradeoff an earlier revision of this section argued. That revision kept region as free text and declined to mint the table before OPEN-05, on the grounds that a scope dimension nobody grants is how can() becomes unreviewable. 03 overruled the storage half of that and was right to: adr/0009 is Accepted, its job_application predicate reads job__current_version__location__region_id IN region_ids, and so an accepted ADR was resting on a column that did not exist (08 GAP-27). The two halves separate cleanly. Minting the table costs nothing that an unused ref table does not already cost, and it buys the one-row rename plus a stable id for adr/0009's predicate and access_scope to reference — neither of which a free-text column could give. Admitting 'region' to the scope_type CHECK is the half that cannot be undone quietly, because from that moment a grant can exist that can() must honour — which is exactly the business question OPEN-05 asks, with a named owner and a gate. Provisioning the column, the arc branch and the scope_key entry now is the cheap ordering rather than the eager one: those three are the edits that are expensive later, and the residual cost of provisioning them is one unreachable CHECK branch plus an empty region_ids bucket, which 03 §7.3 requires a test to assert. Note what the table does not carry: no parent_region_id. 03 §6 gives ref.region code and order_index and nothing else, so a region hierarchy is still not expressible and no endpoint here pretends it is.

Method + path Purpose Perm Idem Async
GET /api/v1/regions List (?is_active=) with location counts authenticated
POST /api/v1/regions Create config.manage
PATCH /api/v1/regions/{id} Update config.manage (If-Match)
GET /api/v1/locations List (?region=, ?country_code=, ?q=, ?is_active=) authenticated
POST /api/v1/locations Create config.manage
GET /api/v1/locations/{id} Read authenticated
PATCH /api/v1/locations/{id} Update config.manage (If-Match)
GET /api/v1/countries ISO-3166 list with default timezone and currency authenticated
GET /api/v1/timezones IANA zone list (validated against pg_timezone_names) authenticated

Request fields: location — key, label, country_code (ISO-3166-1 alpha-2), region_key (resolves to ref.location.region_id; nullable, because a location need not belong to a region), city, address_lines[], postal_code, default_timezone (IANA), default_currency_code (ISO-4217), jurisdiction_key, is_remote_eligible, is_active. Response fields: the above plus public_id, open_requisition_count, retention_policy_key (derived from jurisdiction).

Validation: country_code against the API's own ISO-3166 list — the one GET /countries serves — and not against a reference table, because there is no ref.country: 03 §6 stores ref.location.country_code as char(2) with a ~ '^[A-Z]{2}$' CHECK and nothing more, so a well-formed but non-existent code is accepted by the database and must therefore be rejected by the API. region_key against ref.region where is_active; absent or explicit null clears region_id (§1.16). default_timezone against pg_timezone_names — an offset such as +05:00 is rejected outright, because integrations will send PST, IST and Asia/Calcutta; default_currency_code against ref.currency where is_active. Deactivation blocked while referenced by an open requisition version. A region may not be deactivated while any active location references it (422 region_has_active_locations). Idempotency: required on create. Audit: all writes. A jurisdiction change on a location is a high-risk audit event because it changes which retention policy applies to future records — it does not retroactively re-derive retention_due_on for existing candidates, and the response says so explicitly in meta.warnings.


2.6 Requisitions

Module: requisition. Responsibility: the editorial and approval surface — draft a new version of a job's content and requirements, route it through approval, publish it as the current version.

[API decision] — the requisition/job naming collision. _decisions.md Part 1 names the module requisition with RequisitionVersion / RequisitionRequirement; Part 2 names the tables job, job_version, job_requirement, job_posting. These are the same aggregate. This document resolves it as: one aggregate, whose canonical resource path is /jobs (matching the database, the prototype route jobs, and the JOB-1001 reference code at js/data.js:95), and /requisitions does not exist as a parallel resource. The requisition workflow — draft, approval chain, publish — is exposed as sub-resources under /jobs/{id}/versions and /jobs/{id}/approvals, which is what this group covers. Tradeoff: recruiters say "requisition" and the API says "job", so the UI must translate a label. That is cheaper than two resource names for one row, which guarantees that half the client code fetches the wrong one. Flagged in §9.1.

The same call binds the authorization vocabulary. The scope value for this aggregate is jobapp.access_scope.scope_type = 'job' (03 §7.3) — because the scope enum names the row, and the row is app.job. requisition is not a scope_type value; a role-assignment request carrying scope_type: "requisition" is 422 unknown_scope_type (§2.3), not a synonym silently accepted. One row, one path (/jobs), one scope value (job), one module name (requisition) — and the module name is the only one of the four that never appears in a payload.

Method + path Purpose Perm Idem Async
POST /api/v1/jobs/{id}/versions Create an immutable draft version (content + requirements) job.edit
GET /api/v1/jobs/{id}/versions Version list (version_no, effective_from, created_by, change_reason) job.read
GET /api/v1/jobs/{id}/versions/{version_id} One version with its requirements job.read
GET /api/v1/jobs/{id}/versions/{a}/diff/{b} Field-level diff between two versions job.read
POST /api/v1/jobs/{id}/versions/{version_id}/submit-for-approval Start the approval chain job.edit
GET /api/v1/jobs/{id}/approvals Approval steps with state and actor job.read
POST /api/v1/jobs/{id}/approvals/{step_id}/approve Approve a step job.approve
POST /api/v1/jobs/{id}/approvals/{step_id}/reject Reject with reason job.approve
POST /api/v1/jobs/{id}/versions/{version_id}/publish Make it the current version job.publish ✔ (triggers rescore)
GET /api/v1/jobs/{id}/versions/{version_id}/requirements Weighted requirements job.read
GET /api/v1/jobs/{id}/version-at?ts= The version in force at an instant job.read

Request fields (create version): title, description, employment_type_key, location_key, grade_key, vacancies, salary_min and salary_max as {amount, currency_code} pairs, custom_fields (jsonb, non-queryable by contract), effective_from, change_reason (required, free text, min 10 chars), requirements[] each {kind, skill_key|raw_label, operator, threshold_value, unit, is_mandatory, weight, display_order}, copy_from_version_id (convenience: pre-fill from an existing version). Response fields: public_id, job (public_id, reference_code), version_no, state (draft | pending_approval | approved | published | superseded | rejected), content_hash, effective_from, superseded_at, created_by, change_reason, requirements[] with weight, approval_steps[]. Salary fields are scope-filtered — omitted for interviewers and for roles without job.read_compensation.

Validation: requirement weights must sum to 1.0 ± 0.0001 (422 weights_must_sum_to_one, detail.meta.sum carries the actual sum) — this is a deferrable constraint trigger, so the API must insert all requirements in one transaction, never one per request; weight in [0,1]; salary_max ≥ salary_min and both currency codes equal; amount scale must match the currency's minor_unit (JPY 500000.50 is rejected); at least one is_mandatory requirement; vacancies ≥ 1; effective_from not before the previous version's; change_reason required because a version without a stated reason is an unanswerable "what changed" later. Versions are immutable — there is deliberately no PATCH /jobs/{id}/versions/{version_id}; the database revokes UPDATE and a trigger raises, so an attempt is a 405, documented as such. Idempotency: required on every POST here. A double-submitted draft creating two version_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/manualchannel_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/uploadsfiles[] (each ≤ 25 MB (assumption); application/pdf, DOCX, DOC, RTF, TXT, and images for OCR), optional job_id, optional source_channel_key. resolutionresolution_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 (statussucceeded/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. assignassignee_user_id, note. snoozesnooze_until, reason. bulk-assignintake_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, purged03 §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 (requiredjob_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_atthe 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 0100), 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_namesPST, 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:0017: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: candidatecandidate 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/applicationnot 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:

  1. No principal of its own. ai_orchestration.invoke() takes the human actor and calls identity.can() with that actor. There is no service account, no system principal, and no elevated retrieval role. An endpoint that creates one is a design violation, not a configuration choice.
  2. No SQL, ever. The assistant reaches data only through a whitelisted set of parameterised, typed query intents that call the same module service facades the REST API calls. Text-to-SQL against any application role is prohibited.
  3. Every answer is an audited access event with actor_kind: ai_agent and on_behalf_of_user_id set to the asking user, plus the tool invocations it made.
Method + path Purpose Perm Idem Async
GET /api/v1/assistant/conversations Own conversations only authenticated
POST /api/v1/assistant/conversations Start a conversation assistant.use
GET /api/v1/assistant/conversations/{id} Own conversation with messages authenticated (own only)
DELETE /api/v1/assistant/conversations/{id} Soft-delete own conversation authenticated (own only)
POST /api/v1/assistant/conversations/{id}/messages Ask a question (streaming response) assistant.use streaming
GET /api/v1/assistant/conversations/{id}/messages/{id}/citations The records an answer was built from authenticated (own only)
GET /api/v1/assistant/capabilities What the assistant can answer, and what it cannot assistant.use
POST /api/v1/assistant/messages/{id}/feedback Thumbs up/down plus a note assistant.use
GET /api/v1/ai/runs Invocation ledger (?capability=, ?status=, ?actor=) ai.read_runs (admin + compliance)
GET /api/v1/ai/runs/{id} One run: model id + version, prompt template version, input ref, output, tokens, cost, latency ai.read_runs
POST /api/v1/ai/runs/{id}/review Human review verdict on an AI output ai.review
GET /api/v1/ai/capabilities Capability registry with true per-capability availability ai.read_config
PATCH /api/v1/ai/capabilities/{id} Enable/disable a capability, bind a prompt/model version ai.configure + step-up
GET /api/v1/ai/prompt-templates/{id}/versions Immutable prompt versions ai.read_config
POST /api/v1/ai/prompt-templates/{id}/versions New prompt version ai.configure
GET /api/v1/ai/suggestions Pending suggestions awaiting human acceptance (?subject_type=, ?status=) per subject module
POST /api/v1/ai/suggestions/{id}/accept Accept — applies via the owning domain service the domain permission for the change
POST /api/v1/ai/suggestions/{id}/dismiss Dismiss with a reason per subject module

Request fields (message). content (≤4000 chars), screen_context ({route, subject_type, subject_id} — so "summarise this candidate" works without the user retyping ids), stream (default true), allow_tools (Phase 4). Never accepted: system_prompt, model, temperature, max_tokens, tools, sql, raw_query, or any field that lets a caller reshape the model call. Model and prompt selection is server-side from the pinned capability configuration. Response fields (message). public_id, role (user | assistant), content, created_at, ai_run_id, model_id, model_version, prompt_template_version, citations[] each {entity_type, entity_public_id, label, field_path?}, tool_invocations[] each {tool_key, args_summary, result_count}, confidence_note, token_usage, latency_ms, refusal_reason (when the assistant declined), degraded (true when the provider circuit breaker is open and a cached or reduced answer was served). Streaming: Server-Sent Events on the same endpoint when Accept: text/event-stream. Event types: token, tool_start, tool_end, citation, done, error. Served by the one async Django view under uvicorn — the only streaming endpoint in the platform.

Validation. content length and a per-user daily quota (§1.11). screen_context subject must be visible to the caller — an out-of-scope subject_id is 404, and the assistant is never told the row exists. Tool invocations are checked individually against identity.can() with the human actor; a tool that would return out-of-scope rows returns an empty set and the answer says the user does not have access, rather than the model hallucinating around the gap. If the provider is unavailable, 503 ai_provider_unavailable with a plain-language message — TalentFlow degrades with AI absent rather than failing. Idempotency. Required on POST .../messages because it creates a message row and spends money. A replayed key returns the stored answer rather than re-invoking the model. Audit. Every message: an access audit_event with actor_kind: ai_agent, on_behalf_of_user_id, the ai_run_id, the model and prompt versions, and every tool invocation with its argument summary and result count. Every capability enable/disable, prompt version creation, and suggestion acceptance or dismissal. Reading the AI run ledger is itself an audited access event (it contains model inputs, which may contain candidate data). Scope filtering. Conversations are strictly own-only — there is no admin endpoint to read another user's conversation, because a conversation contains that user's questions and the answers are already audited at the access-event level. The run ledger is admin + compliance and shows run metadata; run payloads containing sensitive_personal data are further restricted to ai.read_run_payloads. Never exists — the explicit prohibition list for this group.

  • No endpoint accepting free-form SQL, a WHERE clause, a table or view name, a raw query, or a "query" string interpreted against the database. Not under an admin permission, not behind a feature flag, not in a debug namespace, not in staging.
  • No endpoint that runs the assistant as a service account, a system user, or with "elevated retrieval".
  • No endpoint where an assistant action writes domain state directly. AI output is a suggestion row referencing an ai_run_id; POST /ai/suggestions/{id}/accept applies it through the owning domain service under the human's permission, and the dependency graph makes the reverse impossible — intelligence modules may not import core domain write paths.
  • No endpoint through which the assistant sets an application status to rejected, or any terminal-negative value, under any circumstance.
  • No endpoint that returns another user's conversation, another user's raw audit log, or the full audit log filtered to a named individual for a non-compliance caller.
  • No prompt-injection escape hatch: screen_context is a typed reference, not free text, so candidate-supplied CV content can never arrive as instructions in a system position.

3. Endpoints returning sensitive fields (scope-filtered)

Every row here is scope-filtered per §1.15 — row scope, then field omission (never masked nulls), then the aggregate floor. "Audited read" means the access is written to audit_event even though it is a GET.

Endpoint Sensitive content Class Audited read Notes
GET /candidates/{id} contact details, expected/current salary, notes personal + sensitive_personal interviewer tier sees name, title, skills only
GET /candidates/{id}/emails | /phones contact channels personal omitted entirely for interviewers
GET /candidates/{id}/notes recruiter free text about a person sensitive_personal author always attributed
GET /candidates/{id}/timeline cross-module activity personal
GET /candidates/{id}/export everything held all ✔ + step-up async; bulk PII egress
GET /candidates/{id}/duplicate-candidates per-signal comparison values personal requires visibility of both candidates
GET /documents/{id}/content the CV itself sensitive_personal 302 to a 5-min signed URL
GET /documents/{id}/extracted-text | /parse full CV text, parsed fields sensitive_personal separate permission from metadata
GET /intake/{id}/payload raw email envelope, headers, other recipients personal, unfiltered intake.read_raw only
GET /intake/{id}/parse-attempts/{id} parsed jsonb sensitive_personal
GET /applications/{id} current score, expected salary sensitive_personal score omitted where visibility is off
GET /applications/{id}/scores, GET /scores/{id} score + full pin set sensitive_personal scoring.read
GET /scores/{id}/explanation per-criterion evidence, AI evidence citations sensitive_personal narrower permission than the score itself
GET /interviews/{id} meeting URL, candidate summary personal meeting_url omitted for non-participants
GET /interviews/availability interviewer calendars internal free/busy only, never titles or subjects
GET /interviews/{id}/scorecards, GET /scorecards/{id} ratings and free text sensitive_personal ✔ (others' cards) own always; others' only after own submit
GET /applications/{id}/feedback-summary aggregated recommendations sensitive_personal free text suppressed below feedback.read
GET /assessments/{id}/results scores, section breakdown sensitive_personal interviewers see passed only
GET /offers/{id}, /versions, /letter full compensation sensitive_personal interviewers never see offers
GET /analytics/offer-outcomes compensation aggregates sensitive_personal min group size 10 (assumption)
GET /analytics/recruiter-performance individual staff performance internal, staff-personal analytics.read_team; no peer access
GET /report-runs/{id}/download, /talent-pools/{id}/export bulk PII all ✔ + step-up the likeliest egress route
GET /ai/runs/{id} model inputs, which may contain candidate data sensitive_personal payloads behind ai.read_run_payloads
GET /assistant/conversations/{id} the user's own questions internal strictly own-only, no admin read
GET /messages/{id}, /candidates/{id}/communications what was sent to a candidate personal recipient masked without candidate.read
GET /audit-events everything, by design all admin + compliance only; see §5

4. Platform endpoints not enumerated in assignment §26.7

Five surfaces the assignment list omits but the design requires — audit, async jobs, reference data, health, and worklist. Listed here so nobody invents an ad-hoc convention for them later. §4.5 is a real domain group, not a platform utility: it is here because §26.7 has no entry for it, and it needs a named home rather than being inferred from the prototype-route table in §7.

4.1 Audit (audit module)

Method + path Purpose Perm
GET /api/v1/audit-events Query the log (?entity_table=, ?entity_public_id=, ?actor=, ?action=, ?outcome=, ?occurred_from/to=) audit.read (admin + compliance)
GET /api/v1/audit-events/{id}?occurred_at= One event — the partition key is required in the query, because the primary key is (id, occurred_at) audit.read
GET /api/v1/candidates/{id}/audit-trail Everything that happened to one candidate audit.read
GET /api/v1/audit-events/verify?partition= Recompute the hash chain for a partition and report audit.verify
POST /api/v1/audit-events/export Compliance export for a window audit.export + step-up, async
POST /api/v1/audit-redactions The narrow, logged erasure path for personal-class values in audit payloads audit.redact + step-up

Cursor-paginated, capped at a 90-day window per request. The composite primary key (id, occurred_at) is a documented API characteristic, not an implementation detail — a client that assumes a single-id lookup will get a 400 partition_key_required. There is no PATCH and no DELETE; the application role holds INSERT and SELECT only.

4.2 Async jobs (§1.14)

GET /api/v1/async-jobs, GET /api/v1/async-jobs/{id}, POST /api/v1/async-jobs/{id}/cancel, POST /api/v1/async-jobs/{id}/retry (worker.manage for another user's job; own jobs otherwise). Plus GET /api/v1/async-jobs/dead-letters for admin. A job handle is a receipt, never a capability to read the result.

4.3 Reference data and configuration (config module)

GET /api/v1/reference/{vocabulary_key} for each controlled vocabulary (pipeline_stage, rejection_reason, source_channel, currency, employment_type, grade, education_level, skill, assignment_role, application_status, interview_type, document_type), with ?include_inactive=. Writes go through POST/PATCH /api/v1/reference/{vocabulary_key} under config.managePhase 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_publishrescore_batch Version becomes current synchronously; the rescore does not
PUT /jobs/{id}/scoring-assignment rescore_batch Same split
POST /scoring-config-versions/{id}/activate rescore_batch Same split
POST /scoring-config-versions/{id}/dry-run scoring_dry_run Sample-set scoring
POST /scoring-config-versions/{id}/evaluations fairness_evaluation Statistical batch over historical outcomes
POST /candidate-merges candidate_merge Re-parents every table carrying candidate_id, plus search reindex
POST /candidate-merges/{id}/reverse candidate_merge_reverse Descending replay of the operation log
POST /duplicate-scan duplicate_scan Trigram scan across the candidate base
POST /assignments/bulk-reassign assignment_bulk_reassign Hundreds of intervals plus notifications
POST /documents document_scan_and_parse Scan → extract → parse
POST /documents/{id}/reparse document_parse Same
GET /documents/{id}/preview (first call) document_preview Render then cache
POST /messages, /messages/bulk, /messages/{id}/resend message_send Provider round trip with retries
POST /interviews, /reschedule, /cancel, /candidate-invite interview_sync Calendar sync (Graph) + notifications + candidate email
POST /interviews/suggest-slots slot_suggestion Free/busy queries across a panel
POST /assessments, /resend-invite assessment_invite Invite delivery
POST /public/assessments/submit assessment_grade Grading, possibly provider-side
POST /webhooks/assessment-providers/{key} assessment_result_ingest Fast 202 to the provider
POST /offers/{id}/versions/{id}/submit-for-approval offer_approval_notify Notification fan-out
POST /offers/{id}/letter offer_letter_render PDF render from a template version
POST /offers/{id}/issue, /withdraw offer_issue / offer_withdraw Letter delivery + candidate token issue
POST /public/offers/respond offer_response_notify Notification fan-out
POST /jobs/{id}/publications, /unpublish, /sync publication_* Slow, rate-limited, flaky external platform APIs
POST /talent-pools/{id}/rematch pool_rematch A scoring batch
GET /talent-pools/{id}/export, GET /candidates/{id}/export export Multi-table assembly plus blob packaging
POST /candidates/{id}/erasure-request retention_pseudonymise Column pseudonymisation plus blob deletion plus merge-reversibility interlock
POST /reports/{id}/run, POST /audit-events/export report_run / audit_export Large scans
POST /saved-segments/{id}/run (>5k rows) segment_run Large result set

Streaming, not async-job: POST /assistant/conversations/{id}/messages streams SSE tokens. It is the only streaming endpoint, served by the one async Django view — deliberately one streaming mechanism, not a general one.


6. Endpoints that must never exist

Each row is a prohibition with a reason, not a backlog item. "Never" means: not under an admin permission, not behind a feature flag, not in a debug namespace, and not in staging.

Must never exist Why What to use instead
Any endpoint accepting SQL, a WHERE clause, a table/view name, or a query DSL from the client — especially on the chatbot An unbounded read capability that no prompt-level guard reliably constrains; it defeats every scope filter in one move Whitelisted parameterised query intents through the module service facades (§2.25)
POST /candidates (direct candidate creation) candidate.created_from_raw_intake_id is NOT NULL against a non-deferrable FK; raw intake must exist first, and manual entry is not an exception POST /intake/manual then POST /intake/{id}/resolution
Any endpoint that returns another user's raw audit log, or the full audit log filtered to a named individual for a non-compliance caller Audit is a forensic record, not a management surveillance feature; per-person filtering by a peer is exactly the misuse GET /audit-events under audit.read (admin + compliance); GET /candidates/{id}/audit-trail for a candidate subject
Any endpoint that sets an application status to rejected (or any terminal-negative value) without an authenticated human actor "AI must never auto-reject" is enforced by a service guard and a CHECK constraint, and a bypass endpoint would silently undo both POST /applications/{id}/reject with a human session and a reason_key
An auto-merge endpoint, or a merge with performed_by_user_id null A false-positive auto-merge cross-contaminates two real people's application and compensation history — a data-protection incident, not a bug POST /candidate-merges with step-up and a preview acknowledgement
A bulk auto-resolve endpoint for the inbox ("resolve all high-confidence") Makes decision_mode = automatic the default path rather than the audited exception, and hollows out raw-intake-before-candidate Per-item POST /intake/{id}/resolution; if the business wants automation it arrives as a configured rule with per-item auto_create_evidence
Hard delete on candidate, job, job_application, raw_intake, audit_event, any *_history, any *_version, candidate_merge, candidate_consent, ats_result Append-only and history tables are the requirement; deletion tears holes in funnels, breaks FKs and makes merge reversal unreplayable Soft delete (distinct endpoint, distinct permission) or the retention pseudonymisation job
An endpoint that presents soft delete as erasure, or one endpoint doing both They have different legal meanings; conflating them in one call or one UI label is how a platform claims compliance it does not have POST /candidates/{id}/soft-delete vs POST /candidates/{id}/erasure-request
PATCH on any *_version row (job, scoring config, offer, prompt, scorecard template, assessment template) Immutability is enforced by revoked UPDATE grants and a trigger; an endpoint would 500 at best and corrupt score reproducibility at worst Create a new version
An endpoint that lets a client choose job_version_id when creating an application Letting a client pick the version it is scored against is a correctness hole Server pins the current version at applied_at
An endpoint that mutates overall_score in place Destroys the historical score, which is the exact requirement Insert a new ats_result and flip is_current
An endpoint returning a score without its pin set A bare number with no provenance is precisely the prototype's aiScore: int(52,98) failure (js/data.js:123) GET /scores/{id} always includes the five pinned versions
A public or unsigned blob URL, or any URL containing the storage key Emailed and forwarded links leak; a storage key is a permanent capability with no expiry or revocation GET /documents/{id}/content → 5-minute signed URL, audited
An endpoint that serves an uploaded document inline in the application origin An uploaded .html or SVG CV executing in a recruiter session is stored XSS with full application privileges (§E) Forced Content-Disposition: attachment and a render allowlist
An override that returns document content while virus_scan_status is infected There is no legitimate reason, and the endpoint would exist solely to be misused under pressure Re-request the document from the candidate
An endpoint that emails a free-text recipient address An API that emails arbitrary addresses on a recruiter's behalf is an open relay with an audit trail recipient_email_id referencing a candidate_email row
An automatic rejection email triggered by a status transition Would let the platform reject a candidate without a human in the loop through the back door An explicit, separately audited POST /messages
An endpoint through which one interviewer reads another's unsubmitted scorecard draft A draft is not a record, and visible peer ratings before submission manufacture consensus Submitted scorecards, after the reader has submitted their own
An endpoint that lets AI write or amend a human's scorecard Attribution of a human judgement must stay intact A distinct suggestion resource attributed to the model, with an ai_run_id
An offer issue path without human step-up confirmation — scheduled, bulk, or AI-triggered Two offer letters with different numbers in a candidate's inbox is the worst failure this API can produce POST /offers/{id}/issue with confirm_issue: true and a fresh step-up assertion
Any bulk offer endpoint Offers are individually approved documents; bulk anything defeats the approval chain One offer at a time
An assistant service account, system principal, or "elevated retrieval" role The access-control guarantee only holds because the assistant has no capability the asking user lacks ai_orchestration.invoke(actor=human)
An assistant endpoint accepting system_prompt, model, temperature, or tools from the client Lets a caller reshape the model call and defeat the pinned, versioned capability configuration Server-side selection from the capability registry
An analytics endpoint returning row-level candidate or application records Analytics scope and the minimum-group-size floor are its whole safety story The domain APIs, where per-record access is audited
An analytics "admin bypass" of the minimum-group-size floor A fine-grained breakdown is re-identifiable; an admin needing a row-level answer should be audited per record Domain APIs
A generic POST /batch request multiplexer Breaks per-request auditing, authorization and idempotency simultaneously The named bulk endpoints, each capped and audited
A generic ?filter[field][op]=value grammar An unbounded query surface (same risk class as text-to-SQL) that also defeats index planning Enumerated per-endpoint filter params
POST /auth/impersonate or any endpoint returning a session for another user Destroys attribution in every history and audit row; "who did this" becomes unanswerable Screen-share, or a scoped read permission
An endpoint returning a password hash, or a GET login with credentials in the query string Credentials in URLs land in access logs, referrers and browser history POST body over TLS only
An endpoint that sets job.current_primary_recruiter_id or any trigger-maintained denormalised column directly Lets the cache diverge from the interval table that is the source of truth Assignment endpoints; the trigger maintains the column
An endpoint that edits a historical interval's valid_from/valid_to History is not editable; an editable history is not a history End the assignment and create a new one, both audited
An unauthenticated endpoint that lists publications for a job, enumerates publication ids, or lists a candidate's interviews Anonymous enumeration of hiring activity GET /public/jobs/{publication_id} by exact id only
An unauthenticated unsubscribe endpoint accepting a candidate id Turns an identifier into a capability A candidate_access_token
Any endpoint whose write path does not carry SET LOCAL app.actor_user_id History triggers would attribute the change to system with actor_unknown = true — a silent provenance gap Middleware-wrapped transactions; the actor_unknown count is a monitored alarm

7. The 23 prototype routes → API contract targets

Source: the ROUTES map at js/app.js:7-16, rendered by a hash router with view functions returning HTML strings plus an onMount hook (js/app.js:20-57). The 23-route information architecture is a validated UX artefact (§G) and is preserved as the React route table, so this table is the frontend's contract target: for each screen, which endpoints must exist before it can be built against real data.

"Boot" endpoints (GET /auth/session, GET /reference/bundle, GET /notifications/unread-count) are called by the shell on every route and are not repeated per row.

# Route Disposition Primary endpoints it consumes Phase
1 dashboard Retained GET /analytics/kpis, /analytics/funnel, /analytics/requisition-ageing, GET /inbox/counts, GET /worklist/tasks?assignee=me, GET /interviews?mine=true&starts_from=today 2
2 inbox (FR-2) Retained as a view over intake (§2.10) GET /inbox, /inbox/counts, /inbox/{id}, POST /inbox/{id}/claim, POST /intake/{id}/resolution, GET /intake/{id}/parse-attempts, GET /documents/{id}/preview 1
3 jobs Retained GET /jobs, GET /jobs/{id}, POST /jobs, POST /jobs/{id}/versions, POST .../publish, GET /jobs/{id}/pipeline-summary, GET /jobs/{id}/assignments 1
4 candidates Retained — the screen most changed by the candidate/application split GET /candidates, POST /candidates/search, GET /candidates/{id}, GET /candidates/{id}/applications, GET /candidates/{id}/documents, GET /candidates/{id}/timeline 1
5 talentpool (FR-5) Retained as tags + saved segments over candidate/application, not a new store GET /talent-pools, /talent-pools/{id}/members, POST /talent-pools/{id}/rematch, GET /saved-segments 3
6 pipeline Retained GET /jobs/{id}/board, GET /jobs/{id}/board/stages/{key} (independent column pagination), POST /applications/{id}/stage, GET /applications/{id}/allowed-transitions, GET /jobs/{id}/pipeline-metrics 2
7 interviews Retained GET /interviews, POST /interviews, POST /interviews/{id}/reschedule, GET /interviews/availability, POST /interviews/suggest-slots, GET /scorecards/pending/me 2
8 assessments Retained GET /assessments, POST /assessments, GET /assessment-templates, POST /assessments/{id}/results 3
9 offers Retained GET /offers, POST /offers, POST /offers/{id}/versions, POST .../approve, POST /offers/{id}/issue, POST /offers/{id}/letter 3
10 managers (FR-15) Dissolved. No Manager entity — a manager is an app_user with a role plus assignment rows GET /users?role=hiring_manager, GET /users/{id}/workload, GET /jobs?hiring_manager={id}, GET /offers?status=pending_approval 1
11 calendar (FR-16) Dissolved into a read view over interview + worklist. No Calendar table GET /calendar?from=&to=&scope=mine|team, GET /interviews, GET /worklist/tasks 2
12 reports Retained GET /reports, POST /reports/{id}/run, GET /report-runs/{id}, GET /report-runs/{id}/download 3
13 analytics Retained the GET /analytics/* family, plus GET /analytics/meta for available dimensions and freshness 2
14 notifications (FR-20) Retained; Phase 1 writes an in-app row only, email templating lives in config GET /notifications, POST /notifications/{id}/read, GET/PUT /notification-preferences 2
15 settings (FR-22) Split. security/users/roles → identity; vocabularies/templates/branding → config. Phase 1 UI is the Django admin, so this screen is deferred GET/PATCH /settings, GET/POST /reference/{vocab}, GET /message-templates, GET /users, GET /roles 4
16 help (FR-23) Dropped from the platform. Static docs plus client-side search — no backend none 4
17 import (FR-7) Retained as the second surface over intake (§2.9/§2.10) POST /intake/uploads, GET /async-jobs/{id}, GET /intake?state=parsing, GET /intake/{id}/parse-attempts, POST /intake/{id}/resolution 1
18 jobboard (FR-8) Retained as a UI surface over integrations_outbound GET /job-publications, POST /jobs/{id}/publications, POST /job-publications/{id}/sync, GET /publication-platforms 4
19 recruiterhub (FR-9) Dissolved into assignment (workload, history) + analytics (efficiency, SLA) GET /assignments/me, GET /users/{id}/workload, GET /analytics/recruiter-performance, GET /worklist/tasks?assignee=me 2
20 aiassistant Retained POST /assistant/conversations, POST /assistant/conversations/{id}/messages (SSE), GET .../citations, POST /assistant/messages/{id}/feedback 2
21 aistudio (FR-19) Dissolved into ai_orchestration's admin surface. Must show true per-capability availability, not a coming-soon grid GET /ai/capabilities, PATCH /ai/capabilities/{id}, GET /ai/runs, GET /ai/prompt-templates/{id}/versions, GET /analytics/ai-usage 1 (framework) / 2-4
22 rbac Retained, and now functional — the prototype's matrix is a display widget whose clicks mutate an in-memory array nothing reads (js/rbac.js:78, js/rbac.js:83-85, js/rbac.js:111-112) GET /permission-matrix, GET /roles, PUT /roles/{id}/permissions, POST /users/{id}/role-assignments, POST /permissions/explain 1
23 tasks Retained, backed by the worklist API group — not in assignment §26.7, so its contract is defined in §4.5 GET /worklist/tasks (?assignee=, ?status=, ?due_before=, ?origin=), POST /worklist/tasks, POST /worklist/tasks/{id}/complete, POST /worklist/tasks/{id}/reassign, GET /worklist/tasks/counts — full table and contract note in §4.5 2

Three frontend consequences worth stating plainly:

  1. Every list screen changes shape. UI.dataTable sorts and paginates client-side over an in-memory array (js/ui.js:251); against these endpoints, sort and pagination are server-side query parameters with a closed allowlist and opaque cursors.
  2. The candidates screen splits. The prototype's flat candidate carries jobId, jobTitle, stage, aiScore and recruiter (js/data.js:117-127). Against this API a candidate has an applications[] collection, each with its own stage, score and assignments, and the profile screen needs an application selector it does not currently have.
  3. Scores, salaries, notes and documents can be absent by permission, not by value. Every screen showing them must handle field omission plus meta.restricted_fields, and must render a lock state rather than an em dash — null and "you may not see this" are different facts (§1.15).

8. CONFIRMED requirements vs UNCONFIRMED ideas

Per the instruction to keep assignment §3 (confirmed) separate from §4 (unconfirmed). The API surfaces above are not all equally warranted, and the split matters for sequencing.

CONFIRMED — assignment §3. These API groups are required and their contracts are settled enough to build against: authentication; users; roles and permissions; departments; regions and locations (as reference data); requisitions/jobs/job publications; recruitment intake; recruitment inbox; candidates; applications; documents; ATS scoring; duplicate review; recruiter assignments; pipeline; interviews; feedback; offers; analytics; audit; async jobs; reference data; worklist (§4.5 — FR-10, confirmed even though §26.7 omits the group).

UNCONFIRMED — assignment §4. Designed here so the boundary is not accidentally violated later, but not committed to Phase 1-2 and subject to change on business input:

Unconfirmed area What is speculative Consequence for the API
Chatbot beyond read-only Q&A The Phase 4 tool-using assistant, and which tools it may call Only the read-only intent set is contracted; every write remains a suggestion requiring human acceptance. Adding tools must not add a principal.
Assessments Whether tests are built in-platform or bought from a provider; whether candidates take them through our portal The provider-webhook and public/assessments endpoints are shaped as adapters precisely because this is unresolved
Communications beyond transactional email Sequences, nurture campaigns, SMS, WhatsApp Only single templated sends and named bulk (≤50) are contracted. No campaign resource exists.
Talent pool automation Automatic pool membership from rules, automatic re-engagement Dynamic pools are contracted; automatic outbound from a pool is not, and would need a consent gate
Fairness evaluation thresholds What "passing" means, and against what historical outcome data (BRD OQ-2 — no confirmed historic hiring outcome data exists) The activation gate endpoint exists; the pass criteria are configuration, not API
Job-board publishing Which of the 8 platforms, at what cost, with what contracts Platform adapters are behind one platform_key; no platform-specific endpoint exists
Candidate self-service portal Whether candidates get accounts, a status page, or only tokenised links Contracted as tokenised links only. An account model would be a new authentication surface, not an extension of this one.
Score visibility per role BRD OQ-5 is open Contracted as a configurable role setting with field omission, so either answer is a config change rather than an API change
Model hosting BRD OQ-1 — contracted API provider under a DPA is an assumption If legal requires self-hosting, /ai/* contracts survive but latency, cost and the streaming endpoint's deployment change

9. Inconsistencies found in _decisions.md, assumptions, and risks

9.1 Inconsistencies between Part 1 (Architecture) and Part 2 (Database)

These are flagged rather than silently resolved. Where this document had to pick one, the pick is stated. Every one of them needs a one-line ruling before implementation, because an API contract cannot be written twice.

Those rulings now exist, in _open-items.md. This table is the evidence and the pick this document made; the ruling is there, and the three rows that said "Needs a ruling" have one. 1 → RULING-06: /jobs is the only resource tree, /requisitions is not minted, and assignment §26.7's two groups survive as two OpenAPI tags over one tree (requisitions = the versioning/approval workflow sub-resources, jobs = the record and its lifecycle) — add that tag table to §2.6. 2 → RULING-01, already settled and already correct here. 3 → RULING-04. 4 → RULING-02, and it confirms this row's diagnosis: the CI gate becomes "the introspected schema matches the models", not "autogenerate produces no diff". 5, 6 → C-04 (Part 2's state sets win; the renames are load-bearing and are recorded in _glossary.md §3). 7 → C-05. 8 → C-06. 9 → OPEN-05 (owner: Talent Lead + Talha). 10 → RULING-05: the table is app.stored_file (migration 005); one row can be referenced by two subjects, which is the point, and retention deletion has one place to look — so the resource shape this document chose is correct. 11 → RULING-03. 12, 13 → C-07: 03 §22, §24 and §26.1 now model all of them, so the gap is filled.

# Inconsistency Part 1 says Part 2 says What this document did
1 Aggregate name: requisition vs job Module requisition; entities Requisition, RequisitionVersion, RequisitionRequirement Tables job, job_version, job_requirement, job_posting; reference code JOB-1001 Canonical resource path /jobs, with the requisition workflow as sub-resources (§2.6). /requisitions deliberately does not exist. Needs a ruling — assignment §26.7 lists "requisitions" and "jobs" as separate API groups, which would otherwise produce two resources for one row.
2 actor_kind enum values — load-bearing. RESOLVED actor_type ∈ human/system/ai, and the "AI never auto-rejects" guard is literally actor_type != human actor_kind ∈ user/system/integration/ai_agentthere 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 ~2025 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
20k60k applications/year, 200600 documents/day at peak Cursor pagination and async parsing are sized for this; a 10× miss changes the worker sizing, not the contracts Ask the Talent Lead for last year's volumes
25 MB document upload cap, PDF/DOCX/DOC/RTF/TXT/images Validation rules in §2.13 Sample the real inbound mailbox
Client polls GET /async-jobs/{id} in Phase 1; SSE for job progress is Phase 2 One streaming mechanism only Product decision on how live the intake screen must feel
Compensation aggregates suppressed below a group size of 10 (vs 5 elsewhere) §2.24 Legal review
Role names (recruiter, hr_admin, hiring_manager, department_head, interviewer, executive, compliance, admin) Every permission column in §2 The authoritative role list from identity / the BRD's 66 seats
A contracted model API provider under a DPA (inherited from Part 1, BRD OQ-1) The one streaming endpoint's deployment, and AI latency budgets Legal ruling on model hosting
Microsoft Graph is the Phase 1 mail channel and Entra ID the IdP §1.2, §2.9's channel adapters Corporate IT confirming the app registration and a dedicated recruiting mailbox
Bulk caps of 50 (inbox assign, application stage, messages, pool members) §1.16, several groups Watch the first month's real recruiter batch sizes

9.4 Risks this API surface carries

  • Field omission is enforced only in the application layer in Phase 1. Row-level security is deferred to Phase 2, so every sensitive_personal omission rule in §1.15 and §3 rests on serializer code in a codebase that today has no authorization at all (§D). Mitigation: one centralised authorization module, no direct model access from views, and a test asserting that every candidate-reading endpoint passes through identity.can(). Without that test this section is documentation, not a control.
  • The audited-read list is a judgement call and will be wrong at the edges. Auditing every list render is unusable; auditing too little fails a compliance question later. The closed list in §1.13.3 should be reviewed with whoever owns compliance before Phase 2, not after an incident.
  • 403-vs-404 discipline is easy to lose. One DRF view that raises PermissionDenied instead of NotFound on an out-of-scope object leaks existence. Mitigation: the scope filter runs on the queryset, so an out-of-scope row is genuinely absent and 404 is the natural outcome — plus a test per scoped endpoint.
  • Required idempotency keys will be forgotten by the first integration written under time pressure. The server rejecting the request is the mitigation, but it means an integration can fail closed on day one. Document it in the generated client's README, and make the generated TS client attach a key automatically for creating 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/query with a SQL body; what prevents it is the dependency rule (intelligence may not import domain write paths), the absence of a service principal, and import-linter in CI. Add an explicit test asserting no view accepts a parameter named sql, query, where or raw.
  • Two frontends will consume different contracts for 612 months. The hardened prototype reads generated in-memory data (js/data.js:8-10); the React app reads this API. The rule from Part 1 holds: real data is only ever wired to the React app, and each migrated screen deletes its prototype counterpart in the same PR. An API that the prototype also starts calling would reintroduce the XSS exposure in §E on a live data path.
  • SET LOCAL app.actor_user_id is a per-request contract, not a per-endpoint one. Any API path that writes without it silently degrades history attribution to actor_unknown. Middleware plus a monitored actor_unknown count is the mitigation; the metric must be an alarm, not a dashboard nobody opens. The same holds for app.source_service (§1.12): its failure mode is quieter, because the row is still written and still attributed to an actor — only 'unknown' where the writing tier should be — which is why 03 §31 makes the source_service = 'unknown' count part of the same alarm rather than a second one.
  • Requirement-weight validation crosses a request boundary. The weights-sum-to-1.0 check is a deferrable constraint trigger firing at COMMIT, so requirements must be written in one transaction. An API that offered POST /jobs/{id}/versions/{id}/requirements per-row would be unbuildable; §2.6 therefore has no such endpoint, and anyone adding one later will discover this the hard way.