{
 "title": "Zonitix instance API: hand-checked route catalogue",
 "about": [
  "Every row of the Route Catalogue on the Zonitix documentation site, plus the typed objects listed there under Response Objects.",
  "It is a subset of what an instance serves: a route that is not here may still exist.",
  "It is not OpenAPI and it is not the file an instance serves at /api/docs/openapi.json, which is a saved file built from the product's route definitions and shipped with the release the instance runs, and covers fewer routes."
 ],
 "appRevision": "99e5035a2",
 "basePath": "/api/v1",
 "server": "https://<your-instance>-api.zonitix.com/api/v1",
 "fields": {
  "rows[].id": "\"<METHOD> <path>\". A route printed in more than one group appears once per group, with the same id.",
  "rows[].purpose / request / response / guard": "The catalogue's own text, unchanged. request and response are one line of prose, not a schema.",
  "rows[].auth.text": "The catalogue's auth cell, unchanged. Listed permissions are alternatives unless the text says \"and\".",
  "rows[].auth.kind": "public (no session), session (a signed-in session; many also accept a personal API token), or other (the text names the credential).",
  "rows[].auth.permissions": "The permission names that appear in auth.text.",
  "rows[].auth.stepUp": "required, conditional (the text says when), or null when the text names none. A call that needs a step-up cannot be made with a personal API token.",
  "rows[].auth.administratorRole": "true when the text asks for the administrator role itself. A personal API token never carries a role.",
  "rows[].auth.licensed": "true when the text says the module must be active on the plan.",
  "rows[].pathParameters": "One entry per :name in the path: name, type, format (uuid, where the record's id is one) and basis, the ground for the type: model (the primary key of the record the path names), route (the route's own check), parser (the handler's signature), literal, or unknown. An unknown one is a string because every path segment is.",
  "rows[].query": "The query parameters of the row as data: name, type and, where the handler sets them, default, minimum, maximum, enum, pattern, required and a note. A value outside minimum and maximum is moved to the nearest bound, not refused. Written while reading the handler, for some rows only: an empty list means the row was not read this way, not that it takes no query string.",
  "rows[].replyShapes": "Present on a list row that answers more than one shape: each entry is one alternative.",
  "rows[].paging": "The paging parameters the request text names (limit, offset, page, pageSize, cursor, startIndex, count). Empty means the row names none, not that the reply is unpaged.",
  "rows[].responses": "response split wherever the catalogue separates replies with a bar ( | ). status is the code an entry starts with, or null when it starts with none. A row that writes its replies in a sentence has every code in the text of one entry.",
  "rows[].errors": "The entries of responses that start with a 4xx or 5xx status. An error a row names inside a sentence is in the text of the entry before it, not here, and a row can omit errors the route still answers.",
  "objects[].id": "Unique. The printed name, or <Group><Name> where two modules print an object under one name.",
  "objects[].fields": "name, type, flags and note as printed under Response Objects. In a reply, an empty flags means not nullable and not read-only; in a request body it means optional."
 },
 "counts": {
  "groups": 41,
  "rows": 873,
  "distinctRoutes": 869,
  "typedObjects": 380
 },
 "groups": [
  {
   "id": "gw-auth",
   "name": "Session & Authentication",
   "service": "api-gateway → identity-service",
   "summary": "Handled at the gateway and delegated to the identity service. This is the unauthenticated sign-in surface, and it is aggressively rate-limited. The other routes that carry no session — the agent endpoints, SCIM, SSO sign-in, shared report links, the careers site and candidate portal, and inbound webhooks — are listed in their own groups below.",
   "rows": 13
  },
  {
   "id": "gw-mfa",
   "name": "MFA, Passkeys & Trusted Devices",
   "service": "api-gateway → identity-service",
   "summary": "TOTP, WebAuthn/passkeys, recovery codes, and the trusted-device list. Admin reset actions require sudo.",
   "rows": 21
  },
  {
   "id": "gw-system",
   "name": "Security Settings, Access Control & Audit Sessions",
   "service": "api-gateway → identity-service / compliance-service",
   "summary": "Instance-wide security configuration, geo/IP access rules, and the audit-session bookkeeping used by the Compliance Hub.",
   "rows": 17
  },
  {
   "id": "users",
   "name": "Users, Roles, Groups & Permissions",
   "service": "user-management-service",
   "summary": "The RBAC core, the org chart, and per-user data export. All paths are prefixed /user-management.",
   "rows": 51
  },
  {
   "id": "workflow",
   "name": "Workflows & Tasks",
   "service": "workflow-service",
   "summary": "Designing a workflow and approving it are deliberately different rights, so a designer cannot put their own work live. Most mutating workflow routes additionally require sudo (marked per row). A tenant can run several named pipelines per type; one of them is the default that unnamed runs use.",
   "rows": 27
  },
  {
   "id": "integration",
   "name": "Identity Hooks & Alignment",
   "service": "integration-service",
   "summary": "Connected identity/SaaS providers, drift detection against a source of truth, and governed write-back.",
   "rows": 34
  },
  {
   "id": "compliance",
   "name": "Compliance, Reporting & System Health",
   "service": "compliance-service",
   "summary": "Readiness assessments, control states, remediation tracking, evidence storage, and the admin-approved backup/patch workflow. Compliance Hub reads accept view_reporting or manage_compliance. The Compliance Hub paths (/compliance/reports/compliance/*, /auth/audits/*) answer 403 when the Compliance module is explicitly switched off on your licence; system-health, patch and backup routes are never licence-gated.",
   "rows": 57
  },
  {
   "id": "security-lab",
   "name": "Security Lab",
   "service": "security-lab-service",
   "summary": "Automated probes and scans. Reached through the gateway under /auth/security-lab. Launching requires the Security Lab entitlement on your license.",
   "rows": 12
  },
  {
   "id": "cloud",
   "name": "Cloud Discovery (AWS / Azure / GCP)",
   "service": "security-lab-service",
   "summary": "Read-only cloud inventory, topology, identity-fusion findings, and governed remediation. All require the cloud-mapping entitlement; remediation additionally requires sudo.",
   "rows": 20
  },
  {
   "id": "recruitment",
   "name": "Recruitment & Hiring",
   "service": "recruitment-service",
   "summary": "Requisitions and their headcount budget. Note the deliberate separation of duties: the finance gate on a requisition requires manage_finance alone, so a recruiter cannot approve their own headcount. Rows marked (licensed) sit behind the recruitment licence check and answer 403 when recruitment is switched off for your plan and 402 while the subscription is lapsed; rows without it stay reachable regardless. The administrator role passes every permission check. A capitalised name in a body or returns line, such as JobPosting, Candidate, Offer or Interview, is an object typed field by field under Data Objects on the Recruitment page. The gateway in front of the service adds answers of its own, each { error:string }: 401 \"No token provided\" with no session or token, 503 \"Service temporarily unavailable\" while the service behind it keeps failing, and 504 \"Gateway Timeout\" when it does not answer. A 401 sent by the service itself reaches the caller as 400 with the body unchanged.",
   "rows": 12
  },
  {
   "id": "recruitment-candidates",
   "name": "Recruitment: Candidates & Pipeline",
   "service": "recruitment-service",
   "summary": "The candidate pipeline: listing, screening, stage moves, tags, notes, messages, rejection, erasure and export. A permission admits a caller to the route; the service then checks the caller against the individual candidate, and a candidate outside that caller's scope is refused. Erasure, export and the résumé read stay available when recruitment is switched off.",
   "rows": 24
  },
  {
   "id": "recruitment-interviews",
   "name": "Recruitment: Interviews & Scorecards",
   "service": "recruitment-service",
   "summary": "Scheduling, candidate self-scheduling proposals, scorecards and the interview templates behind them.",
   "rows": 11
  },
  {
   "id": "recruitment-offers",
   "name": "Recruitment: Offers & Signing",
   "service": "recruitment-service",
   "summary": "Offer terms, approval, sending for signature through Zonitix Sign, withdrawal, rescission and offer templates. The permission lists are alternatives, so they do not by themselves keep setting terms and approving them apart: the service refuses an approval from the person who set the terms.",
   "rows": 20
  },
  {
   "id": "recruitment-insights",
   "name": "Recruitment: Assistant, Referrals & Reports",
   "service": "recruitment-service",
   "summary": "The recruitment assistant, employee referrals, pipeline analytics, the hiring report and the careers-page settings.",
   "rows": 11
  },
  {
   "id": "recruitment-public",
   "name": "Recruitment: Careers Site, Candidate Portal & Webhooks",
   "service": "recruitment-service",
   "summary": "Routes that carry no session: the careers site and job feeds, the token-addressed candidate portal and self-scheduling pages, and the two inbound webhooks. The gateway rate-limits all of them and the service rate-limits again per client address, answering 429 with a Retry-After header. The portal, self-scheduling and webhook routes are outside the licence check, so an offer already in flight still completes when recruitment is switched off.",
   "rows": 20
  },
  {
   "id": "parser",
   "name": "Finance & Document Parser",
   "service": "document-parser-service (Python/FastAPI)",
   "summary": "Invoice OCR and the invoice ledger. Runs entirely on your instance — no document is sent to a third-party parsing API. Permissions are checked inside the service on every call, and the administrator role passes each check. Rows marked (licensed) are part of Finance: they answer 403 when Finance is switched off for your plan and 402 while the subscription is lapsed. The résumé screening and document routes at the end of the table are shared with recruitment and are not behind the Finance licence. A capitalised name in a body or returns line, such as Invoice, Vendor, PurchaseOrder or Budget, is an object typed field by field under Data Objects on the Finance Operations page. Money in these replies is a JSON number. Error bodies differ from the other services: a refusal raised by a handler is { detail:string }, a request the framework rejects before the handler runs (a missing or mistyped parameter) is 422 { detail:[ ... ] }, and the two licence refusals (403 and 402) are { error:string, message:string }. The gateway in front of the service adds answers of its own, each { error:string }: 401 \"No token provided\" with no session or token, 503 \"Service temporarily unavailable\" while the service behind it keeps failing, and 504 \"Gateway Timeout\" when it does not answer. A 401 sent by the service itself reaches the caller as 400 with the body unchanged.",
   "rows": 21
  },
  {
   "id": "finance-vendors-settings",
   "name": "Finance: Insights, Vendors & Settings",
   "service": "document-parser-service (Python/FastAPI)",
   "summary": "Spend insights, the vendor directory, custom fields, currency and exchange rates, finance settings, the digest and the finance audit trail. Exchange rates and the control settings are policy: manage_finance alone cannot change them. A capitalised name in a body or returns line, such as Invoice, Vendor, PurchaseOrder or Budget, is an object typed field by field under Data Objects on the Finance Operations page. Money in these replies is a JSON number.",
   "rows": 27
  },
  {
   "id": "finance-procurement",
   "name": "Finance: Purchasing, Approvals & Close",
   "service": "document-parser-service (Python/FastAPI)",
   "summary": "Purchase orders, goods receipts, match exceptions, approval rules, the approvals queue, the chart of accounts, period close, journals and ledger sync. Approval rules are policy and need manage_finance_policy to change; closing a period needs close_finance. A capitalised name in a body or returns line, such as Invoice, Vendor, PurchaseOrder or Budget, is an object typed field by field under Data Objects on the Finance Operations page. Money in these replies is a JSON number.",
   "rows": 27
  },
  {
   "id": "finance-budgets",
   "name": "Finance: Budgets, Transfers & Spend Requests",
   "service": "document-parser-service (Python/FastAPI)",
   "summary": "Budgets and their lifecycle (submit, approve, reforecast, lock, freeze), budget lines, alerts, transfers, spend requests and linked systems. Budget permissions come in two kinds: organisation-wide ones (view_finance, manage_finance, view_finance_consolidated, manage_all_finance_budgets, approve_finance_budget) and scoped ones (view_finance_budgets, manage_finance_budget, submit_finance_budget) that the service limits to the caller's own scope; a scoped maker can change only a budget they own. A budget outside the caller's scope answers 403 or 404 without confirming that it exists. A capitalised name in a body or returns line, such as Invoice, Vendor, PurchaseOrder or Budget, is an object typed field by field under Data Objects on the Finance Operations page. Money in these replies is a JSON number.",
   "rows": 42
  },
  {
   "id": "ai",
   "name": "Local AI",
   "service": "ai-service",
   "summary": "A local LLM (Ollama) running on your instance. Prompts and candidate data are not sent to an outside AI service, and there is no third-party AI API key in this path. One small model, sized to run on the instance CPU, serves every AI feature: an administrator picks it in Settings from a fixed allow-list of two — qwen3:0.6b (the default) or the lighter qwen2.5:0.5b — and a model outside the allow-list is refused with 400. No larger model ships, so treat the output as a drafting and triage aid that a person reviews, not as a decision.",
   "rows": 24
  },
  {
   "id": "logs-devices",
   "name": "Audit Logs & Device Inventory",
   "service": "logging-service, device-service",
   "summary": "The audit log and the manual device catalogue, reached through the /user-management prefix. Fleet management routes are in the Device Management group.",
   "rows": 6
  },
  {
   "id": "agent",
   "name": "Zonitix Agent (Endpoints)",
   "service": "api-gateway → device-service",
   "summary": "The endpoints the agent calls at the /agent prefix. These carry NO session — the agent authenticates each machine with a per-device P-256 key (on a Mac, a Secure Enclave key where the hardware has one and the build is signed for it, otherwise a software key; a root-only keyfile on Linux; an admin-protected file under ProgramData on Windows). Enroll is a one-time trust bootstrap; every heartbeat is signed and replay-protected (±5-minute timestamp window + one-time nonce). The macOS, Linux and Windows agents share enroll and heartbeat; the installer downloads, recovery-key escrow and MDM enrolment-code redeem below complete the allow-list — nothing else under /agent is reachable.",
   "rows": 10
  },
  {
   "id": "device-management",
   "name": "Device Management",
   "service": "device-service",
   "summary": "Fleet management behind the Devices pages: enrolment, policy, commands, groups, configuration profiles, Apple MDM (preview), software inventory, recovery keys and agent rollouts. Reads need view_devices and changes need manage_devices; the destructive actions (wipe, delete, recovery-key reveal) need the separate manage_device_destructive permission plus sudo. Errors are { error } with a sentence for the person; a machine code is named on the row where one exists. This table is a subset of the device routes; the device read routes are also in your instance's generated reference at /api/docs.",
   "rows": 81
  },
  {
   "id": "api-tokens",
   "name": "Personal API Tokens",
   "service": "api-gateway → identity-service",
   "summary": "Mint and revoke the zx_ bearer tokens that scripts use. These routes are for a signed-in person: every one of them refuses a call that is itself authenticated with an API token, so a token can never create or revoke tokens. Limits: 20 active tokens per user, expiry 90 days by default and 365 days at most, 300 requests per minute per token.",
   "rows": 6
  },
  {
   "id": "sso",
   "name": "Single Sign-On (OIDC & SAML)",
   "service": "api-gateway → identity-service",
   "summary": "The browser sign-in redirects under /auth/sso carry no session and are rate-limited; the configuration routes under /identity-admin are administrative.",
   "rows": 10
  },
  {
   "id": "scim",
   "name": "SCIM 2.0 Provisioning",
   "service": "api-gateway → user-management-service",
   "summary": "Inbound SCIM for Okta or Entra ID. The /scim/v2 endpoints carry no session: your identity provider authenticates with the SCIM bearer token (it begins zscim_) you issue under /user-management/scim-provisioning. At most 10 tokens can be active. Only the resource paths listed here are reachable under /scim. Every /scim/v2 answer is application/scim+json, errors included. Filters, paging and error format are on the SSO & Passwordless page.",
   "rows": 12
  },
  {
   "id": "access-governance",
   "name": "Access Reviews, Access Requests & Departments",
   "service": "user-management-service",
   "summary": "Periodic access reviews (certifications), self-service access requests, and the governed department list. Errors from these routes are { error } with a sentence written for the person; they carry no machine code.",
   "rows": 20
  },
  {
   "id": "user-admin",
   "name": "User Import, Invites & Bulk Actions",
   "service": "user-management-service",
   "summary": "Bulk onboarding for a company with no directory, the set-password invite actions, forced password changes, role changes through a persona, and the administrator's data-subject export.",
   "rows": 16
  },
  {
   "id": "ops-admin",
   "name": "Log Forwarding & Support Access",
   "service": "logging-service, user-management-service",
   "summary": "Administrator-only operational settings. Because they require the administrator role itself rather than a permission, a personal API token cannot call the administrator-only rows.",
   "rows": 8
  },
  {
   "id": "report-engine",
   "name": "Report Engine & Scheduled Reports",
   "service": "compliance-service",
   "summary": "Generating, scheduling, sealing and sharing reports. These routes answer 403 when the Reporting module is explicitly switched off on your licence. Scheduled reports replaced the retired compliance digest. Every route that builds, saves or schedules a report also checks the report kind against your permissions and answers 403 \"A '<kind>' report requires one of: ...\" when you may not see that kind or one of its data sets.",
   "rows": 13
  },
  {
   "id": "compliance-hub",
   "name": "Compliance Hub: Policies, Risks, Vendors & Auditors",
   "service": "compliance-service",
   "summary": "The registers and auditor-facing surfaces of the Compliance Hub. All are under /compliance/reports/compliance and answer 403 when the Compliance module is explicitly switched off on your licence. The policy rows are the exception: they also answer when Training is on your plan (see Policy Signing & People Evidence). The three registers return everything in one answer: none of them reads a paging parameter.",
   "rows": 20
  },
  {
   "id": "shared-links",
   "name": "Public Share Links",
   "service": "api-gateway → compliance-service",
   "summary": "Links you hand to someone outside the company. They carry no session: the token in the path is the credential, and the routes are rate-limited. A token must be 64 lowercase hex characters; anything else answers 404 at the gateway. Each view of a report link or an auditor link, and each refusal of a well-formed token, is written to the audit log.",
   "rows": 5
  },
  {
   "id": "backup-ops",
   "name": "Backups, Restore & Release Channel",
   "service": "compliance-service",
   "summary": "The backup inventory and the two-person restore and delete flows. Reads need view_system; these routes are never licence-gated. Updates, database restores, backup deletions and file restores share one approval slot: while one request is waiting or running, another is refused with 409.",
   "rows": 9
  },
  {
   "id": "company-data-export",
   "name": "Company Data Export",
   "service": "compliance-service",
   "summary": "A copy of everything on the instance, built in the background as one ZIP and held encrypted on the instance. Every route needs the administrator role itself, not a permission, so a personal API token cannot call any of them. The on-screen steps are in the guide Export all your company data. Not plan-gated.",
   "rows": 5
  },
  {
   "id": "security-lab-more",
   "name": "Security Lab: Schedules, Issues, Exceptions & Attack Paths",
   "service": "security-lab-service",
   "summary": "The rest of the Security Lab surface. Each row names the permissions its route guard accepts: holding either one is enough. Rows marked (licensed) answer 403 unless the Security Lab entitlement is on; the other rows answer 403 only when the entitlement is explicitly switched off on your licence.",
   "rows": 43
  },
  {
   "id": "hr-employee-files",
   "name": "Employee Files, Pay History, Documents & Payroll Hand-off",
   "service": "user-management-service",
   "summary": "The employee file is on every plan. Rows marked (licensed) need the HR module (the hris entitlement, Team and Advanced) and answer 403 when it is explicitly switched off on your licence. A capitalised name in a body or returns line, such as EmployeeFile or LeaveRequest, is an object typed field by field under Data Objects on the HR Records page. The gateway in front of the service adds answers of its own, each { error:string }: 401 \"No token provided\" with no session or token, 503 \"Service temporarily unavailable\" while the service behind it keeps failing, and 504 \"Gateway Timeout\" when it does not answer. A 401 sent by the service itself reaches the caller as 400 with the body unchanged.",
   "rows": 27
  },
  {
   "id": "hr-time-off",
   "name": "Time Off",
   "service": "user-management-service",
   "summary": "Requests, approvals, balances and the setup behind them. All of it needs the HR module (the hris entitlement, Team and Advanced). Requesting, cancelling and deciding need no permission: a person acts on their own time off, and the people above them in the reporting line decide it. A capitalised name in a body or returns line, such as EmployeeFile or LeaveRequest, is an object typed field by field under Data Objects on the HR Records page.",
   "rows": 14
  },
  {
   "id": "hr-goals-reviews-surveys",
   "name": "Goals, Review Cycles & Surveys",
   "service": "user-management-service",
   "summary": "All three need the HR module (the hris entitlement, Team and Advanced). Goals carry no permission on any route: the service decides per person who may read and who may write. A review that is not yours to write or read answers 404. A capitalised name in a body or returns line, such as EmployeeFile or LeaveRequest, is an object typed field by field under Data Objects on the HR Records page.",
   "rows": 20
  },
  {
   "id": "training",
   "name": "Training: Courses, Learning Paths & Imports",
   "service": "user-management-service",
   "summary": "Every route needs Training on your plan (the training entitlement, Team and Advanced) and answers 403 when it is explicitly switched off on your licence. Uploaded video and SCORM import are new and not yet validated end-to-end. A capitalised name in a body or returns line, such as TrainingAssignment, is an object typed field by field under Data Objects on the Training page.",
   "rows": 26
  },
  {
   "id": "phishing-simulation",
   "name": "Phishing Simulation",
   "service": "user-management-service",
   "summary": "Practice phishing emails to your own staff. Part of Training (the training entitlement, Team and Advanced), with its own permission: manage_employee_training is not accepted on any managing route here. PhishingCampaign is typed under Data Objects on the Training page.",
   "rows": 11
  },
  {
   "id": "policy-signing",
   "name": "Policy Signing & People Evidence",
   "service": "compliance-service",
   "summary": "The policies people sign, who has signed, and the signed records. These routes answer when either Training or the Compliance Hub is on your plan, so they are available from Team. The policy register rows themselves are listed under Compliance Hub above.",
   "rows": 20
  }
 ],
 "rows": [
  {
   "id": "POST /auth/login",
   "group": "gw-auth",
   "method": "POST",
   "path": "/auth/login",
   "purpose": "Exchange credentials for a session; returns an MFA or terms-acceptance challenge when required.",
   "auth": {
    "text": "Public",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "A LoginRequest: { username, password } required (username<=255, password<=1024); optional consentAccepted:boolean to accept terms in-line",
   "response": "200 a SessionGrant { auth:true, token } | 200 a challenge instead of a session: a LoginTermsChallenge { termsAcceptanceRequired, userId, termsVersion }, a LoginPasswordChangeChallenge { passwordChangeRequired, userId, requirements, passwordChangeToken } or a LoginMfaChallenge { mfaRequired, mfaStatus, userId, mfaToken, mfaMethods? } (each typed under Response Objects) | 401 bad creds (gateway maps 401/403 to 'Invalid username or password')",
   "responses": [
    {
     "status": 200,
     "text": "a SessionGrant { auth:true, token }"
    },
    {
     "status": 200,
     "text": "a challenge instead of a session: a LoginTermsChallenge { termsAcceptanceRequired, userId, termsVersion }, a LoginPasswordChangeChallenge { passwordChangeRequired, userId, requirements, passwordChangeToken } or a LoginMfaChallenge { mfaRequired, mfaStatus, userId, mfaToken, mfaMethods? } (each typed under Response Objects)"
    },
    {
     "status": 401,
     "text": "bad creds (gateway maps 401/403 to 'Invalid username or password')"
    }
   ],
   "errors": [
    {
     "status": 401,
     "text": "bad creds (gateway maps 401/403 to 'Invalid username or password')"
    }
   ],
   "guard": "5 consecutive fails => 15-min lockout (403); blocked statuses (disabled/inactive/deleted/terminated/suspended/offboarded/archived) refused; directory-synced (non-local source) refused 403; admins always forced into MFA"
  },
  {
   "id": "POST /auth/logout",
   "group": "gw-auth",
   "method": "POST",
   "path": "/auth/logout",
   "purpose": "Invalidate the current session and clear the session cookie.",
   "auth": {
    "text": "Public",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No body from client — gateway reads the session token from the __Host-token cookie and forwards { token } to identity",
   "response": "200 { ok:true } always",
   "responses": [
    {
     "status": 200,
     "text": "{ ok:true } always"
    }
   ],
   "errors": [],
   "guard": "no auth guard (works mid-MFA); token added to blacklist best-effort so a stolen JWT stops working; server-side notify failure never blocks cookie clear"
  },
  {
   "id": "GET /auth/me",
   "group": "gw-auth",
   "method": "GET",
   "path": "/auth/me",
   "purpose": "Return the signed-in user with resolved roles, groups, and effective permissions.",
   "auth": {
    "text": "Session",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { auth:true, user: CurrentUser } where CurrentUser is { id:integer, username:string (the account email), firstName:string|null, lastName:string|null, roles:string[] (role names), groups:string[] (group names), permissions:string[] (permission names), mfaEnabled:boolean, mfaVerified:boolean, mfaRequired:boolean, supportSession?:SupportSessionInfo (present only in a support-access session) }, typed under Response Objects | 401 { auth:false } when the token is missing, invalid, blacklisted, or the account is blocked or its sessions were invalidated | 503 { degraded:true, message } when the profile cannot be resolved right now (the session is still valid; retry)",
   "responses": [
    {
     "status": 200,
     "text": "{ auth:true, user: CurrentUser } where CurrentUser is { id:integer, username:string (the account email), firstName:string|null, lastName:string|null, roles:string[] (role names), groups:string[] (group names), permissions:string[] (permission names), mfaEnabled:boolean, mfaVerified:boolean, mfaRequired:boolean, supportSession?:SupportSessionInfo (present only in a support-access session) }, typed under Response Objects"
    },
    {
     "status": 401,
     "text": "{ auth:false } when the token is missing, invalid, blacklisted, or the account is blocked or its sessions were invalidated"
    },
    {
     "status": 503,
     "text": "{ degraded:true, message } when the profile cannot be resolved right now (the session is still valid; retry)"
    }
   ],
   "errors": [
    {
     "status": 401,
     "text": "{ auth:false } when the token is missing, invalid, blacklisted, or the account is blocked or its sessions were invalidated"
    },
    {
     "status": 503,
     "text": "{ degraded:true, message } when the profile cannot be resolved right now (the session is still valid; retry)"
    }
   ],
   "guard": "read-only and safe to repeat; a 503 is not a sign-out — only a 401 means the session is gone"
  },
  {
   "id": "POST /auth/refresh",
   "group": "gw-auth",
   "method": "POST",
   "path": "/auth/refresh",
   "purpose": "Refresh the session token before expiry.",
   "auth": {
    "text": "Session",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No body from client — gateway extracts token from the Authorization bearer/cookie and forwards { token }",
   "response": "200 a SessionGrant { auth:true, token } (new session, also set as the session cookie) | 401 from the gateway's own session check (no token, or a session that is expired, revoked or no longer active) | 400 { error, correlationId } when the identity service then refuses the token: blacklisted, expired, or an MFA, password-change, magic-link or terms token (the identity service answers 401; the gateway returns it as 400) | 403 account not active | 404 user not found",
   "responses": [
    {
     "status": 200,
     "text": "a SessionGrant { auth:true, token } (new session, also set as the session cookie)"
    },
    {
     "status": 401,
     "text": "from the gateway's own session check (no token, or a session that is expired, revoked or no longer active)"
    },
    {
     "status": 400,
     "text": "{ error, correlationId } when the identity service then refuses the token: blacklisted, expired, or an MFA, password-change, magic-link or terms token (the identity service answers 401; the gateway returns it as 400)"
    },
    {
     "status": 403,
     "text": "account not active"
    },
    {
     "status": 404,
     "text": "user not found"
    }
   ],
   "errors": [
    {
     "status": 401,
     "text": "from the gateway's own session check (no token, or a session that is expired, revoked or no longer active)"
    },
    {
     "status": 400,
     "text": "{ error, correlationId } when the identity service then refuses the token: blacklisted, expired, or an MFA, password-change, magic-link or terms token (the identity service answers 401; the gateway returns it as 400)"
    },
    {
     "status": 403,
     "text": "account not active"
    },
    {
     "status": 404,
     "text": "user not found"
    }
   ],
   "guard": "requires authenticateToken at gateway; refuses non-session token types (isMfaSession/isPasswordChangeSession/isMagicLink) so a single-use email credential can't mint a session"
  },
  {
   "id": "POST /auth/password/change",
   "group": "gw-auth",
   "method": "POST",
   "path": "/auth/password/change",
   "purpose": "Change your own password (enforces the configured password policy).",
   "auth": {
    "text": "Session",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "A PasswordChangeRequest: { newPassword } required; the single-use password-change token goes in the Authorization: Bearer header, and a call without that header is answered 401 — target user derived from token, never from body",
   "response": "200 a PasswordChangeResult { success:true, message, devicesRevoked:integer } | 400 policy fail or same-as-current | 401 missing/invalid/expired token | 403 token is not a password-change token | 404 user not found",
   "responses": [
    {
     "status": 200,
     "text": "a PasswordChangeResult { success:true, message, devicesRevoked:integer }"
    },
    {
     "status": 400,
     "text": "policy fail or same-as-current"
    },
    {
     "status": 401,
     "text": "missing/invalid/expired token"
    },
    {
     "status": 403,
     "text": "token is not a password-change token"
    },
    {
     "status": 404,
     "text": "user not found"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "policy fail or same-as-current"
    },
    {
     "status": 401,
     "text": "missing/invalid/expired token"
    },
    {
     "status": 403,
     "text": "token is not a password-change token"
    },
    {
     "status": 404,
     "text": "user not found"
    }
   ],
   "guard": "anti-IDOR: account taken from verified token claims only; token single-use (blacklisted after); revokes ALL trusted devices as breach response; authLimiter rate-limited"
  },
  {
   "id": "POST /auth/sudo",
   "group": "gw-auth",
   "method": "POST",
   "path": "/auth/sudo",
   "purpose": "Step-up re-authentication that unlocks sudo-gated actions for a short window.",
   "auth": {
    "text": "Session",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "A StepUpRequest: { password } required; current session token in Authorization: Bearer header",
   "response": "200 a SessionGrant { auth:true, token } (sudo-elevated token, also set as the session cookie) | 400 { error:'Invalid password', correlationId } for a wrong password (with code NO_LOCAL_PASSWORD and a longer message on a directory-synced account that has no password here), and 400 'Invalid user' or 'Session is no longer valid' when the account or session is refused (the identity service answers 401; the gateway returns it as 400) | 401 only from the gateway's own session check, when there is no valid session at all | 429 when the step-up attempt budget is used up",
   "responses": [
    {
     "status": 200,
     "text": "a SessionGrant { auth:true, token } (sudo-elevated token, also set as the session cookie)"
    },
    {
     "status": 400,
     "text": "{ error:'Invalid password', correlationId } for a wrong password (with code NO_LOCAL_PASSWORD and a longer message on a directory-synced account that has no password here), and 400 'Invalid user' or 'Session is no longer valid' when the account or session is refused (the identity service answers 401; the gateway returns it as 400)"
    },
    {
     "status": 401,
     "text": "only from the gateway's own session check, when there is no valid session at all"
    },
    {
     "status": 429,
     "text": "when the step-up attempt budget is used up"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ error:'Invalid password', correlationId } for a wrong password (with code NO_LOCAL_PASSWORD and a longer message on a directory-synced account that has no password here), and 400 'Invalid user' or 'Session is no longer valid' when the account or session is refused (the identity service answers 401; the gateway returns it as 400)"
    },
    {
     "status": 401,
     "text": "only from the gateway's own session check, when there is no valid session at all"
    },
    {
     "status": 429,
     "text": "when the step-up attempt budget is used up"
    }
   ],
   "guard": "re-verifies the user's password (bcrypt) to step up; sudoLimiter rate-limited; blocked-status users refused; failures audit-logged"
  },
  {
   "id": "POST /auth/magic-link/request",
   "group": "gw-auth",
   "method": "POST",
   "path": "/auth/magic-link/request",
   "purpose": "Email a one-time sign-in link.",
   "auth": {
    "text": "Public",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "A SignInLinkRequest: { email } required",
   "response": "200 { message:'If an account exists…' } whether or not the account exists (anti-enumeration) — the sign-in token is never in the response, it goes only to the mailbox | 400 { error, correlationId }: 'email is required' when email is missing or empty, 'Enter your email address.' when it is only spaces | 409 { error:'Email sign-in is not available on this system. Please sign in with your password instead.' } when email delivery (SMTP) is not configured on the instance",
   "responses": [
    {
     "status": 200,
     "text": "{ message:'If an account exists…' } whether or not the account exists (anti-enumeration) — the sign-in token is never in the response, it goes only to the mailbox"
    },
    {
     "status": 400,
     "text": "{ error, correlationId }: 'email is required' when email is missing or empty, 'Enter your email address.' when it is only spaces"
    },
    {
     "status": 409,
     "text": "{ error:'Email sign-in is not available on this system. Please sign in with your password instead.' } when email delivery (SMTP) is not configured on the instance"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ error, correlationId }: 'email is required' when email is missing or empty, 'Enter your email address.' when it is only spaces"
    },
    {
     "status": 409,
     "text": "{ error:'Email sign-in is not available on this system. Please sign in with your password instead.' } when email delivery (SMTP) is not configured on the instance"
    }
   ],
   "guard": "only status 'active' users get a link; email send is best-effort (a send failure never reveals account existence); token never logged or persisted; magicLinkRequestLimiter — 30 requests per 15 minutes per IP, every request counted"
  },
  {
   "id": "GET /auth/magic-link/verify",
   "group": "gw-auth",
   "method": "GET",
   "path": "/auth/magic-link/verify",
   "purpose": "Redeem a magic-link token. Opens a session, or hands back the next step when one is owed.",
   "auth": {
    "text": "Public",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query: ?token= required",
   "response": "200 a SessionGrant { auth:true, token } (session) — or, instead of a session, a LoginMfaChallenge when a second factor is required, a LoginPasswordChangeChallenge when a change is forced, or a LoginTermsChallenge { termsAcceptanceRequired:true, userId, termsVersion, termsToken } (each typed under Response Objects) when the current terms are not yet accepted | 400 { error, correlationId } in every refusal: 'Token is required' when ?token= is missing, and 'Magic link expired or invalid', 'This magic link has already been used.', 'Invalid token type' or 'Account is not active' otherwise (the identity service answers 401; the gateway returns it as 400)",
   "responses": [
    {
     "status": 200,
     "text": "a SessionGrant { auth:true, token } (session) — or, instead of a session, a LoginMfaChallenge when a second factor is required, a LoginPasswordChangeChallenge when a change is forced, or a LoginTermsChallenge { termsAcceptanceRequired:true, userId, termsVersion, termsToken } (each typed under Response Objects) when the current terms are not yet accepted"
    },
    {
     "status": 400,
     "text": "{ error, correlationId } in every refusal: 'Token is required' when ?token= is missing, and 'Magic link expired or invalid', 'This magic link has already been used.', 'Invalid token type' or 'Account is not active' otherwise (the identity service answers 401; the gateway returns it as 400)"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ error, correlationId } in every refusal: 'Token is required' when ?token= is missing, and 'Magic link expired or invalid', 'This magic link has already been used.', 'Invalid token type' or 'Account is not active' otherwise (the identity service answers 401; the gateway returns it as 400)"
    }
   ],
   "guard": "single use: the token is claimed atomically before anything is issued, so a replay is refused with 400 'This magic link has already been used.'"
  },
  {
   "id": "POST /auth/accept-terms",
   "group": "gw-auth",
   "method": "POST",
   "path": "/auth/accept-terms",
   "purpose": "Accept the current terms when a sign-in handed back a terms token instead of a session (passkey, magic-link and SSO sign-ins can; a password sign-in instead re-sends POST /auth/login with consentAccepted:true).",
   "auth": {
    "text": "Public",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ termsToken, consentAccepted:true } both required",
   "response": "200 a SessionGrant { auth:true, token } (session, also set as the session cookie) — or, instead of a session, the next step still owed: a password-change token when a change is forced, or an MFA challenge | 400 'Missing token.' | 400 'You must accept the Terms of Service and Privacy Policy to continue.' when consentAccepted is not true | 400 { error, correlationId } when the terms token is expired, already used, not a terms token, or the account is not active (the identity service answers 401; the gateway returns it as 400)",
   "responses": [
    {
     "status": 200,
     "text": "a SessionGrant { auth:true, token } (session, also set as the session cookie) — or, instead of a session, the next step still owed: a password-change token when a change is forced, or an MFA challenge"
    },
    {
     "status": 400,
     "text": "'Missing token.'"
    },
    {
     "status": 400,
     "text": "'You must accept the Terms of Service and Privacy Policy to continue.' when consentAccepted is not true"
    },
    {
     "status": 400,
     "text": "{ error, correlationId } when the terms token is expired, already used, not a terms token, or the account is not active (the identity service answers 401; the gateway returns it as 400)"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "'Missing token.'"
    },
    {
     "status": 400,
     "text": "'You must accept the Terms of Service and Privacy Policy to continue.' when consentAccepted is not true"
    },
    {
     "status": 400,
     "text": "{ error, correlationId } when the terms token is expired, already used, not a terms token, or the account is not active (the identity service answers 401; the gateway returns it as 400)"
    }
   ],
   "guard": "single use: the terms token is claimed before anything is issued, so a replay is refused; counted by the sign-in rate limiter"
  },
  {
   "id": "GET /auth/setup/status",
   "group": "gw-auth",
   "method": "GET",
   "path": "/auth/setup/status",
   "purpose": "Report whether first-run setup is still pending, and whether a one-time setup key is required (setupKeyRequired). Used by the setup wizard.",
   "auth": {
    "text": "Public",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { initialSetupCompleted, setupKeyRequired, setupKeyNotConfiguredMessage (string or null), branding:{ companyName, logoUrl, primaryBrandColor } } | 503 { error, correlationId } when setup state cannot be read (never reported as 'not set up'); retry",
   "responses": [
    {
     "status": 200,
     "text": "{ initialSetupCompleted, setupKeyRequired, setupKeyNotConfiguredMessage (string or null), branding:{ companyName, logoUrl, primaryBrandColor } }"
    },
    {
     "status": 503,
     "text": "{ error, correlationId } when setup state cannot be read (never reported as 'not set up'); retry"
    }
   ],
   "errors": [
    {
     "status": 503,
     "text": "{ error, correlationId } when setup state cannot be read (never reported as 'not set up'); retry"
    }
   ],
   "guard": null
  },
  {
   "id": "POST /auth/setup/unlock",
   "group": "gw-auth",
   "method": "POST",
   "path": "/auth/setup/unlock",
   "purpose": "Check the one-time setup key before the wizard shows its steps. It completes nothing; the key is checked again on complete.",
   "auth": {
    "text": "Public",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ setupKey } required",
   "response": "200 { ok:true } | 401 invalid setup key | 410 setup key expired | 429 too many attempts | 409 code SETUP_KEY_NOT_CONFIGURED (no setup key is configured on the installation)",
   "responses": [
    {
     "status": 200,
     "text": "{ ok:true }"
    },
    {
     "status": 401,
     "text": "invalid setup key"
    },
    {
     "status": 410,
     "text": "setup key expired"
    },
    {
     "status": 429,
     "text": "too many attempts"
    },
    {
     "status": 409,
     "text": "code SETUP_KEY_NOT_CONFIGURED (no setup key is configured on the installation)"
    }
   ],
   "errors": [
    {
     "status": 401,
     "text": "invalid setup key"
    },
    {
     "status": 410,
     "text": "setup key expired"
    },
    {
     "status": 429,
     "text": "too many attempts"
    },
    {
     "status": 409,
     "text": "code SETUP_KEY_NOT_CONFIGURED (no setup key is configured on the installation)"
    }
   ],
   "guard": "no session (no admin exists yet); 10 failed attempts per source IP per 15 minutes, then a 15-minute lockout; every rejection is audit-logged"
  },
  {
   "id": "POST /auth/setup/complete",
   "group": "gw-auth",
   "method": "POST",
   "path": "/auth/setup/complete",
   "purpose": "Create the first owner admin and initial security settings. First run only.",
   "auth": {
    "text": "Public",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ adminInfo:{ email, password } } required; setupKey required whenever the status call reports setupKeyRequired; optional acceptedTerms:boolean, updates ({ mode:'manual' } or { mode:'automatic', day:'sun'..'sat', time:'HH:MM', timezone: IANA zone } — the wizard's Updates step), securitySettings (object; the wizard sends none and the seeder applies its defaults)",
   "response": "200 { success:true } | 401 invalid setup key | 410 setup key expired | 429 too many setup-key attempts | 400 missing email/password, password below bootstrap policy, or an invalid updates value | 409 code SETUP_KEY_NOT_CONFIGURED | 403 setup already completed / a primary admin already exists | 409 setup already in progress | 503 cannot verify setup state",
   "responses": [
    {
     "status": 200,
     "text": "{ success:true }"
    },
    {
     "status": 401,
     "text": "invalid setup key"
    },
    {
     "status": 410,
     "text": "setup key expired"
    },
    {
     "status": 429,
     "text": "too many setup-key attempts"
    },
    {
     "status": 400,
     "text": "missing email/password, password below bootstrap policy, or an invalid updates value"
    },
    {
     "status": 409,
     "text": "code SETUP_KEY_NOT_CONFIGURED"
    },
    {
     "status": 403,
     "text": "setup already completed / a primary admin already exists"
    },
    {
     "status": 409,
     "text": "setup already in progress"
    },
    {
     "status": 503,
     "text": "cannot verify setup state"
    }
   ],
   "errors": [
    {
     "status": 401,
     "text": "invalid setup key"
    },
    {
     "status": 410,
     "text": "setup key expired"
    },
    {
     "status": 429,
     "text": "too many setup-key attempts"
    },
    {
     "status": 400,
     "text": "missing email/password, password below bootstrap policy, or an invalid updates value"
    },
    {
     "status": 409,
     "text": "code SETUP_KEY_NOT_CONFIGURED"
    },
    {
     "status": 403,
     "text": "setup already completed / a primary admin already exists"
    },
    {
     "status": 409,
     "text": "setup already in progress"
    },
    {
     "status": 503,
     "text": "cannot verify setup state"
    }
   ],
   "guard": "first-run only; the setup key is re-checked here (this is the authoritative gate); Postgres advisory lock serializes concurrent calls; anchored to the FACT (refuses if any isPrimaryAdmin user exists), fails CLOSED; enforces min-12 + upper + number + symbol admin password regardless of operator policy; setupLimiter rate-limited"
  },
  {
   "id": "GET /auth/settings/branding",
   "group": "gw-auth",
   "method": "GET",
   "path": "/auth/settings/branding",
   "purpose": "Public branding (name, logo, colours) for the login screen.",
   "auth": {
    "text": "Public",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { companyName, logoUrl, primaryBrandColor, timeZone, locale }. If the identity service does not answer, still 200 with the last branding it served (or the product defaults, without timeZone/locale) plus degraded:true",
   "responses": [
    {
     "status": 200,
     "text": "{ companyName, logoUrl, primaryBrandColor, timeZone, locale }. If the identity service does not answer, still 200 with the last branding it served (or the product defaults, without timeZone/locale) plus degraded:true"
    }
   ],
   "errors": [],
   "guard": "a body carrying degraded:true is a fallback, not the saved settings — do not write it back"
  },
  {
   "id": "POST /auth/mfa/setup/init",
   "group": "gw-mfa",
   "method": "POST",
   "path": "/auth/mfa/setup/init",
   "purpose": "Begin TOTP enrolment; returns the secret and provisioning URI.",
   "auth": {
    "text": "Session",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "An MfaSetupStart: { mfaToken } optional — MFA-challenge or full session token; else read from Authorization Bearer header. No other fields.",
   "response": "200 a TotpSetup { secret, otpauth_url } (typed under Response Objects); stages a PENDING TOTP secret | 400 { error, code?, correlationId } when the token is missing ('Authentication token required') or expired (code MFA_SESSION_EXPIRED) (the identity service answers 401; the gateway returns it as 400) | 403 wrong session type, or enrolment not allowed for this session | 404 user not found",
   "responses": [
    {
     "status": 200,
     "text": "a TotpSetup { secret, otpauth_url } (typed under Response Objects); stages a PENDING TOTP secret"
    },
    {
     "status": 400,
     "text": "{ error, code?, correlationId } when the token is missing ('Authentication token required') or expired (code MFA_SESSION_EXPIRED) (the identity service answers 401; the gateway returns it as 400)"
    },
    {
     "status": 403,
     "text": "wrong session type, or enrolment not allowed for this session"
    },
    {
     "status": 404,
     "text": "user not found"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ error, code?, correlationId } when the token is missing ('Authentication token required') or expired (code MFA_SESSION_EXPIRED) (the identity service answers 401; the gateway returns it as 400)"
    },
    {
     "status": 403,
     "text": "wrong session type, or enrolment not allowed for this session"
    },
    {
     "status": 404,
     "text": "user not found"
    }
   ],
   "guard": "token must be enrollment type (MFA-challenge or full session, not magic-link/pw-change); denyUnlessMayEnroll blocks re-enroll on an already-MFA account unless MFA-verified"
  },
  {
   "id": "POST /auth/mfa/setup/confirm",
   "group": "gw-mfa",
   "method": "POST",
   "path": "/auth/mfa/setup/confirm",
   "purpose": "Confirm TOTP enrolment with a first code and issue recovery codes.",
   "auth": {
    "text": "Session",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "An MfaSetupConfirm: { token } required (6-digit TOTP code); { mfaToken } optional (else Authorization Bearer).",
   "response": "200 an MfaEnrolmentResult { auth:true, token, recoveryCodes } (recoveryCodes is null when the account already had codes) | 400 { error, code?, correlationId } in every code or token refusal: 'MFA code is required', 'No MFA setup in progress. Restart enrollment.', and a wrong code ('Invalid token'), a missing token or an expired session (code MFA_SESSION_EXPIRED) (the identity service answers 401; the gateway returns it as 400) | 403 wrong session type | 404 user not found",
   "responses": [
    {
     "status": 200,
     "text": "an MfaEnrolmentResult { auth:true, token, recoveryCodes } (recoveryCodes is null when the account already had codes)"
    },
    {
     "status": 400,
     "text": "{ error, code?, correlationId } in every code or token refusal: 'MFA code is required', 'No MFA setup in progress. Restart enrollment.', and a wrong code ('Invalid token'), a missing token or an expired session (code MFA_SESSION_EXPIRED) (the identity service answers 401; the gateway returns it as 400)"
    },
    {
     "status": 403,
     "text": "wrong session type"
    },
    {
     "status": 404,
     "text": "user not found"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ error, code?, correlationId } in every code or token refusal: 'MFA code is required', 'No MFA setup in progress. Restart enrollment.', and a wrong code ('Invalid token'), a missing token or an expired session (code MFA_SESSION_EXPIRED) (the identity service answers 401; the gateway returns it as 400)"
    },
    {
     "status": 403,
     "text": "wrong session type"
    },
    {
     "status": 404,
     "text": "user not found"
    }
   ],
   "guard": "enrollment token type + denyUnlessMayEnroll; only a proven code promotes pendingSecret to live mfaSecret"
  },
  {
   "id": "POST /auth/mfa/verify",
   "group": "gw-mfa",
   "method": "POST",
   "path": "/auth/mfa/verify",
   "purpose": "Verify a TOTP code to complete a login challenge.",
   "auth": {
    "text": "Public (login challenge)",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "An MfaVerifyRequest: { mfaToken, token } required — mfaToken is the login-challenge JWT, token is the TOTP code; { rememberDevice } optional bool.",
   "response": "200 an MfaVerifyResult { auth:true, token, trustedDeviceToken } (trustedDeviceToken is null unless rememberDevice was true) | 400 code NO_TOTP_ENROLLED | 400 { error, code?, correlationId } for a wrong code, a reused code (code TOTP_REPLAY), a missing mfaToken, or a challenge that expired or was used up by wrong codes (code MFA_SESSION_EXPIRED: start again from POST /auth/login) (the identity service answers 401; the gateway returns it as 400) | 403 not an MFA-session token | 404 user not found | 423 code MFA_LOCKED when repeated wrong codes locked the account for 15 minutes",
   "responses": [
    {
     "status": 200,
     "text": "an MfaVerifyResult { auth:true, token, trustedDeviceToken } (trustedDeviceToken is null unless rememberDevice was true)"
    },
    {
     "status": 400,
     "text": "code NO_TOTP_ENROLLED"
    },
    {
     "status": 400,
     "text": "{ error, code?, correlationId } for a wrong code, a reused code (code TOTP_REPLAY), a missing mfaToken, or a challenge that expired or was used up by wrong codes (code MFA_SESSION_EXPIRED: start again from POST /auth/login) (the identity service answers 401; the gateway returns it as 400)"
    },
    {
     "status": 403,
     "text": "not an MFA-session token"
    },
    {
     "status": 404,
     "text": "user not found"
    },
    {
     "status": 423,
     "text": "code MFA_LOCKED when repeated wrong codes locked the account for 15 minutes"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "code NO_TOTP_ENROLLED"
    },
    {
     "status": 400,
     "text": "{ error, code?, correlationId } for a wrong code, a reused code (code TOTP_REPLAY), a missing mfaToken, or a challenge that expired or was used up by wrong codes (code MFA_SESSION_EXPIRED: start again from POST /auth/login) (the identity service answers 401; the gateway returns it as 400)"
    },
    {
     "status": 403,
     "text": "not an MFA-session token"
    },
    {
     "status": 404,
     "text": "user not found"
    },
    {
     "status": 423,
     "text": "code MFA_LOCKED when repeated wrong codes locked the account for 15 minutes"
    }
   ],
   "guard": "mfaToken.isMfaSession must be true; TOTP verified against user.mfaSecret window 1"
  },
  {
   "id": "POST /auth/mfa/verify-recovery",
   "group": "gw-mfa",
   "method": "POST",
   "path": "/auth/mfa/verify-recovery",
   "purpose": "Complete a login challenge using a one-time recovery code.",
   "auth": {
    "text": "Public (login challenge)",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "An MfaRecoveryVerify: { mfaToken, recoveryCode } required (recoveryCode a string); { rememberDevice } optional bool.",
   "response": "200 an MfaVerifyResult { auth:true, token, trustedDeviceToken } (trustedDeviceToken is null unless rememberDevice was true) | 400 'Recovery code is required' / 'No recovery codes available for this user' | 400 { error, code?, correlationId } for a wrong code, a missing mfaToken or an expired challenge (code MFA_SESSION_EXPIRED) (the identity service answers 401; the gateway returns it as 400) | 403 not an MFA-session token | 404 user not found | 423 code MFA_LOCKED when repeated wrong codes locked the account for 15 minutes",
   "responses": [
    {
     "status": 200,
     "text": "an MfaVerifyResult { auth:true, token, trustedDeviceToken } (trustedDeviceToken is null unless rememberDevice was true)"
    },
    {
     "status": 400,
     "text": "'Recovery code is required' / 'No recovery codes available for this user'"
    },
    {
     "status": 400,
     "text": "{ error, code?, correlationId } for a wrong code, a missing mfaToken or an expired challenge (code MFA_SESSION_EXPIRED) (the identity service answers 401; the gateway returns it as 400)"
    },
    {
     "status": 403,
     "text": "not an MFA-session token"
    },
    {
     "status": 404,
     "text": "user not found"
    },
    {
     "status": 423,
     "text": "code MFA_LOCKED when repeated wrong codes locked the account for 15 minutes"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "'Recovery code is required' / 'No recovery codes available for this user'"
    },
    {
     "status": 400,
     "text": "{ error, code?, correlationId } for a wrong code, a missing mfaToken or an expired challenge (code MFA_SESSION_EXPIRED) (the identity service answers 401; the gateway returns it as 400)"
    },
    {
     "status": 403,
     "text": "not an MFA-session token"
    },
    {
     "status": 404,
     "text": "user not found"
    },
    {
     "status": 423,
     "text": "code MFA_LOCKED when repeated wrong codes locked the account for 15 minutes"
    }
   ],
   "guard": "mfaToken.isMfaSession required; one-time — matched code marked used, bcrypt-compared against stored hashes"
  },
  {
   "id": "POST /auth/mfa/recovery-codes/regenerate",
   "group": "gw-mfa",
   "method": "POST",
   "path": "/auth/mfa/recovery-codes/regenerate",
   "purpose": "Invalidate and reissue your recovery codes.",
   "auth": {
    "text": "Session",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No body. Caller identified from Authorization Bearer (full session); gateway also forwards cookie token.",
   "response": "200 { recoveryCodes } (new raw set, invalidates old) | 400 { error, code?, correlationId } when the session token is missing ('Authentication required') or expired (code MFA_SESSION_EXPIRED) (the identity service answers 401; the gateway returns it as 400) | 403 wrong session type or code MFA_VERIFICATION_REQUIRED | 404 user not found",
   "responses": [
    {
     "status": 200,
     "text": "{ recoveryCodes } (new raw set, invalidates old)"
    },
    {
     "status": 400,
     "text": "{ error, code?, correlationId } when the session token is missing ('Authentication required') or expired (code MFA_SESSION_EXPIRED) (the identity service answers 401; the gateway returns it as 400)"
    },
    {
     "status": 403,
     "text": "wrong session type or code MFA_VERIFICATION_REQUIRED"
    },
    {
     "status": 404,
     "text": "user not found"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ error, code?, correlationId } when the session token is missing ('Authentication required') or expired (code MFA_SESSION_EXPIRED) (the identity service answers 401; the gateway returns it as 400)"
    },
    {
     "status": 403,
     "text": "wrong session type or code MFA_VERIFICATION_REQUIRED"
    },
    {
     "status": 404,
     "text": "user not found"
    }
   ],
   "guard": "must be a full session (not mfaSession/magicLink/pwChange) AND decoded.mfaVerified===true"
  },
  {
   "id": "GET /auth/webauthn/register/generate",
   "group": "gw-mfa",
   "method": "GET",
   "path": "/auth/webauthn/register/generate",
   "purpose": "Get a WebAuthn registration challenge for a new passkey.",
   "auth": {
    "text": "Session",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query: ?mfaToken= optional (the login-challenge token during enrolment); otherwise the session is read from the Authorization bearer or cookie",
   "response": "200 PasskeyRegistrationOptions (typed under Response Objects): WebAuthn registration options (challenge, rp, user, excludeCredentials listing the passkeys already registered; attestation 'none', residentKey 'preferred', userVerification 'required'). The server passes no algorithm list and no prompt timeout, so the WebAuthn library the instance uses decides pubKeyCredParams and timeout | 400 { error, code?, correlationId } when the token is missing or expired (the identity service answers 401; the gateway returns it as 400) | 403 'Invalid session type' or enrolment not allowed for this session | 404 user not found",
   "responses": [
    {
     "status": 200,
     "text": "PasskeyRegistrationOptions (typed under Response Objects): WebAuthn registration options (challenge, rp, user, excludeCredentials listing the passkeys already registered; attestation 'none', residentKey 'preferred', userVerification 'required'). The server passes no algorithm list and no prompt timeout, so the WebAuthn library the instance uses decides pubKeyCredParams and timeout"
    },
    {
     "status": 400,
     "text": "{ error, code?, correlationId } when the token is missing or expired (the identity service answers 401; the gateway returns it as 400)"
    },
    {
     "status": 403,
     "text": "'Invalid session type' or enrolment not allowed for this session"
    },
    {
     "status": 404,
     "text": "user not found"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ error, code?, correlationId } when the token is missing or expired (the identity service answers 401; the gateway returns it as 400)"
    },
    {
     "status": 403,
     "text": "'Invalid session type' or enrolment not allowed for this session"
    },
    {
     "status": 404,
     "text": "user not found"
    }
   ],
   "guard": "each call stores a fresh one-shot challenge for the user and replaces the previous one, so only the latest options can be verified"
  },
  {
   "id": "POST /auth/webauthn/register/verify",
   "group": "gw-mfa",
   "method": "POST",
   "path": "/auth/webauthn/register/verify",
   "purpose": "Verify the attestation and store the passkey credential.",
   "auth": {
    "text": "Session",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "attestation as whole body, or { attestation } when { mfaToken } rides alongside; token from mfaToken or Authorization Bearer.",
   "response": "200 { verified:true, recoveryCodes } (+ token when enrolling via mfaToken; codes only if account had none) | 400 challenge expired / verification failed | 400 { error, code?, correlationId } when the token is missing or expired (the identity service answers 401; the gateway returns it as 400) | 403 wrong session type | 404 user not found",
   "responses": [
    {
     "status": 200,
     "text": "{ verified:true, recoveryCodes } (+ token when enrolling via mfaToken; codes only if account had none)"
    },
    {
     "status": 400,
     "text": "challenge expired / verification failed"
    },
    {
     "status": 400,
     "text": "{ error, code?, correlationId } when the token is missing or expired (the identity service answers 401; the gateway returns it as 400)"
    },
    {
     "status": 403,
     "text": "wrong session type"
    },
    {
     "status": 404,
     "text": "user not found"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "challenge expired / verification failed"
    },
    {
     "status": 400,
     "text": "{ error, code?, correlationId } when the token is missing or expired (the identity service answers 401; the gateway returns it as 400)"
    },
    {
     "status": 403,
     "text": "wrong session type"
    },
    {
     "status": 404,
     "text": "user not found"
    }
   ],
   "guard": "isEnrollmentTokenType + denyUnlessMayEnroll; server-stored one-shot challenge reg_<userId> consumed regardless of outcome; enables MFA, clears staged TOTP secret"
  },
  {
   "id": "GET /auth/webauthn/authenticate/generate",
   "group": "gw-mfa",
   "method": "GET",
   "path": "/auth/webauthn/authenticate/generate",
   "purpose": "Get a WebAuthn authentication challenge.",
   "auth": {
    "text": "Public (login challenge)",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query: ?mfaToken= required (the login-challenge token)",
   "response": "200 WebAuthn authentication options (challenge, allowCredentials = the user's passkeys, userVerification 'required') | 400 { error, code?, correlationId } when mfaToken is missing or expired (the identity service answers 401; the gateway returns it as 400) | 403 not an MFA-session token | 404 user not found, or 'No WebAuthn credentials found for user'",
   "responses": [
    {
     "status": 200,
     "text": "WebAuthn authentication options (challenge, allowCredentials = the user's passkeys, userVerification 'required')"
    },
    {
     "status": 400,
     "text": "{ error, code?, correlationId } when mfaToken is missing or expired (the identity service answers 401; the gateway returns it as 400)"
    },
    {
     "status": 403,
     "text": "not an MFA-session token"
    },
    {
     "status": 404,
     "text": "user not found, or 'No WebAuthn credentials found for user'"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ error, code?, correlationId } when mfaToken is missing or expired (the identity service answers 401; the gateway returns it as 400)"
    },
    {
     "status": 403,
     "text": "not an MFA-session token"
    },
    {
     "status": 404,
     "text": "user not found, or 'No WebAuthn credentials found for user'"
    }
   ],
   "guard": null
  },
  {
   "id": "POST /auth/webauthn/authenticate/verify",
   "group": "gw-mfa",
   "method": "POST",
   "path": "/auth/webauthn/authenticate/verify",
   "purpose": "Verify the assertion and complete passkey sign-in.",
   "auth": {
    "text": "Public (login challenge)",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ mfaToken, response } required — mfaToken the login-challenge JWT, response the WebAuthn assertion (response.id selects the credential).",
   "response": "200 { auth:true, token } | 400 'Passkey response missing', 'Challenge expired or not found' or the verifier's error | 400 { error, code?, correlationId } for a missing or expired mfaToken and for 'Verification failed' (the identity service answers 401; the gateway returns it as 400) | 403 not an MFA-session token | 404 user or credential not found",
   "responses": [
    {
     "status": 200,
     "text": "{ auth:true, token }"
    },
    {
     "status": 400,
     "text": "'Passkey response missing', 'Challenge expired or not found' or the verifier's error"
    },
    {
     "status": 400,
     "text": "{ error, code?, correlationId } for a missing or expired mfaToken and for 'Verification failed' (the identity service answers 401; the gateway returns it as 400)"
    },
    {
     "status": 403,
     "text": "not an MFA-session token"
    },
    {
     "status": 404,
     "text": "user or credential not found"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "'Passkey response missing', 'Challenge expired or not found' or the verifier's error"
    },
    {
     "status": 400,
     "text": "{ error, code?, correlationId } for a missing or expired mfaToken and for 'Verification failed' (the identity service answers 401; the gateway returns it as 400)"
    },
    {
     "status": 403,
     "text": "not an MFA-session token"
    },
    {
     "status": 404,
     "text": "user or credential not found"
    }
   ],
   "guard": "one-shot challenge auth_<userId> consumed before verify (no retry); credential scoped to user.id; counter advanced on success"
  },
  {
   "id": "GET /auth/webauthn/login/options",
   "group": "gw-mfa",
   "method": "GET",
   "path": "/auth/webauthn/login/options",
   "purpose": "Start a passkey sign-in with no username typed: a challenge any passkey registered on this instance can answer.",
   "auth": {
    "text": "Public",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 WebAuthn authentication options (challenge, rpId, userVerification 'required'; no allowCredentials, so the browser offers the passkeys it holds for this instance)",
   "responses": [
    {
     "status": 200,
     "text": "WebAuthn authentication options (challenge, rpId, userVerification 'required'; no allowCredentials, so the browser offers the passkeys it holds for this instance)"
    }
   ],
   "errors": [],
   "guard": "the challenge is stored server-side and must come back in POST /auth/webauthn/login/verify"
  },
  {
   "id": "POST /auth/webauthn/login/verify",
   "group": "gw-mfa",
   "method": "POST",
   "path": "/auth/webauthn/login/verify",
   "purpose": "Finish a passkey sign-in. The passkey counts as both factors, so no separate MFA challenge follows.",
   "auth": {
    "text": "Public",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The WebAuthn assertion as the whole body, or { response: <assertion> }",
   "response": "200 { auth:true, token } (session, also set as the session cookie) — or, instead of a session, a password-change token when a change is forced, or { termsAcceptanceRequired:true, userId, termsVersion, termsToken } | 400 'Malformed passkey assertion.' / 'Malformed client data.' / 'Challenge expired or not found. Please try again.' / the verifier's error | 401 { error, code:'UNKNOWN_PASSKEY' } for a passkey this instance does not know, 401 'Passkey verification failed.' | 403 when the account is locked or not active",
   "responses": [
    {
     "status": 200,
     "text": "{ auth:true, token } (session, also set as the session cookie) — or, instead of a session, a password-change token when a change is forced, or { termsAcceptanceRequired:true, userId, termsVersion, termsToken }"
    },
    {
     "status": 400,
     "text": "'Malformed passkey assertion.' / 'Malformed client data.' / 'Challenge expired or not found. Please try again.' / the verifier's error"
    },
    {
     "status": 401,
     "text": "{ error, code:'UNKNOWN_PASSKEY' } for a passkey this instance does not know, 401 'Passkey verification failed.'"
    },
    {
     "status": 403,
     "text": "when the account is locked or not active"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "'Malformed passkey assertion.' / 'Malformed client data.' / 'Challenge expired or not found. Please try again.' / the verifier's error"
    },
    {
     "status": 401,
     "text": "{ error, code:'UNKNOWN_PASSKEY' } for a passkey this instance does not know, 401 'Passkey verification failed.'"
    },
    {
     "status": 403,
     "text": "when the account is locked or not active"
    }
   ],
   "guard": "this route forwards the identity service's status unchanged, so a 401 here is a 401 (unlike the MFA-challenge routes above)"
  },
  {
   "id": "GET /auth/webauthn/credentials",
   "group": "gw-mfa",
   "method": "GET",
   "path": "/auth/webauthn/credentials",
   "purpose": "List your registered passkeys.",
   "auth": {
    "text": "Session",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 a PasskeyList { credentials:[{ id, transports, createdAt }] (oldest first, every passkey in one reply), totpEnrolled:boolean, recoveryCodesRemaining:number } | 400 { error, code?, correlationId } when the session token is missing or expired (the identity service answers 401; the gateway returns it as 400) | 403 'Invalid session type' (an MFA-challenge, magic-link or password-change token) | 404 user not found",
   "responses": [
    {
     "status": 200,
     "text": "a PasskeyList { credentials:[{ id, transports, createdAt }] (oldest first, every passkey in one reply), totpEnrolled:boolean, recoveryCodesRemaining:number }"
    },
    {
     "status": 400,
     "text": "{ error, code?, correlationId } when the session token is missing or expired (the identity service answers 401; the gateway returns it as 400)"
    },
    {
     "status": 403,
     "text": "'Invalid session type' (an MFA-challenge, magic-link or password-change token)"
    },
    {
     "status": 404,
     "text": "user not found"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ error, code?, correlationId } when the session token is missing or expired (the identity service answers 401; the gateway returns it as 400)"
    },
    {
     "status": 403,
     "text": "'Invalid session type' (an MFA-challenge, magic-link or password-change token)"
    },
    {
     "status": 404,
     "text": "user not found"
    }
   ],
   "guard": "returns everything (no paging); never returns key material"
  },
  {
   "id": "DELETE /auth/webauthn/credentials/:id",
   "group": "gw-mfa",
   "method": "DELETE",
   "path": "/auth/webauthn/credentials/:id",
   "purpose": "Remove one of your passkeys.",
   "auth": {
    "text": "Session + sudo",
    "kind": "session",
    "permissions": [],
    "stepUp": "required",
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body. Caller resolved from own session token (Authorization Bearer or cookie).",
   "response": "200 { ok:true } | 401 SUDO_REQUIRED | 403 MFA_VERIFICATION_REQUIRED | 404 passkey not found | 409 LAST_FACTOR",
   "responses": [
    {
     "status": 200,
     "text": "{ ok:true }"
    },
    {
     "status": 401,
     "text": "SUDO_REQUIRED"
    },
    {
     "status": 403,
     "text": "MFA_VERIFICATION_REQUIRED"
    },
    {
     "status": 404,
     "text": "passkey not found"
    },
    {
     "status": 409,
     "text": "LAST_FACTOR"
    }
   ],
   "errors": [
    {
     "status": 401,
     "text": "SUDO_REQUIRED"
    },
    {
     "status": 403,
     "text": "MFA_VERIFICATION_REQUIRED"
    },
    {
     "status": 404,
     "text": "passkey not found"
    },
    {
     "status": 409,
     "text": "LAST_FACTOR"
    }
   ],
   "guard": "fresh step-up (sudo) required; session.mfaVerified===true required; credential scoped to caller userId; refuses to remove the last remaining factor (passkeyCount<=1 && no mfaSecret)"
  },
  {
   "id": "GET /auth/webauthn/sudo/available",
   "group": "gw-mfa",
   "method": "GET",
   "path": "/auth/webauthn/sudo/available",
   "purpose": "Whether you have a passkey that can be used for step-up (sudo).",
   "auth": {
    "text": "Session",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { available:boolean } (true when the caller has at least one passkey) | 400 { error, code?, correlationId } when the session token is missing or expired (the identity service answers 401; the gateway returns it as 400) | 403 'Invalid session type'",
   "responses": [
    {
     "status": 200,
     "text": "{ available:boolean } (true when the caller has at least one passkey)"
    },
    {
     "status": 400,
     "text": "{ error, code?, correlationId } when the session token is missing or expired (the identity service answers 401; the gateway returns it as 400)"
    },
    {
     "status": 403,
     "text": "'Invalid session type'"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ error, code?, correlationId } when the session token is missing or expired (the identity service answers 401; the gateway returns it as 400)"
    },
    {
     "status": 403,
     "text": "'Invalid session type'"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /auth/webauthn/sudo/options",
   "group": "gw-mfa",
   "method": "GET",
   "path": "/auth/webauthn/sudo/options",
   "purpose": "Get a challenge for a passkey step-up.",
   "auth": {
    "text": "Session",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 WebAuthn authentication options for the caller's passkeys | 404 { error:'NO_PASSKEY', message } when no passkey is enrolled | 401 no or expired session | 403 'Invalid session type'",
   "responses": [
    {
     "status": 200,
     "text": "WebAuthn authentication options for the caller's passkeys"
    },
    {
     "status": 404,
     "text": "{ error:'NO_PASSKEY', message } when no passkey is enrolled"
    },
    {
     "status": 401,
     "text": "no or expired session"
    },
    {
     "status": 403,
     "text": "'Invalid session type'"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "{ error:'NO_PASSKEY', message } when no passkey is enrolled"
    },
    {
     "status": 401,
     "text": "no or expired session"
    },
    {
     "status": 403,
     "text": "'Invalid session type'"
    }
   ],
   "guard": "30 requests / 15 min per user (challenge budget)"
  },
  {
   "id": "POST /auth/webauthn/sudo/verify",
   "group": "gw-mfa",
   "method": "POST",
   "path": "/auth/webauthn/sudo/verify",
   "purpose": "Verify the passkey assertion and open the step-up window, exactly as POST /auth/sudo does with a password.",
   "auth": {
    "text": "Session",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The WebAuthn assertion as the whole body (its id selects the credential)",
   "response": "200 { auth:true, token } — a sudo-stamped session token, also set as the session cookie | 400 'Challenge expired or not found' | 400 code ASSERTION_NOT_VERIFIED | 400 the verifier's error message | 404 'Credential not found' | 401 no or expired session | 403 'Invalid session type'",
   "responses": [
    {
     "status": 200,
     "text": "{ auth:true, token } — a sudo-stamped session token, also set as the session cookie"
    },
    {
     "status": 400,
     "text": "'Challenge expired or not found'"
    },
    {
     "status": 400,
     "text": "code ASSERTION_NOT_VERIFIED"
    },
    {
     "status": 400,
     "text": "the verifier's error message"
    },
    {
     "status": 404,
     "text": "'Credential not found'"
    },
    {
     "status": 401,
     "text": "no or expired session"
    },
    {
     "status": 403,
     "text": "'Invalid session type'"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "'Challenge expired or not found'"
    },
    {
     "status": 400,
     "text": "code ASSERTION_NOT_VERIFIED"
    },
    {
     "status": 400,
     "text": "the verifier's error message"
    },
    {
     "status": 404,
     "text": "'Credential not found'"
    },
    {
     "status": 401,
     "text": "no or expired session"
    },
    {
     "status": 403,
     "text": "'Invalid session type'"
    }
   ],
   "guard": "5 failed attempts / 15 min, the same budget as POST /auth/sudo; cannot create a session, only re-stamp an existing one; not repeatable: the challenge is consumed before verification, so a retry needs new options from GET /auth/webauthn/sudo/options"
  },
  {
   "id": "GET /auth/devices",
   "group": "gw-mfa",
   "method": "GET",
   "path": "/auth/devices",
   "purpose": "List your trusted devices / active sessions.",
   "auth": {
    "text": "Session",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { devices: TrustedDevice[] } (typed under Response Objects), each { id, userAgent, ipAddress, lastUsedAt, expiresAt, createdAt, current:boolean }, most recently used first, every device in one reply (no paging) | 400 { error, code?, correlationId } when the session token is missing or expired (the identity service answers 401; the gateway returns it as 400) | 403 wrong session type",
   "responses": [
    {
     "status": 200,
     "text": "{ devices: TrustedDevice[] } (typed under Response Objects), each { id, userAgent, ipAddress, lastUsedAt, expiresAt, createdAt, current:boolean }, most recently used first, every device in one reply (no paging)"
    },
    {
     "status": 400,
     "text": "{ error, code?, correlationId } when the session token is missing or expired (the identity service answers 401; the gateway returns it as 400)"
    },
    {
     "status": 403,
     "text": "wrong session type"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ error, code?, correlationId } when the session token is missing or expired (the identity service answers 401; the gateway returns it as 400)"
    },
    {
     "status": 403,
     "text": "wrong session type"
    }
   ],
   "guard": "expired entries are purged before the list is built; returns everything (no paging)"
  },
  {
   "id": "DELETE /auth/devices/all",
   "group": "gw-mfa",
   "method": "DELETE",
   "path": "/auth/devices/all",
   "purpose": "Revoke every trusted device on your account.",
   "auth": {
    "text": "Session",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No body. Caller resolved from own session token (Authorization Bearer or cookie).",
   "response": "200 { ok:true, revoked } — count of trusted devices dropped | 400 { error, correlationId } when the session token is missing or expired (the identity service answers 401; the gateway returns it as 400)",
   "responses": [
    {
     "status": 200,
     "text": "{ ok:true, revoked } — count of trusted devices dropped"
    },
    {
     "status": 400,
     "text": "{ error, correlationId } when the session token is missing or expired (the identity service answers 401; the gateway returns it as 400)"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ error, correlationId } when the session token is missing or expired (the identity service answers 401; the gateway returns it as 400)"
    }
   ],
   "guard": "session must be a full session (rejects mfaSession/magicLink/pwChange, requires username); scoped to caller id"
  },
  {
   "id": "DELETE /auth/devices/:id",
   "group": "gw-mfa",
   "method": "DELETE",
   "path": "/auth/devices/:id",
   "purpose": "Revoke a single trusted device.",
   "auth": {
    "text": "Session",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "string",
     "basis": "unknown"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body. Caller resolved from own session token.",
   "response": "200 { ok:true } | 400 { error, correlationId } when the session token is missing or expired (the identity service answers 401; the gateway returns it as 400) | 403 wrong session type | 404 'Device not found'",
   "responses": [
    {
     "status": 200,
     "text": "{ ok:true }"
    },
    {
     "status": 400,
     "text": "{ error, correlationId } when the session token is missing or expired (the identity service answers 401; the gateway returns it as 400)"
    },
    {
     "status": 403,
     "text": "wrong session type"
    },
    {
     "status": 404,
     "text": "'Device not found'"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ error, correlationId } when the session token is missing or expired (the identity service answers 401; the gateway returns it as 400)"
    },
    {
     "status": 403,
     "text": "wrong session type"
    },
    {
     "status": 404,
     "text": "'Device not found'"
    }
   ],
   "guard": "destroy scoped to { id, userId: caller } so one user cannot revoke another's device"
  },
  {
   "id": "POST /auth/security/mfa/reset/:userId",
   "group": "gw-mfa",
   "method": "POST",
   "path": "/auth/security/mfa/reset/:userId",
   "purpose": "Administratively reset another user's MFA enrolment.",
   "auth": {
    "text": "Session + manage_security + sudo",
    "kind": "session",
    "permissions": [
     "manage_security"
    ],
    "stepUp": "required",
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "userId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body. Target userId in path; actor read from x-user header injected by gateway.",
   "response": "200 a FactorResetResult { ok:true, email, passkeysRemoved, devicesRevoked } | 400 { error:'SUDO_REQUIRED', correlationId } without a fresh step-up (the identity service answers 401 SUDO_REQUIRED; on this route the gateway returns it as 400, so match on the error text, not on a 401) | 403 without manage_security | 404 user not found",
   "responses": [
    {
     "status": 200,
     "text": "a FactorResetResult { ok:true, email, passkeysRemoved, devicesRevoked }"
    },
    {
     "status": 400,
     "text": "{ error:'SUDO_REQUIRED', correlationId } without a fresh step-up (the identity service answers 401 SUDO_REQUIRED; on this route the gateway returns it as 400, so match on the error text, not on a 401)"
    },
    {
     "status": 403,
     "text": "without manage_security"
    },
    {
     "status": 404,
     "text": "user not found"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ error:'SUDO_REQUIRED', correlationId } without a fresh step-up (the identity service answers 401 SUDO_REQUIRED; on this route the gateway returns it as 400, so match on the error text, not on a 401)"
    },
    {
     "status": 403,
     "text": "without manage_security"
    },
    {
     "status": 404,
     "text": "user not found"
    }
   ],
   "guard": "manage_security + sudo (requireSudo) at route; destructive — wipes passkeys, TOTP secrets, recovery codes AND all trusted devices; always audited"
  },
  {
   "id": "POST /auth/security/devices/revoke/:userId",
   "group": "gw-mfa",
   "method": "POST",
   "path": "/auth/security/devices/revoke/:userId",
   "purpose": "Administratively revoke another user's trusted devices.",
   "auth": {
    "text": "Session + manage_security + sudo",
    "kind": "session",
    "permissions": [
     "manage_security"
    ],
    "stepUp": "required",
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "userId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body. Target userId in path; actor read from x-user header.",
   "response": "200 { ok:true, email, revoked } | 400 { error:'SUDO_REQUIRED', correlationId } without a fresh step-up (the identity service answers 401 SUDO_REQUIRED; on this route the gateway returns it as 400, so match on the error text, not on a 401) | 403 without manage_security | 404 user not found",
   "responses": [
    {
     "status": 200,
     "text": "{ ok:true, email, revoked }"
    },
    {
     "status": 400,
     "text": "{ error:'SUDO_REQUIRED', correlationId } without a fresh step-up (the identity service answers 401 SUDO_REQUIRED; on this route the gateway returns it as 400, so match on the error text, not on a 401)"
    },
    {
     "status": 403,
     "text": "without manage_security"
    },
    {
     "status": 404,
     "text": "user not found"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ error:'SUDO_REQUIRED', correlationId } without a fresh step-up (the identity service answers 401 SUDO_REQUIRED; on this route the gateway returns it as 400, so match on the error text, not on a 401)"
    },
    {
     "status": 403,
     "text": "without manage_security"
    },
    {
     "status": 404,
     "text": "user not found"
    }
   ],
   "guard": "manage_security + sudo (requireSudo) at route; revokes only trusted devices (keeps user's factors); audited"
  },
  {
   "id": "GET /auth/settings/security",
   "group": "gw-system",
   "method": "GET",
   "path": "/auth/settings/security",
   "purpose": "Read security settings (MFA policy, session timeout, password policy, SMTP).",
   "auth": {
    "text": "Session + manage_security | view_users | view_dashboard",
    "kind": "session",
    "permissions": [
     "manage_security",
     "view_dashboard",
     "view_users"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 a SecuritySettings object (typed under Response Objects): mfaEnabled, passwordPolicies[] (each a PasswordPolicy = { id:string ('default' is the organisation-wide Standard Policy; any other id is a policy scoped to groups), name:string, minLength:integer (8 to 128), requireUppercase:boolean, requireNumbers:boolean, requireSymbols:boolean, groupIds:integer[] (the groups it applies to; empty on 'default') }), passwordHistoryCount, namingConvention, emailDomain, conflictStrategy, sessionTimeout, auditLogRetentionDays, auditLogRetention, workflowRequireSeparateApprover, workflowRequireSeparateRunApprover, reportRecipientPolicy, reportRecipientDomains, the module switches (awsMappingEnabled, azureMappingEnabled, gcpMappingEnabled, securityLabEnabled, complianceEnabled, reportingEnabled, financeEnabled), licenceFlagsExplicitlyOff, activeAiModel, aiModelRouting, aiEnabled, aiRequireUsePermission, smtpConfigured, branding:{ companyName, logoUrl, primaryBrandColor }",
   "responses": [
    {
     "status": 200,
     "text": "a SecuritySettings object (typed under Response Objects): mfaEnabled, passwordPolicies[] (each a PasswordPolicy = { id:string ('default' is the organisation-wide Standard Policy; any other id is a policy scoped to groups), name:string, minLength:integer (8 to 128), requireUppercase:boolean, requireNumbers:boolean, requireSymbols:boolean, groupIds:integer[] (the groups it applies to; empty on 'default') }), passwordHistoryCount, namingConvention, emailDomain, conflictStrategy, sessionTimeout, auditLogRetentionDays, auditLogRetention, workflowRequireSeparateApprover, workflowRequireSeparateRunApprover, reportRecipientPolicy, reportRecipientDomains, the module switches (awsMappingEnabled, azureMappingEnabled, gcpMappingEnabled, securityLabEnabled, complianceEnabled, reportingEnabled, financeEnabled), licenceFlagsExplicitlyOff, activeAiModel, aiModelRouting, aiEnabled, aiRequireUsePermission, smtpConfigured, branding:{ companyName, logoUrl, primaryBrandColor }"
    }
   ],
   "errors": [],
   "guard": "itNotificationRecipients, hrNotificationRecipients and the smtp* fields (smtpHost, smtpPort, smtpUser, smtpFrom, smtpSecure, smtpPassSet) are present only for a caller with manage_security or the administrator role; the SMTP password itself is never returned. Merge rule for passwordPolicies: a person is held to 'default' plus every policy whose groupIds contains a group they belong to, and the strictest value of each rule wins (the largest minLength; a character class required by any one of them stays required). A first-run instance is seeded with one policy: id 'default', name 'Standard Policy', minLength 8, uppercase, number and symbol all required"
  },
  {
   "id": "POST /auth/settings/security",
   "group": "gw-system",
   "method": "POST",
   "path": "/auth/settings/security",
   "purpose": "Update security settings.",
   "auth": {
    "text": "Session + manage_security + sudo",
    "kind": "session",
    "permissions": [
     "manage_security"
    ],
    "stepUp": "required",
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "All optional (only provided keys change): mfaEnabled bool, passwordPolicies[], namingConvention, sessionTimeout, awsMappingEnabled/azureMappingEnabled/gcpMappingEnabled/securityLabEnabled/complianceEnabled/reportingEnabled/financeEnabled bool, workflowRequireSeparateApprover bool, activeAiModel, branding{companyName,logoUrl,primaryBrandColor}, smtp{Host,Port,User,From,Secure,Pass}, adminEmail",
   "response": "200 { ok:true } | 400 { error, correlationId } and nothing saved when a value is refused: a passwordPolicies element that is not an object, or whose minLength is not a whole number from 8 to 128 ('Password policy \"<name>\": minimum length must be a whole number from 8 to 128. Nothing was saved.'); sessionTimeout outside 5m to 24h; audit-log retention outside 90 to 3650 days; password history outside 0 to 24; an activeAiModel or aiModelRouting entry not on the allowlist | 400 for an unknown reportRecipientPolicy; settings earlier in the same body may already have been saved | 400 { error:'SUDO_REQUIRED', correlationId } without a fresh step-up (the identity service answers 401 SUDO_REQUIRED; on this route the gateway returns it as 400) | 403 { error:'License-managed setting', correlationId } when a module switch is not yours to change (the sentence naming the modules does not pass the gateway)",
   "responses": [
    {
     "status": 200,
     "text": "{ ok:true }"
    },
    {
     "status": 400,
     "text": "{ error, correlationId } and nothing saved when a value is refused: a passwordPolicies element that is not an object, or whose minLength is not a whole number from 8 to 128 ('Password policy \"<name>\": minimum length must be a whole number from 8 to 128. Nothing was saved.'); sessionTimeout outside 5m to 24h; audit-log retention outside 90 to 3650 days; password history outside 0 to 24; an activeAiModel or aiModelRouting entry not on the allowlist"
    },
    {
     "status": 400,
     "text": "for an unknown reportRecipientPolicy; settings earlier in the same body may already have been saved"
    },
    {
     "status": 400,
     "text": "{ error:'SUDO_REQUIRED', correlationId } without a fresh step-up (the identity service answers 401 SUDO_REQUIRED; on this route the gateway returns it as 400)"
    },
    {
     "status": 403,
     "text": "{ error:'License-managed setting', correlationId } when a module switch is not yours to change (the sentence naming the modules does not pass the gateway)"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ error, correlationId } and nothing saved when a value is refused: a passwordPolicies element that is not an object, or whose minLength is not a whole number from 8 to 128 ('Password policy \"<name>\": minimum length must be a whole number from 8 to 128. Nothing was saved.'); sessionTimeout outside 5m to 24h; audit-log retention outside 90 to 3650 days; password history outside 0 to 24; an activeAiModel or aiModelRouting entry not on the allowlist"
    },
    {
     "status": 400,
     "text": "for an unknown reportRecipientPolicy; settings earlier in the same body may already have been saved"
    },
    {
     "status": 400,
     "text": "{ error:'SUDO_REQUIRED', correlationId } without a fresh step-up (the identity service answers 401 SUDO_REQUIRED; on this route the gateway returns it as 400)"
    },
    {
     "status": 403,
     "text": "{ error:'License-managed setting', correlationId } when a module switch is not yours to change (the sentence naming the modules does not pass the gateway)"
    }
   ],
   "guard": "gateway manage_security + identity requireSudo; activeAiModel must match AI_MODELS allowlist (no slash/scheme/host) to block Ollama SSRF; smtpPass write-only, '' leaves stored pass unchanged; mfaEnabled is the organisation-wide default that groups set to inherit follow, and writing it changes no group. passwordPolicies REPLACES the whole stored array (a policy left out is deleted), each element a PasswordPolicy as typed on the GET row; an out-of-range minLength is accepted only when it is unchanged from the stored value of the same id. Saving a new or changed 'default' policy forces every account with a local password to choose a new one at next sign-in; a new or changed group policy does the same for the members of its groupIds"
  },
  {
   "id": "POST /auth/settings/branding",
   "group": "gw-system",
   "method": "POST",
   "path": "/auth/settings/branding",
   "purpose": "Save company branding and the instance's regional defaults (the Branding page).",
   "auth": {
    "text": "Session + manage_security",
    "kind": "session",
    "permissions": [
     "manage_security"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ branding:{ companyName, logoUrl, primaryBrandColor } } or the same three fields at the top level; timeZone (an IANA name such as Europe/London) and locale (a tag such as en-GB) inside branding or at the top level. Only the fields sent change",
   "response": "200 { success:true } | 400 'No branding fields supplied.' | 400 unknown time zone or locale, naming the value",
   "responses": [
    {
     "status": 200,
     "text": "{ success:true }"
    },
    {
     "status": 400,
     "text": "'No branding fields supplied.'"
    },
    {
     "status": 400,
     "text": "unknown time zone or locale, naming the value"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "'No branding fields supplied.'"
    },
    {
     "status": 400,
     "text": "unknown time zone or locale, naming the value"
    }
   ],
   "guard": "audited as BRANDING_UPDATED and, when a regional default changed, REGIONAL_SETTINGS_UPDATED"
  },
  {
   "id": "POST /auth/settings/security/smtp/test",
   "group": "gw-system",
   "method": "POST",
   "path": "/auth/settings/security/smtp/test",
   "purpose": "Send one test email. With host, user and pass all in the body it uses those without saving them; otherwise it uses the saved mail settings.",
   "auth": {
    "text": "Session + manage_security",
    "kind": "session",
    "permissions": [
     "manage_security"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ to (required), host?, port?, user?, pass?, from?, secure? }",
   "response": "200 { success:true, messageId, message } | 400 { error, correlationId } when to is missing ('A recipient email address is required.') or no mail server is configured | 424 { success:false, error } when the mail server refused the message or could not be reached; error carries the mail server's reason",
   "responses": [
    {
     "status": 200,
     "text": "{ success:true, messageId, message }"
    },
    {
     "status": 400,
     "text": "{ error, correlationId } when to is missing ('A recipient email address is required.') or no mail server is configured"
    },
    {
     "status": 424,
     "text": "{ success:false, error } when the mail server refused the message or could not be reached; error carries the mail server's reason"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ error, correlationId } when to is missing ('A recipient email address is required.') or no mail server is configured"
    },
    {
     "status": 424,
     "text": "{ success:false, error } when the mail server refused the message or could not be reached; error carries the mail server's reason"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /auth/settings/security/access-control",
   "group": "gw-system",
   "method": "GET",
   "path": "/auth/settings/security/access-control",
   "purpose": "Read the geo/IP access-control rule set.",
   "auth": {
    "text": "Session + manage_security",
    "kind": "session",
    "permissions": [
     "manage_security"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 AccessControlRules (typed under Response Objects), the stored rule set merged over the defaults: { enabled, mode:'allowlist'|'blocklist', countries[], blockedIps[], allowedIps[], blockUnknownGeo, dryRun, autoBlock:{ enabled, dryRun, threshold, windowSec, banSec, patterns, tuning }, … }, plus geoDbLoaded:boolean added by the gateway (whether its offline country database loaded; country rules do nothing when false). With nothing saved: enabled:false, mode:'blocklist', empty lists, autoBlock off and in dry-run",
   "responses": [
    {
     "status": 200,
     "text": "AccessControlRules (typed under Response Objects), the stored rule set merged over the defaults: { enabled, mode:'allowlist'|'blocklist', countries[], blockedIps[], allowedIps[], blockUnknownGeo, dryRun, autoBlock:{ enabled, dryRun, threshold, windowSec, banSec, patterns, tuning }, … }, plus geoDbLoaded:boolean added by the gateway (whether its offline country database loaded; country rules do nothing when false). With nothing saved: enabled:false, mode:'blocklist', empty lists, autoBlock off and in dry-run"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /auth/settings/security/access-control",
   "group": "gw-system",
   "method": "POST",
   "path": "/auth/settings/security/access-control",
   "purpose": "Update geo/IP access rules (mode, countries, IP lists, dry run).",
   "auth": {
    "text": "Session + manage_security + sudo",
    "kind": "session",
    "permissions": [
     "manage_security"
    ],
    "stepUp": "required",
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "All coerced/optional: enabled bool, mode 'allowlist'|'blocklist' (anything else->blocklist), countries[], blockedIps[], allowedIps[], blockUnknownGeo bool, dryRun bool",
   "response": "200 { ok:true } (writes ACCESS_CONTROL_UPDATED audit log) | 400 { error:'SUDO_REQUIRED', correlationId } without a fresh step-up (the identity service answers 401 SUDO_REQUIRED; on this route the gateway returns it as 400, so match on the error text, not on a 401)",
   "responses": [
    {
     "status": 200,
     "text": "{ ok:true } (writes ACCESS_CONTROL_UPDATED audit log)"
    },
    {
     "status": 400,
     "text": "{ error:'SUDO_REQUIRED', correlationId } without a fresh step-up (the identity service answers 401 SUDO_REQUIRED; on this route the gateway returns it as 400, so match on the error text, not on a 401)"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ error:'SUDO_REQUIRED', correlationId } without a fresh step-up (the identity service answers 401 SUDO_REQUIRED; on this route the gateway returns it as 400, so match on the error text, not on a 401)"
    }
   ],
   "guard": "Session + manage_security + a fresh step-up; non-array list fields default to []; unknown mode coerced to blocklist"
  },
  {
   "id": "POST /auth/settings/security/access-control/invalidate-cache",
   "group": "gw-system",
   "method": "POST",
   "path": "/auth/settings/security/access-control/invalidate-cache",
   "purpose": "Force the gateway to reload access rules instead of waiting for the 60s cache.",
   "auth": {
    "text": "Session + manage_security",
    "kind": "session",
    "permissions": [
     "manage_security"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { ok:true }",
   "responses": [
    {
     "status": 200,
     "text": "{ ok:true }"
    }
   ],
   "errors": [],
   "guard": "Session + manage_security; handled at gateway (calls invalidateGeoCache), never proxied downstream"
  },
  {
   "id": "POST /auth/log",
   "group": "gw-system",
   "method": "POST",
   "path": "/auth/log",
   "purpose": "Write a compliance/audit-execution event to the central audit log from the app.",
   "auth": {
    "text": "Session + manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ action, details } required; level optional ('info' default); payload optional object",
   "response": "200 { ok:true }",
   "responses": [
    {
     "status": 200,
     "text": "{ ok:true }"
    }
   ],
   "errors": [],
   "guard": "manage_compliance at the gateway; the logging service accepts only an allow-list of audit actions and clamps field sizes; the actor is taken from the signed session, not the body"
  },
  {
   "id": "GET /auth/audits/history",
   "group": "gw-system",
   "method": "GET",
   "path": "/auth/audits/history",
   "purpose": "List historical audit records.",
   "auth": {
    "text": "Session + manage_compliance | view_reporting",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_reporting"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [
    "offset"
   ],
   "query": [
    {
     "name": "summary",
     "type": "string",
     "note": "Any value selects the paged summaries."
    },
    {
     "name": "offset",
     "type": "integer",
     "default": 0,
     "minimum": 0,
     "note": "Read only together with summary. The page size is fixed at 100."
    }
   ],
   "request": "Query: ?summary=1 selects the paged summaries; ?offset= (default 0, only read together with summary). Fixed page size 100, newest completedDate first. Without ?summary the first page of the same shape is returned (the old unpaged list is retired; the gateway response carries no deprecation header)",
   "response": "200 { truncated:boolean (another page exists), nextOffset (number or null), items:[{ id, name, framework, startDate, endDate, completedDate, completedBy, score, sealed, sealValid, sealedAt, verifiedScore, evidenceCount }] } — no evidence rows; fetch one record for those",
   "responses": [
    {
     "status": 200,
     "text": "{ truncated:boolean (another page exists), nextOffset (number or null), items:[{ id, name, framework, startDate, endDate, completedDate, completedBy, score, sealed, sealValid, sealedAt, verifiedScore, evidenceCount }] } — no evidence rows; fetch one record for those"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /auth/audits/history/:id",
   "group": "gw-system",
   "method": "GET",
   "path": "/auth/audits/history/:id",
   "purpose": "One completed audit in full (evidence rows and sealed verdicts).",
   "auth": {
    "text": "Session + manage_compliance | view_reporting",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_reporting"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "string",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { id, name, framework, startDate, endDate, completedDate, completedBy, score, sealed, sealValid, sealedAt, sealedVerdicts, sealedScope, verifiedScore, completedItems:[…evidence rows] } | 404 'Audit record not found'",
   "responses": [
    {
     "status": 200,
     "text": "{ id, name, framework, startDate, endDate, completedDate, completedBy, score, sealed, sealValid, sealedAt, sealedVerdicts, sealedScope, verifiedScore, completedItems:[…evidence rows] }"
    },
    {
     "status": 404,
     "text": "'Audit record not found'"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'Audit record not found'"
    }
   ],
   "guard": null
  },
  {
   "id": "POST /auth/audits/history",
   "group": "gw-system",
   "method": "POST",
   "path": "/auth/audits/history",
   "purpose": "Save a historical audit record.",
   "auth": {
    "text": "Session + manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ id, name, framework, startDate, completedDate, completedBy } + completedItems[] (each {id, evidenceDetails{status,notes,rawData,attachmentUrl}}), totalControls optional",
   "response": "200 { AuditRecord } (finalized/completed; the gateway answers 200 whatever the service status)",
   "responses": [
    {
     "status": 200,
     "text": "{ AuditRecord } (finalized/completed; the gateway answers 200 whatever the service status)"
    }
   ],
   "errors": [],
   "guard": "Session + manage_compliance; score is RECOMPUTED server-side from evidence status (client score ignored) — a control passes only with >=1 passing and 0 failing evidence; idempotent finalize (upsert by id)"
  },
  {
   "id": "DELETE /auth/audits/history/:id",
   "group": "gw-system",
   "method": "DELETE",
   "path": "/auth/audits/history/:id",
   "purpose": "Delete a historical audit record.",
   "auth": {
    "text": "Session + manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "string",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { ok:true, deleted:id } | 404 not found | 409 if record is COMPLETED (immutable)",
   "responses": [
    {
     "status": 200,
     "text": "{ ok:true, deleted:id }"
    },
    {
     "status": 404,
     "text": "not found"
    },
    {
     "status": 409,
     "text": "if record is COMPLETED (immutable)"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "not found"
    },
    {
     "status": 409,
     "text": "if record is COMPLETED (immutable)"
    }
   ],
   "guard": "Session + manage_compliance; only DRAFT (non-completed) records deletable — completed audit evidence is immutable, refusal itself is logged"
  },
  {
   "id": "GET /auth/audits/active",
   "group": "gw-system",
   "method": "GET",
   "path": "/auth/audits/active",
   "purpose": "List in-progress audit sessions.",
   "auth": {
    "text": "Session + manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 [ { id, name, framework, startDate, endDate, completedDate (null), score, completedItems:[{ id, title, evidenceDetails:{ status, notes, … } }] } ], newest startDate first; the standing continuous-monitoring record is excluded",
   "responses": [
    {
     "status": 200,
     "text": "[ { id, name, framework, startDate, endDate, completedDate (null), score, completedItems:[{ id, title, evidenceDetails:{ status, notes, … } }] } ], newest startDate first; the standing continuous-monitoring record is excluded"
    }
   ],
   "errors": [],
   "guard": "bare array, no paging"
  },
  {
   "id": "POST /auth/audits/active",
   "group": "gw-system",
   "method": "POST",
   "path": "/auth/audits/active",
   "purpose": "Create or update an in-progress audit session.",
   "auth": {
    "text": "Session + manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ id, name, framework, score, startDate, completedBy } + endDate optional, completedItems[] (each {id, evidenceDetails{status,notes,rawData,attachmentUrl}})",
   "response": "200 { AuditRecord } (completedDate forced null = in-progress)",
   "responses": [
    {
     "status": 200,
     "text": "{ AuditRecord } (completedDate forced null = in-progress)"
    }
   ],
   "errors": [],
   "guard": "Session + manage_compliance; upsert by id; autosave preserves harvester evidence tagged '[ACTIVE VERIFICATION]', wiping only manual evidence before re-insert"
  },
  {
   "id": "DELETE /auth/audits/active/:id",
   "group": "gw-system",
   "method": "DELETE",
   "path": "/auth/audits/active/:id",
   "purpose": "End an in-progress audit session.",
   "auth": {
    "text": "Session + manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "string",
     "basis": "unknown"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { ok:true, deleted:id } | 404 not found",
   "responses": [
    {
     "status": 200,
     "text": "{ ok:true, deleted:id }"
    },
    {
     "status": 404,
     "text": "not found"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "not found"
    }
   ],
   "guard": "Session + manage_compliance; only deletes records with completedDate=null (in-progress); cascades AuditEvidence delete"
  },
  {
   "id": "GET /health/cluster",
   "group": "gw-system",
   "method": "GET",
   "path": "/health/cluster",
   "purpose": "Aggregate health of the instance (operational / degraded / maintenance). Deliberately returns no per-service detail to anonymous callers.",
   "auth": {
    "text": "Public",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { status:'operational', timestamp:string (ISO 8601 date-time) } when every expected service is up | 503 { status:'degraded'|'maintenance', timestamp:string } otherwise ('maintenance' = no expected service is up or degraded)",
   "responses": [
    {
     "status": 200,
     "text": "{ status:'operational', timestamp:string (ISO 8601 date-time) } when every expected service is up"
    },
    {
     "status": 503,
     "text": "{ status:'degraded'|'maintenance', timestamp:string } otherwise ('maintenance' = no expected service is up or degraded)"
    }
   ],
   "errors": [
    {
     "status": 503,
     "text": "{ status:'degraded'|'maintenance', timestamp:string } otherwise ('maintenance' = no expected service is up or degraded)"
    }
   ],
   "guard": "services whose module is not licensed are not probed and do not count against the status"
  },
  {
   "id": "GET /health",
   "group": "gw-system",
   "method": "GET",
   "path": "/health",
   "purpose": "Liveness of the API gateway process only. It says nothing about the other services; use /health/cluster for that.",
   "auth": {
    "text": "Public",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { status:'UP', service:'api-gateway' }",
   "responses": [
    {
     "status": 200,
     "text": "{ status:'UP', service:'api-gateway' }"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /user-management/users",
   "group": "users",
   "method": "GET",
   "path": "/user-management/users",
   "purpose": "List users (server-side pagination, filtering, search): ?limit (default 50, max 500), ?offset, ?status, ?source, ?mfa, ?role, ?manager, ?search. Returns { count, rows }; full records need view_users, manage_users or manage_permissions, other callers get minimal identity cards.",
   "auth": {
    "text": "Session + view_users | manage_users | manage_permissions | manage_workflows | view_dashboard",
    "kind": "session",
    "permissions": [
     "manage_permissions",
     "manage_users",
     "manage_workflows",
     "view_dashboard",
     "view_users"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [
    "limit",
    "offset"
   ],
   "query": [
    {
     "name": "limit",
     "type": "integer",
     "default": 50,
     "minimum": 1,
     "maximum": 500
    },
    {
     "name": "offset",
     "type": "integer",
     "default": 0,
     "minimum": 0
    },
    {
     "name": "status",
     "type": "string"
    },
    {
     "name": "source",
     "type": "string"
    },
    {
     "name": "mfa",
     "type": "string"
    },
    {
     "name": "role",
     "type": "string",
     "note": "A role name."
    },
    {
     "name": "manager",
     "type": "string"
    },
    {
     "name": "search",
     "type": "string"
    }
   ],
   "request": "Query: ?limit (default 50, max 500; a missing, zero or non-numeric value is the default, a negative one becomes 1), ?offset (default 0), ?status, ?source, ?mfa ('enabled' | anything else = not enabled), ?role (role name), ?manager ('none' | 'assigned'), ?search (up to 6 words, each matched against email, firstName, lastName, sAMAccountName). 'all' on status/source/mfa/role means no filter. No sort parameter: always id descending",
   "response": "200 a UserPage: { count:integer (all matching users), rows:array }, typed under Response Objects. With view_users, manage_users or manage_permissions each row is the full user (the User object; never passwordHash) with Groups, Roles and Manager{ id, firstName, lastName, email }; other callers get { id, firstName, lastName, email, position, department, photoUrl } (the UserCard object) and ?role is ignored",
   "responses": [
    {
     "status": 200,
     "text": "a UserPage: { count:integer (all matching users), rows:array }, typed under Response Objects. With view_users, manage_users or manage_permissions each row is the full user (the User object; never passwordHash) with Groups, Roles and Manager{ id, firstName, lastName, email }; other callers get { id, firstName, lastName, email, position, department, photoUrl } (the UserCard object) and ?role is ignored"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /user-management/users/search",
   "group": "users",
   "method": "GET",
   "path": "/user-management/users/search",
   "purpose": "Typeahead user lookup for pickers.",
   "auth": {
    "text": "Session + view_users | manage_groups | manage_roles | manage_employee_records",
    "kind": "session",
    "permissions": [
     "manage_employee_records",
     "manage_groups",
     "manage_roles",
     "view_users"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query: ?query= (up to 6 words, each matched against email, firstName, lastName, sAMAccountName)",
   "response": "200 [ { id, firstName, lastName, email, sAMAccountName, status } ] — at most 10, ordered by firstName, lastName, id; an empty query answers []",
   "responses": [
    {
     "status": 200,
     "text": "[ { id, firstName, lastName, email, sAMAccountName, status } ] — at most 10, ordered by firstName, lastName, id; an empty query answers []"
    }
   ],
   "errors": [],
   "guard": "no paging; narrow the query instead"
  },
  {
   "id": "GET /user-management/users/stats",
   "group": "users",
   "method": "GET",
   "path": "/user-management/users/stats",
   "purpose": "Aggregate user counts for dashboard tiles.",
   "auth": {
    "text": "Session + view_users | manage_users | view_dashboard",
    "kind": "session",
    "permissions": [
     "manage_users",
     "view_dashboard",
     "view_users"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { total, active, mfaEnabled, synced (source other than local or onboarding), serviceAccounts, mfaEligible (active accounts that can sign in), mfaEligibleEnabled }",
   "responses": [
    {
     "status": 200,
     "text": "{ total, active, mfaEnabled, synced (source other than local or onboarding), serviceAccounts, mfaEligible (active accounts that can sign in), mfaEligibleEnabled }"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /user-management/users/me/export",
   "group": "users",
   "method": "GET",
   "path": "/user-management/users/me/export",
   "purpose": "Export your own profile, groups, roles, and most recent audit entries as JSON (GDPR access request).",
   "auth": {
    "text": "Session",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { exportedAt, requestingUser, profile:{ id, firstName, lastName, email, username, status, department, mfaEnabled, attributes, createdAt, updatedAt }, groups[], roles[], permissions[], recentAuditLogs[] (your newest 200: action, details, level, createdAt), employeeRecord, crossServiceData } | 404 'User profile not found'",
   "responses": [
    {
     "status": 200,
     "text": "{ exportedAt, requestingUser, profile:{ id, firstName, lastName, email, username, status, department, mfaEnabled, attributes, createdAt, updatedAt }, groups[], roles[], permissions[], recentAuditLogs[] (your newest 200: action, details, level, createdAt), employeeRecord, crossServiceData }"
    },
    {
     "status": 404,
     "text": "'User profile not found'"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'User profile not found'"
    }
   ],
   "guard": "each export is audit-logged as PEOPLE_DATA_EXPORTED"
  },
  {
   "id": "POST /user-management/users",
   "group": "users",
   "method": "POST",
   "path": "/user-management/users",
   "purpose": "Create a user.",
   "auth": {
    "text": "Session + manage_users",
    "kind": "session",
    "permissions": [
     "manage_users"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "A UserCreate plus password: firstName, lastName, email, sAMAccountName, password (required if source==='local', unless sendInvite:true is sent instead: the account is created without a password and a set-password invitation is issued; local accounts only), position, department, managerId, status, source, employeeId, phoneNumber, attributes, roleIds[], groupIds[], personaId",
   "response": "201 created User (with sendInvite:true the same object plus an invite field) | 400 sAMAccountName taken / { error:'Password is required for local users (or send an invitation instead).' } when a local account has neither password nor sendInvite:true / password-policy / sendInvite on a non-local source | 403 privilege escalation | 409 a user with that email or login name already exists",
   "responses": [
    {
     "status": 201,
     "text": "created User (with sendInvite:true the same object plus an invite field)"
    },
    {
     "status": 400,
     "text": "sAMAccountName taken / { error:'Password is required for local users (or send an invitation instead).' } when a local account has neither password nor sendInvite:true / password-policy / sendInvite on a non-local source"
    },
    {
     "status": 403,
     "text": "privilege escalation"
    },
    {
     "status": 409,
     "text": "a user with that email or login name already exists"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "sAMAccountName taken / { error:'Password is required for local users (or send an invitation instead).' } when a local account has neither password nor sendInvite:true / password-policy / sendInvite on a non-local source"
    },
    {
     "status": 403,
     "text": "privilege escalation"
    },
    {
     "status": 409,
     "text": "a user with that email or login name already exists"
    }
   ],
   "guard": "assigning roleIds/groupIds at create requires manage_roles; roleIds subject to subset invariant (cannot confer a perm you lack); not safe to repeat blindly: email and sAMAccountName are unique, so a second identical call is refused (409, or 400 for a taken sAMAccountName) rather than creating a duplicate"
  },
  {
   "id": "PUT /user-management/users/:id",
   "group": "users",
   "method": "PUT",
   "path": "/user-management/users/:id",
   "purpose": "Update a user.",
   "auth": {
    "text": "Session + manage_users (+ manage_security and sudo when the body sets a password)",
    "kind": "session",
    "permissions": [
     "manage_security",
     "manage_users"
    ],
    "stepUp": "conditional",
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "A UserUpdate plus isPrimaryAdmin and password. Allowlist: firstName,lastName,email,sAMAccountName,position,department,managerId,personaId,photoUrl,employeeId,phoneNumber,terminationDate; also status, isPrimaryAdmin, password, roleIds[], groupIds[]",
   "response": "200 User | 404 not found | 403 primaryAdmin immutable / reactivation|isPrimaryAdmin need manage_security / roles need manage_roles / groups need manage_groups | 400 name taken/self-manage/cycle/pwd-policy",
   "responses": [
    {
     "status": 200,
     "text": "User"
    },
    {
     "status": 404,
     "text": "not found"
    },
    {
     "status": 403,
     "text": "primaryAdmin immutable / reactivation|isPrimaryAdmin need manage_security / roles need manage_roles / groups need manage_groups"
    },
    {
     "status": 400,
     "text": "name taken/self-manage/cycle/pwd-policy"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "not found"
    },
    {
     "status": 403,
     "text": "primaryAdmin immutable / reactivation|isPrimaryAdmin need manage_security / roles need manage_roles / groups need manage_groups"
    },
    {
     "status": 400,
     "text": "name taken/self-manage/cycle/pwd-policy"
    }
   ],
   "guard": "mass-assign allowlist; a body that carries password (resetting another user's password) additionally requires the manage_security permission (403 without it) and a sudo step-up; reactivate & isPrimaryAdmin need manage_security; roleIds subset+no-self; groupIds need manage_groups"
  },
  {
   "id": "DELETE /user-management/users/:id",
   "group": "users",
   "method": "DELETE",
   "path": "/user-management/users/:id",
   "purpose": "Delete a user and their personal records.",
   "auth": {
    "text": "Session + manage_users",
    "kind": "session",
    "permissions": [
     "manage_users"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { deleted:true, erasure } | 200 { anonymized:true, message, crossServiceErasure, erasure } when other records still reference the user, so the personal fields are overwritten and the row is kept | 404 not found | 403 system administrator account cannot be deleted | 409 the user is still active (offboard first) | 409 code SUPPORT_ACCOUNT_MANAGED for the Zonitix support account",
   "responses": [
    {
     "status": 200,
     "text": "{ deleted:true, erasure }"
    },
    {
     "status": 200,
     "text": "{ anonymized:true, message, crossServiceErasure, erasure } when other records still reference the user, so the personal fields are overwritten and the row is kept"
    },
    {
     "status": 404,
     "text": "not found"
    },
    {
     "status": 403,
     "text": "system administrator account cannot be deleted"
    },
    {
     "status": 409,
     "text": "the user is still active (offboard first)"
    },
    {
     "status": 409,
     "text": "code SUPPORT_ACCOUNT_MANAGED for the Zonitix support account"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "not found"
    },
    {
     "status": 403,
     "text": "system administrator account cannot be deleted"
    },
    {
     "status": 409,
     "text": "the user is still active (offboard first)"
    },
    {
     "status": 409,
     "text": "code SUPPORT_ACCOUNT_MANAGED for the Zonitix support account"
    }
   ],
   "guard": "an account whose status is active is refused; offboard it first, then delete"
  },
  {
   "id": "POST /user-management/users/:id/offboard",
   "group": "users",
   "method": "POST",
   "path": "/user-management/users/:id/offboard",
   "purpose": "Start the offboarding flow (deprovision + dated reclaim tasks).",
   "auth": {
    "text": "Session + manage_users",
    "kind": "session",
    "permissions": [
     "manage_users"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "A UserOffboard: { lastDay?: string, immediate?: boolean, reason?: string }, all optional. immediate:true runs the run's revoke steps at once instead of leaving them queued, and then needs a reason of at least 5 characters.",
   "response": "201 task | 202 PARTIAL (access revoked but no deprovisioning run created; or, with immediate:true, the forced revocation left something outstanding) | 400 { error:'An immediate termination needs a reason on the record.' } | 403 cannot offboard your own account",
   "responses": [
    {
     "status": 201,
     "text": "task"
    },
    {
     "status": 202,
     "text": "PARTIAL (access revoked but no deprovisioning run created; or, with immediate:true, the forced revocation left something outstanding)"
    },
    {
     "status": 400,
     "text": "{ error:'An immediate termination needs a reason on the record.' }"
    },
    {
     "status": 403,
     "text": "cannot offboard your own account"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ error:'An immediate termination needs a reason on the record.' }"
    },
    {
     "status": 403,
     "text": "cannot offboard your own account"
    }
   ],
   "guard": "cannot offboard yourself; audited as USER_OFFBOARDING_STARTED, or USER_TERMINATED_IMMEDIATELY with immediate:true; primary-admin refused inside triggerOffboarding; 202 signals best-effort run creation failed"
  },
  {
   "id": "GET /user-management/users/org-tree",
   "group": "users",
   "method": "GET",
   "path": "/user-management/users/org-tree",
   "purpose": "Org chart hierarchy.",
   "auth": {
    "text": "Session + view_org_chart | view_dashboard",
    "kind": "session",
    "permissions": [
     "view_dashboard",
     "view_org_chart"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query: ?format=csv returns the directory as a CSV download instead of JSON",
   "response": "200 [ user ] — every active user plus any non-active manager needed to complete a reporting line, id ascending. Fields: id, firstName, lastName, position, department, managerId, photoUrl, source, lastActiveAt, createdAt, attributes; email, employeeId and phoneNumber only for a caller with view_users or manage_users (others get attributes reduced to dottedManagerIds). CSV columns: Name, Email, Title, Department, Manager | 403 ?format=csv without one of those permissions",
   "responses": [
    {
     "status": 200,
     "text": "[ user ] — every active user plus any non-active manager needed to complete a reporting line, id ascending. Fields: id, firstName, lastName, position, department, managerId, photoUrl, source, lastActiveAt, createdAt, attributes; email, employeeId and phoneNumber only for a caller with view_users or manage_users (others get attributes reduced to dottedManagerIds). CSV columns: Name, Email, Title, Department, Manager"
    },
    {
     "status": 403,
     "text": "?format=csv without one of those permissions"
    }
   ],
   "errors": [
    {
     "status": 403,
     "text": "?format=csv without one of those permissions"
    }
   ],
   "guard": "bare array, no paging; a CSV export is audit-logged"
  },
  {
   "id": "GET /user-management/users/org-rollups",
   "group": "users",
   "method": "GET",
   "path": "/user-management/users/org-rollups",
   "purpose": "Rolled-up headcount/health metrics per branch of the org chart.",
   "auth": {
    "text": "Session + view_org_chart | view_dashboard",
    "kind": "session",
    "permissions": [
     "view_dashboard",
     "view_org_chart"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { totalHeadcount, departmentCount, departments:[{ department, headcount, managers, ics }] } — active users only, largest department first; a user with no department is counted under 'Unassigned'",
   "responses": [
    {
     "status": 200,
     "text": "{ totalHeadcount, departmentCount, departments:[{ department, headcount, managers, ics }] } — active users only, largest department first; a user with no department is counted under 'Unassigned'"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /user-management/users/org-vacancies",
   "group": "users",
   "method": "GET",
   "path": "/user-management/users/org-vacancies",
   "purpose": "Open positions surfaced on the org chart.",
   "auth": {
    "text": "Session + view_org_chart | view_dashboard",
    "kind": "session",
    "permissions": [
     "view_dashboard",
     "view_org_chart"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { total, byManager:{ [hiringManagerId]: count }, byDepartment:{ [department]: count } } — counts job postings whose status is Open or Published",
   "responses": [
    {
     "status": 200,
     "text": "{ total, byManager:{ [hiringManagerId]: count }, byDepartment:{ [department]: count } } — counts job postings whose status is Open or Published"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "PUT /user-management/users/:id/dotted-reports",
   "group": "users",
   "method": "PUT",
   "path": "/user-management/users/:id/dotted-reports",
   "purpose": "Set matrix (dotted-line) reporting relationships.",
   "auth": {
    "text": "Session + manage_org_chart",
    "kind": "session",
    "permissions": [
     "manage_org_chart"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "A DottedReportsUpdate: { managerIds: number[] } REPLACES the set — deduped, self removed, max 10, each must be an active user",
   "response": "200 { id, dottedManagerIds } | 404 user not found | 400 >10 managers / one or more not valid active users",
   "responses": [
    {
     "status": 200,
     "text": "{ id, dottedManagerIds }"
    },
    {
     "status": 404,
     "text": "user not found"
    },
    {
     "status": 400,
     "text": ">10 managers / one or more not valid active users"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "user not found"
    },
    {
     "status": 400,
     "text": ">10 managers / one or more not valid active users"
    }
   ],
   "guard": "stored in user.attributes.dottedManagerIds; route gated manage_org_chart; safe to repeat (the set is replaced, not appended)"
  },
  {
   "id": "GET /user-management/groups",
   "group": "users",
   "method": "GET",
   "path": "/user-management/groups",
   "purpose": "List groups.",
   "auth": {
    "text": "Session + view_groups | manage_groups | view_dashboard",
    "kind": "session",
    "permissions": [
     "manage_groups",
     "view_dashboard",
     "view_groups"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 [ Group ] (typed under Response Objects) — each with Users:[{ id:integer }]",
   "responses": [
    {
     "status": 200,
     "text": "[ Group ] (typed under Response Objects) — each with Users:[{ id:integer }]"
    }
   ],
   "errors": [],
   "guard": "bare array, no paging or sort parameter"
  },
  {
   "id": "POST /user-management/groups",
   "group": "users",
   "method": "POST",
   "path": "/user-management/groups",
   "purpose": "Create a group.",
   "auth": {
    "text": "Session + manage_groups",
    "kind": "session",
    "permissions": [
     "manage_groups"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "A GroupWrite: name, description, userIds[], mfaPolicy (inherit | require | exempt), mfaExemptUntil, mfaExemptReason. The older boolean requiresMfa is still read when mfaPolicy is not sent. The MFA keys are honored only for a manage_security caller",
   "response": "201 created Group | 409 a group with that name already exists",
   "responses": [
    {
     "status": 201,
     "text": "created Group"
    },
    {
     "status": 409,
     "text": "a group with that name already exists"
    }
   ],
   "errors": [
    {
     "status": 409,
     "text": "a group with that name already exists"
    }
   ],
   "guard": "the MFA keys (MFA-enforcement control) only applied for an elevated manage_security caller; ignored otherwise; group names are unique, so a repeated create is refused with 409"
  },
  {
   "id": "PUT /user-management/groups/:id",
   "group": "users",
   "method": "PUT",
   "path": "/user-management/groups/:id",
   "purpose": "Update a group.",
   "auth": {
    "text": "Session + manage_groups",
    "kind": "session",
    "permissions": [
     "manage_groups"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "A GroupWrite: name, description, userIds[], mfaPolicy (inherit | require | exempt), mfaExemptUntil, mfaExemptReason. The older boolean requiresMfa is still read when mfaPolicy is not sent. The MFA keys are honored only for a manage_security caller",
   "response": "200 Group | 404 not found",
   "responses": [
    {
     "status": 200,
     "text": "Group"
    },
    {
     "status": 404,
     "text": "not found"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "not found"
    }
   ],
   "guard": "the MFA keys writable only by manage_security caller (delegated manage_groups cannot downgrade MFA)"
  },
  {
   "id": "DELETE /user-management/groups/:id",
   "group": "users",
   "method": "DELETE",
   "path": "/user-management/groups/:id",
   "purpose": "Delete a group.",
   "auth": {
    "text": "Session + manage_groups",
    "kind": "session",
    "permissions": [
     "manage_groups"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "204 no content | 404 not found",
   "responses": [
    {
     "status": 204,
     "text": "no content"
    },
    {
     "status": 404,
     "text": "not found"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "not found"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /user-management/roles",
   "group": "users",
   "method": "GET",
   "path": "/user-management/roles",
   "purpose": "List roles.",
   "auth": {
    "text": "Session + view_roles | manage_roles | view_dashboard",
    "kind": "session",
    "permissions": [
     "manage_roles",
     "view_dashboard",
     "view_roles"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 [ Role ] (typed under Response Objects) — each with Permissions[], Users:[{ id:integer }] and Groups:[{ id:integer, name:string }]",
   "responses": [
    {
     "status": 200,
     "text": "[ Role ] (typed under Response Objects) — each with Permissions[], Users:[{ id:integer }] and Groups:[{ id:integer, name:string }]"
    }
   ],
   "errors": [],
   "guard": "bare array, no paging or sort parameter"
  },
  {
   "id": "POST /user-management/roles",
   "group": "users",
   "method": "POST",
   "path": "/user-management/roles",
   "purpose": "Create a role.",
   "auth": {
    "text": "Session + manage_roles",
    "kind": "session",
    "permissions": [
     "manage_roles"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "A RoleWrite: name, description, userIds[], permissionIds[], groupIds[]",
   "response": "201 created Role | 403 only administrator can create 'administrator' role / privilege escalation (perms you do not hold) | 409 a role with that name already exists",
   "responses": [
    {
     "status": 201,
     "text": "created Role"
    },
    {
     "status": 403,
     "text": "only administrator can create 'administrator' role / privilege escalation (perms you do not hold)"
    },
    {
     "status": 409,
     "text": "a role with that name already exists"
    }
   ],
   "errors": [
    {
     "status": 403,
     "text": "only administrator can create 'administrator' role / privilege escalation (perms you do not hold)"
    },
    {
     "status": 409,
     "text": "a role with that name already exists"
    }
   ],
   "guard": "subset invariant on permissionIds; name==='administrator' admin-only and auto-gets ALL permissions; role names are unique, so a repeated create is refused with 409"
  },
  {
   "id": "PUT /user-management/roles/:id",
   "group": "users",
   "method": "PUT",
   "path": "/user-management/roles/:id",
   "purpose": "Update a role.",
   "auth": {
    "text": "Session + manage_roles",
    "kind": "session",
    "permissions": [
     "manage_roles"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "A RoleWrite: name, description, userIds[], permissionIds[], groupIds[]",
   "response": "200 Role | 404 not found | 403 rename crossing 'administrator' / grant perms you lack / assign members to over-privileged role / admin-role membership needs administrator",
   "responses": [
    {
     "status": 200,
     "text": "Role"
    },
    {
     "status": 404,
     "text": "not found"
    },
    {
     "status": 403,
     "text": "rename crossing 'administrator' / grant perms you lack / assign members to over-privileged role / admin-role membership needs administrator"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "not found"
    },
    {
     "status": 403,
     "text": "rename crossing 'administrator' / grant perms you lack / assign members to over-privileged role / admin-role membership needs administrator"
    }
   ],
   "guard": "name pinned on entry to block rename-bypass; permissionIds subset invariant; administrator role always reset to ALL perms"
  },
  {
   "id": "DELETE /user-management/roles/:id",
   "group": "users",
   "method": "DELETE",
   "path": "/user-management/roles/:id",
   "purpose": "Delete a role.",
   "auth": {
    "text": "Session + manage_roles",
    "kind": "session",
    "permissions": [
     "manage_roles"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "204 no content | 404 not found | 403 the primary administrator role cannot be deleted",
   "responses": [
    {
     "status": 204,
     "text": "no content"
    },
    {
     "status": 404,
     "text": "not found"
    },
    {
     "status": 403,
     "text": "the primary administrator role cannot be deleted"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "not found"
    },
    {
     "status": 403,
     "text": "the primary administrator role cannot be deleted"
    }
   ],
   "guard": null
  },
  {
   "id": "POST /user-management/roles/:roleId/permissions",
   "group": "users",
   "method": "POST",
   "path": "/user-management/roles/:roleId/permissions",
   "purpose": "Replace a role's permission set.",
   "auth": {
    "text": "Session + manage_roles",
    "kind": "session",
    "permissions": [
     "manage_roles"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "roleId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "A RolePermissionsReplace: { permissionIds: number[] } REPLACES the role's permission set",
   "response": "200 { ok:true } | 404 role not found | 403 privilege escalation (cannot grant perms you do not hold)",
   "responses": [
    {
     "status": 200,
     "text": "{ ok:true }"
    },
    {
     "status": 404,
     "text": "role not found"
    },
    {
     "status": 403,
     "text": "privilege escalation (cannot grant perms you do not hold)"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "role not found"
    },
    {
     "status": 403,
     "text": "privilege escalation (cannot grant perms you do not hold)"
    }
   ],
   "guard": "subset invariant via permissionsCallerMayNotGrant; administrator role always forced to ALL perms; safe to repeat (replace semantics)"
  },
  {
   "id": "GET /user-management/permissions",
   "group": "users",
   "method": "GET",
   "path": "/user-management/permissions",
   "purpose": "List every atomic permission.",
   "auth": {
    "text": "Session + manage_permissions | manage_roles",
    "kind": "session",
    "permissions": [
     "manage_permissions",
     "manage_roles"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 [ Permission ] (typed under Response Objects) — each with Roles[] and Users:[{ id:integer }]",
   "responses": [
    {
     "status": 200,
     "text": "[ Permission ] (typed under Response Objects) — each with Roles[] and Users:[{ id:integer }]"
    }
   ],
   "errors": [],
   "guard": "bare array, no paging"
  },
  {
   "id": "POST /user-management/permissions/:id/assignments",
   "group": "users",
   "method": "POST",
   "path": "/user-management/permissions/:id/assignments",
   "purpose": "Set which roles/users hold a given permission.",
   "auth": {
    "text": "Session + manage_permissions",
    "kind": "session",
    "permissions": [
     "manage_permissions"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "{ roleIds?: number[], userIds?: number[] } — each REPLACES that side of the join for this permission",
   "response": "200 { ok:true } | 404 permission not found | 403 you cannot assign a permission you do not hold",
   "responses": [
    {
     "status": 200,
     "text": "{ ok:true }"
    },
    {
     "status": 404,
     "text": "permission not found"
    },
    {
     "status": 403,
     "text": "you cannot assign a permission you do not hold"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "permission not found"
    },
    {
     "status": 403,
     "text": "you cannot assign a permission you do not hold"
    }
   ],
   "guard": "caller must already hold the permission (subset); administrator role is always re-added to it"
  },
  {
   "id": "POST /user-management/permissions/compliance-access/:userId",
   "group": "users",
   "method": "POST",
   "path": "/user-management/permissions/compliance-access/:userId",
   "purpose": "Grant a named user read access to compliance evidence (e.g. an external auditor you have onboarded as a user).",
   "auth": {
    "text": "Session + manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "userId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body. Grants the view_assigned_compliance permission to :userId.",
   "response": "200 { ok:true } | 404 user not found | 400 view_assigned_compliance permission not seeded",
   "responses": [
    {
     "status": 200,
     "text": "{ ok:true }"
    },
    {
     "status": 404,
     "text": "user not found"
    },
    {
     "status": 400,
     "text": "view_assigned_compliance permission not seeded"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "user not found"
    },
    {
     "status": 400,
     "text": "view_assigned_compliance permission not seeded"
    }
   ],
   "guard": "direct user->permission grant (addPermission), scoped to the one user"
  },
  {
   "id": "DELETE /user-management/permissions/compliance-access/:userId",
   "group": "users",
   "method": "DELETE",
   "path": "/user-management/permissions/compliance-access/:userId",
   "purpose": "Revoke that compliance access.",
   "auth": {
    "text": "Session + manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "userId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body. Removes view_assigned_compliance from :userId.",
   "response": "200 { ok:true } | 404 user not found",
   "responses": [
    {
     "status": 200,
     "text": "{ ok:true }"
    },
    {
     "status": 404,
     "text": "user not found"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "user not found"
    }
   ],
   "guard": "no-op if the permission is not seeded (removePermission only when found)"
  },
  {
   "id": "GET /user-management/personas",
   "group": "users",
   "method": "GET",
   "path": "/user-management/personas",
   "purpose": "List onboarding personas.",
   "auth": {
    "text": "Session + view_users | manage_users | manage_personas",
    "kind": "session",
    "permissions": [
     "manage_personas",
     "manage_users",
     "view_users"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 [ UserPersona ], ordered by name",
   "responses": [
    {
     "status": 200,
     "text": "[ UserPersona ], ordered by name"
    }
   ],
   "errors": [],
   "guard": "bare array, no paging"
  },
  {
   "id": "POST /user-management/personas",
   "group": "users",
   "method": "POST",
   "path": "/user-management/personas",
   "purpose": "Create a persona.",
   "auth": {
    "text": "Session + manage_users | manage_personas",
    "kind": "session",
    "permissions": [
     "manage_personas",
     "manage_users"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "name (required, unique), description, hardwareTemplate{}, licenseTemplate[], accessRequirements[], hardwareProvisioning[], providerMapping{}",
   "response": "201 created UserPersona (estimatedOneTimeCost/estimatedMonthlyCost computed server-side) | 409 a persona with that name already exists",
   "responses": [
    {
     "status": 201,
     "text": "created UserPersona (estimatedOneTimeCost/estimatedMonthlyCost computed server-side)"
    },
    {
     "status": 409,
     "text": "a persona with that name already exists"
    }
   ],
   "errors": [
    {
     "status": 409,
     "text": "a persona with that name already exists"
    }
   ],
   "guard": "service spreads req.body then recomputes cost totals from hardwareTemplate/licenseTemplate; a repeated create with the same name is refused with 409"
  },
  {
   "id": "PUT /user-management/personas/:id",
   "group": "users",
   "method": "PUT",
   "path": "/user-management/personas/:id",
   "purpose": "Update a persona.",
   "auth": {
    "text": "Session + manage_users | manage_personas",
    "kind": "session",
    "permissions": [
     "manage_personas",
     "manage_users"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "same fields as create: name, description, hardwareTemplate{}, licenseTemplate[], accessRequirements[], hardwareProvisioning[], providerMapping{}",
   "response": "200 updated UserPersona (costs recomputed)",
   "responses": [
    {
     "status": 200,
     "text": "updated UserPersona (costs recomputed)"
    }
   ],
   "errors": [],
   "guard": "service spreads req.body over the row (no explicit write allowlist); costs recomputed server-side"
  },
  {
   "id": "DELETE /user-management/personas/:id",
   "group": "users",
   "method": "DELETE",
   "path": "/user-management/personas/:id",
   "purpose": "Delete a persona.",
   "auth": {
    "text": "Session + manage_users | manage_personas",
    "kind": "session",
    "permissions": [
     "manage_personas",
     "manage_users"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "204 no content",
   "responses": [
    {
     "status": 204,
     "text": "no content"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /user-management/personas/:id/provisioning-plan",
   "group": "users",
   "method": "POST",
   "path": "/user-management/personas/:id/provisioning-plan",
   "purpose": "Generate the provisioning plan a persona would produce.",
   "auth": {
    "text": "Session + view_users | manage_users | manage_personas | manage_workflows | manage_approvals",
    "kind": "session",
    "permissions": [
     "manage_approvals",
     "manage_personas",
     "manage_users",
     "manage_workflows",
     "view_users"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "{ startDate?: string, workflowId? } both optional (each also accepted as a query parameter). With workflowId, items that workflow leaves out for this persona are marked excluded. Read-only — no persistence.",
   "response": "200 { persona:{ id, name }, cost:{ oneTime, monthly }, workflow:{ id, name } | null, startDate, items[], summary } | 404 persona not found",
   "responses": [
    {
     "status": 200,
     "text": "{ persona:{ id, name }, cost:{ oneTime, monthly }, workflow:{ id, name } | null, startDate, items[], summary }"
    },
    {
     "status": 404,
     "text": "persona not found"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "persona not found"
    }
   ],
   "guard": "classifies auto vs dated IT tasks against active ExternalProviders; writes nothing"
  },
  {
   "id": "POST /user-management/personas/preview/provisioning-plan",
   "group": "users",
   "method": "POST",
   "path": "/user-management/personas/preview/provisioning-plan",
   "purpose": "Preview a provisioning plan for an unsaved persona definition.",
   "auth": {
    "text": "Session + view_users | manage_users | manage_personas | manage_workflows",
    "kind": "session",
    "permissions": [
     "manage_personas",
     "manage_users",
     "manage_workflows",
     "view_users"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ persona: {...unsaved persona def}, startDate?: string }. Read-only — no persistence.",
   "response": "200 { startDate, items[], summary }",
   "responses": [
    {
     "status": 200,
     "text": "{ startDate, items[], summary }"
    }
   ],
   "errors": [],
   "guard": "live-preview for an unsaved persona; writes nothing"
  },
  {
   "id": "GET /user-management/org-snapshots",
   "group": "users",
   "method": "GET",
   "path": "/user-management/org-snapshots",
   "purpose": "List saved org-chart snapshots.",
   "auth": {
    "text": "Session + view_org_chart",
    "kind": "session",
    "permissions": [
     "view_org_chart"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { snapshots:[{ id, name, snapshotDate, createdBy, metrics, createdAt }] (newest snapshotDate first; the captured tree is not included), metadata:{ nextScheduledSnapshot } }",
   "responses": [
    {
     "status": 200,
     "text": "{ snapshots:[{ id, name, snapshotDate, createdBy, metrics, createdAt }] (newest snapshotDate first; the captured tree is not included), metadata:{ nextScheduledSnapshot } }"
    }
   ],
   "errors": [],
   "guard": "returns every snapshot (no paging)"
  },
  {
   "id": "POST /user-management/org-snapshots",
   "group": "users",
   "method": "POST",
   "path": "/user-management/org-snapshots",
   "purpose": "Take an org-chart snapshot.",
   "auth": {
    "text": "Session + manage_org_chart",
    "kind": "session",
    "permissions": [
     "manage_org_chart"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "An OrgSnapshotCreate: { name: string } required",
   "response": "201 created OrgSnapshot | 400 Snapshot name is required",
   "responses": [
    {
     "status": 201,
     "text": "created OrgSnapshot"
    },
    {
     "status": 400,
     "text": "Snapshot name is required"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "Snapshot name is required"
    }
   ],
   "guard": "createdBy derived from x-user header (username/email), not the body; not deduplicated: there is no uniqueness check on the name, so repeating the call stores a second snapshot"
  },
  {
   "id": "GET /user-management/org-snapshots/diff",
   "group": "users",
   "method": "GET",
   "path": "/user-management/org-snapshots/diff",
   "purpose": "Diff two snapshots to show what changed.",
   "auth": {
    "text": "Session + view_org_chart",
    "kind": "session",
    "permissions": [
     "view_org_chart"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query: ?from= and ?to= (snapshot ids), both required",
   "response": "200 { from:{ id, name, date }, to:{ id, name, date }, summary:{ joined, left, moved }, joined:[{ id, name, department, position }], left:[same], moved:[{ id, name, changes:{ managerId?, department?, position? } }] } — each change is { from, to }; email is added to joined/left only for a caller allowed to see personal data | 400 'from and to snapshot ids are required' | 404 'Snapshot not found'",
   "responses": [
    {
     "status": 200,
     "text": "{ from:{ id, name, date }, to:{ id, name, date }, summary:{ joined, left, moved }, joined:[{ id, name, department, position }], left:[same], moved:[{ id, name, changes:{ managerId?, department?, position? } }] } — each change is { from, to }; email is added to joined/left only for a caller allowed to see personal data"
    },
    {
     "status": 400,
     "text": "'from and to snapshot ids are required'"
    },
    {
     "status": 404,
     "text": "'Snapshot not found'"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "'from and to snapshot ids are required'"
    },
    {
     "status": 404,
     "text": "'Snapshot not found'"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /user-management/org-snapshots/:id",
   "group": "users",
   "method": "GET",
   "path": "/user-management/org-snapshots/:id",
   "purpose": "Fetch one snapshot.",
   "auth": {
    "text": "Session + view_org_chart",
    "kind": "session",
    "permissions": [
     "view_org_chart"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 the snapshot row including data[] (the captured org nodes, redacted for a caller not allowed to see personal data) | 404 'Snapshot not found'",
   "responses": [
    {
     "status": 200,
     "text": "the snapshot row including data[] (the captured org nodes, redacted for a caller not allowed to see personal data)"
    },
    {
     "status": 404,
     "text": "'Snapshot not found'"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'Snapshot not found'"
    }
   ],
   "guard": null
  },
  {
   "id": "DELETE /user-management/org-snapshots/:id",
   "group": "users",
   "method": "DELETE",
   "path": "/user-management/org-snapshots/:id",
   "purpose": "Delete a snapshot.",
   "auth": {
    "text": "Session + manage_org_chart",
    "kind": "session",
    "permissions": [
     "manage_org_chart"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "204 no content (destroy is idempotent — no 404 if id absent)",
   "responses": [
    {
     "status": 204,
     "text": "no content (destroy is idempotent — no 404 if id absent)"
    }
   ],
   "errors": [],
   "guard": "safe to repeat: a second call still answers 204"
  },
  {
   "id": "GET /user-management/license",
   "group": "users",
   "method": "GET",
   "path": "/user-management/license",
   "purpose": "Current license status, tier, and entitlements as last synced.",
   "auth": {
    "text": "Session + manage_security | view_dashboard | view_users",
    "kind": "session",
    "permissions": [
     "manage_security",
     "view_dashboard",
     "view_users"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { status:'ACTIVE'|'PENDING_APPROVAL'|'PROVISIONING_FAILED'|'UNPROVISIONED', licenseType, features:{ [flag]: boolean }, companyName, serviceId, hasProvisioningKey, lastHeartbeat, nextHeartbeat, heartbeatStatus, heartbeatHealth:{ state:'OK'|'WARNING'|'GRACE_EXPIRED', consecutiveFailures, firstFailureAt, graceExpiresAt, graceHoursRemaining, featuresDisabled:false, policy }, subscription:{ enforced, state:'active'|'grace'|'lapsed'|'unknown', paidThroughAt, graceEndsAt, storeContactSinceExpiry, premiumPaused }, host:{ cpuCount, ramGb }, … }",
   "responses": [
    {
     "status": 200,
     "text": "{ status:'ACTIVE'|'PENDING_APPROVAL'|'PROVISIONING_FAILED'|'UNPROVISIONED', licenseType, features:{ [flag]: boolean }, companyName, serviceId, hasProvisioningKey, lastHeartbeat, nextHeartbeat, heartbeatStatus, heartbeatHealth:{ state:'OK'|'WARNING'|'GRACE_EXPIRED', consecutiveFailures, firstFailureAt, graceExpiresAt, graceHoursRemaining, featuresDisabled:false, policy }, subscription:{ enforced, state:'active'|'grace'|'lapsed'|'unknown', paidThroughAt, graceEndsAt, storeContactSinceExpiry, premiumPaused }, host:{ cpuCount, ramGb }, … }"
    }
   ],
   "errors": [],
   "guard": "the provisioning key itself is never returned, only hasProvisioningKey"
  },
  {
   "id": "POST /user-management/license/provision",
   "group": "users",
   "method": "POST",
   "path": "/user-management/license/provision",
   "purpose": "Retired. Always answers 410; activation is automatic and store-managed.",
   "auth": {
    "text": "Session + manage_security",
    "kind": "session",
    "permissions": [
     "manage_security"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Ignored.",
   "response": "410 { error:'Manual provisioning is disabled. This instance is activated automatically by the store.' }",
   "responses": [
    {
     "status": 410,
     "text": "{ error:'Manual provisioning is disabled. This instance is activated automatically by the store.' }"
    }
   ],
   "errors": [
    {
     "status": 410,
     "text": "{ error:'Manual provisioning is disabled. This instance is activated automatically by the store.' }"
    }
   ],
   "guard": "every call is audited as LICENSE_PROVISION_REJECTED"
  },
  {
   "id": "POST /user-management/license/sync",
   "group": "users",
   "method": "POST",
   "path": "/user-management/license/sync",
   "purpose": "Force an immediate license heartbeat instead of waiting for the 5-minute beat.",
   "auth": {
    "text": "Session + manage_security",
    "kind": "session",
    "permissions": [
     "manage_security"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { message:'Sync completed', config } | 200 { message:'Sync did not complete', config:null } when the instance is not activated or the store did not answer or refused",
   "responses": [
    {
     "status": 200,
     "text": "{ message:'Sync completed', config }"
    },
    {
     "status": 200,
     "text": "{ message:'Sync did not complete', config:null } when the instance is not activated or the store did not answer or refused"
    }
   ],
   "errors": [],
   "guard": "a completed sync is audited as LICENSE_SYNCED; one that did not complete as LICENSE_SYNC_NOT_COMPLETED (warning), and entitlements are left unchanged"
  },
  {
   "id": "POST /user-management/license/reset",
   "group": "users",
   "method": "POST",
   "path": "/user-management/license/reset",
   "purpose": "Clear all license and provisioning state on the instance.",
   "auth": {
    "text": "Session + administrator role",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": true,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { message:'License reset successfully' } | 403 'Only an administrator can reset the licence.'",
   "responses": [
    {
     "status": 200,
     "text": "{ message:'License reset successfully' }"
    },
    {
     "status": 403,
     "text": "'Only an administrator can reset the licence.'"
    }
   ],
   "errors": [
    {
     "status": 403,
     "text": "'Only an administrator can reset the licence.'"
    }
   ],
   "guard": "the administrator role itself is needed: holding the security-management permission without that role is refused with 403; audited as LICENSE_RESET; a refused call as LICENSE_RESET_DENIED; every plan-gated module is switched off"
  },
  {
   "id": "GET /user-management/saas-pricing/plans",
   "group": "users",
   "method": "GET",
   "path": "/user-management/saas-pricing/plans",
   "purpose": "Vendor pricing catalog entries.",
   "auth": {
    "text": "Session + view_users | manage_users | manage_security",
    "kind": "session",
    "permissions": [
     "manage_security",
     "manage_users",
     "view_users"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 [ SaaSPlan ], ordered by vendor then price",
   "responses": [
    {
     "status": 200,
     "text": "[ SaaSPlan ], ordered by vendor then price"
    }
   ],
   "errors": [],
   "guard": "bare array, no paging"
  },
  {
   "id": "GET /user-management/saas-pricing/schedule",
   "group": "users",
   "method": "GET",
   "path": "/user-management/saas-pricing/schedule",
   "purpose": "Read the pricing-catalog sync schedule.",
   "auth": {
    "text": "Session + manage_security",
    "kind": "session",
    "permissions": [
     "manage_security"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 the stored schedule object ({ frequency, cron } as saved by the PUT) | 404 'SaaS sync settings not configured'",
   "responses": [
    {
     "status": 200,
     "text": "the stored schedule object ({ frequency, cron } as saved by the PUT)"
    },
    {
     "status": 404,
     "text": "'SaaS sync settings not configured'"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'SaaS sync settings not configured'"
    }
   ],
   "guard": null
  },
  {
   "id": "PUT /user-management/saas-pricing/schedule",
   "group": "users",
   "method": "PUT",
   "path": "/user-management/saas-pricing/schedule",
   "purpose": "Update the pricing-catalog sync schedule (cron).",
   "auth": {
    "text": "Session + manage_security",
    "kind": "session",
    "permissions": [
     "manage_security"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ frequency: string, cron: string } both required",
   "response": "200 { message, schedule } | 400 Frequency and Cron expression are required | 404 SaaS sync settings not found",
   "responses": [
    {
     "status": 200,
     "text": "{ message, schedule }"
    },
    {
     "status": 400,
     "text": "Frequency and Cron expression are required"
    },
    {
     "status": 404,
     "text": "SaaS sync settings not found"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "Frequency and Cron expression are required"
    },
    {
     "status": 404,
     "text": "SaaS sync settings not found"
    }
   ],
   "guard": null
  },
  {
   "id": "POST /user-management/saas-pricing/sync",
   "group": "users",
   "method": "POST",
   "path": "/user-management/saas-pricing/sync",
   "purpose": "Run a pricing-catalog sync now.",
   "auth": {
    "text": "Session + manage_security",
    "kind": "session",
    "permissions": [
     "manage_security"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { message, details }. While no vendor price feed is connected, details is { success:false, synced:false, reason:'no_vendor_feed', checked, count:0, message } and no price is changed, although the top-level message still reads 'SaaS vendor prices synchronized successfully'. Read details.synced.",
   "responses": [
    {
     "status": 200,
     "text": "{ message, details }. While no vendor price feed is connected, details is { success:false, synced:false, reason:'no_vendor_feed', checked, count:0, message } and no price is changed, although the top-level message still reads 'SaaS vendor prices synchronized successfully'. Read details.synced."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /user-management/device-catalog/models",
   "group": "users",
   "method": "GET",
   "path": "/user-management/device-catalog/models",
   "purpose": "Available hardware models for onboarding selection.",
   "auth": {
    "text": "Session + view_users | manage_users | manage_security",
    "kind": "session",
    "permissions": [
     "manage_security",
     "manage_users",
     "view_users"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 [ DeviceModel ], ordered by brand then modelName",
   "responses": [
    {
     "status": 200,
     "text": "[ DeviceModel ], ordered by brand then modelName"
    }
   ],
   "errors": [],
   "guard": "bare array, no paging"
  },
  {
   "id": "GET /user-management/device-catalog/schedule",
   "group": "users",
   "method": "GET",
   "path": "/user-management/device-catalog/schedule",
   "purpose": "Read the device-catalog sync schedule.",
   "auth": {
    "text": "Session + manage_security",
    "kind": "session",
    "permissions": [
     "manage_security"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 the stored schedule object ({ frequency, cron } as saved by the PUT) | 404 'Device catalog sync settings not configured'",
   "responses": [
    {
     "status": 200,
     "text": "the stored schedule object ({ frequency, cron } as saved by the PUT)"
    },
    {
     "status": 404,
     "text": "'Device catalog sync settings not configured'"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'Device catalog sync settings not configured'"
    }
   ],
   "guard": null
  },
  {
   "id": "PUT /user-management/device-catalog/schedule",
   "group": "users",
   "method": "PUT",
   "path": "/user-management/device-catalog/schedule",
   "purpose": "Update the device-catalog sync schedule (cron).",
   "auth": {
    "text": "Session + manage_security",
    "kind": "session",
    "permissions": [
     "manage_security"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ frequency: string, cron: string } both required",
   "response": "200 { message, schedule } | 400 required fields / Invalid cron expression | 404 Device catalog sync settings not found",
   "responses": [
    {
     "status": 200,
     "text": "{ message, schedule }"
    },
    {
     "status": 400,
     "text": "required fields / Invalid cron expression"
    },
    {
     "status": 404,
     "text": "Device catalog sync settings not found"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "required fields / Invalid cron expression"
    },
    {
     "status": 404,
     "text": "Device catalog sync settings not found"
    }
   ],
   "guard": "validate-then-swap: rejects invalid cron BEFORE persisting, then re-inits scheduler"
  },
  {
   "id": "POST /user-management/device-catalog/sync",
   "group": "users",
   "method": "POST",
   "path": "/user-management/device-catalog/sync",
   "purpose": "Run a device-catalog sync now.",
   "auth": {
    "text": "Session + manage_security",
    "kind": "session",
    "permissions": [
     "manage_security"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "409 { error, synced:false, reason:'no_feed', details } while no hardware catalog feed is connected (the current state; models and costs are left unchanged) | 200 { synced:true, message:'Device catalog synchronized successfully', details } only when a feed actually synced",
   "responses": [
    {
     "status": 409,
     "text": "{ error, synced:false, reason:'no_feed', details } while no hardware catalog feed is connected (the current state; models and costs are left unchanged)"
    },
    {
     "status": 200,
     "text": "{ synced:true, message:'Device catalog synchronized successfully', details } only when a feed actually synced"
    }
   ],
   "errors": [
    {
     "status": 409,
     "text": "{ error, synced:false, reason:'no_feed', details } while no hardware catalog feed is connected (the current state; models and costs are left unchanged)"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /user-management/notifications",
   "group": "users",
   "method": "GET",
   "path": "/user-management/notifications",
   "purpose": "Your own notification feed (the bell in the top bar). Query: ?unread=1 keeps unread entries only. No paging parameter: at most 50 entries. Unread entries first, newest first; then read entries in the order they were read, earliest first.",
   "auth": {
    "text": "Session + view_dashboard",
    "kind": "session",
    "permissions": [
     "view_dashboard"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query: ?unread=1 optional.",
   "response": "200 [ Notification ] (typed under Response Objects) — a bare array, no total",
   "responses": [
    {
     "status": 200,
     "text": "[ Notification ] (typed under Response Objects) — a bare array, no total"
    }
   ],
   "errors": [],
   "guard": "only the caller's own entries are ever returned; there is no parameter that reads another user's feed"
  },
  {
   "id": "POST /user-management/notifications/:id/read",
   "group": "users",
   "method": "POST",
   "path": "/user-management/notifications/:id/read",
   "purpose": "Mark one of your notifications as read. Safe to repeat: an entry that is already read is left as it is and the call answers updated: 0.",
   "auth": {
    "text": "Session + view_dashboard",
    "kind": "session",
    "permissions": [
     "view_dashboard"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { updated:integer } — 1 when this call marked it, 0 when it was already read or is not yours | 400 { error:'Invalid notification id.' } when :id is not a positive whole number",
   "responses": [
    {
     "status": 200,
     "text": "{ updated:integer } — 1 when this call marked it, 0 when it was already read or is not yours"
    },
    {
     "status": 400,
     "text": "{ error:'Invalid notification id.' } when :id is not a positive whole number"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ error:'Invalid notification id.' } when :id is not a positive whole number"
    }
   ],
   "guard": null
  },
  {
   "id": "POST /user-management/notifications/read-all",
   "group": "users",
   "method": "POST",
   "path": "/user-management/notifications/read-all",
   "purpose": "Mark every unread notification of yours as read. Safe to repeat: a second call answers updated: 0.",
   "auth": {
    "text": "Session + view_dashboard",
    "kind": "session",
    "permissions": [
     "view_dashboard"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { updated:integer } — how many entries this call marked",
   "responses": [
    {
     "status": 200,
     "text": "{ updated:integer } — how many entries this call marked"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /user-management/workflows",
   "group": "workflow",
   "method": "GET",
   "path": "/user-management/workflows",
   "purpose": "List workflow definitions and versions.",
   "auth": {
    "text": "Session + manage_workflows | approve_workflows | execute_workflows | manage_approvals",
    "kind": "session",
    "permissions": [
     "approve_workflows",
     "execute_workflows",
     "manage_approvals",
     "manage_workflows"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query: ?includeArchived=true also returns soft-deleted (archived) versions",
   "response": "200 [ Workflow ] (typed under Response Objects) — every version row, ordered by name then version descending, each with purgeable:boolean (true only when no version of that pipeline is default, approved, or has ever been run)",
   "responses": [
    {
     "status": 200,
     "text": "[ Workflow ] (typed under Response Objects) — every version row, ordered by name then version descending, each with purgeable:boolean (true only when no version of that pipeline is default, approved, or has ever been run)"
    }
   ],
   "errors": [],
   "guard": "bare array, no paging"
  },
  {
   "id": "POST /user-management/workflows",
   "group": "workflow",
   "method": "POST",
   "path": "/user-management/workflows",
   "purpose": "Create a workflow definition (a new version of a named pipeline).",
   "auth": {
    "text": "Session + manage_workflows",
    "kind": "session",
    "permissions": [
     "manage_workflows"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "A WorkflowWrite. Client-writable: name (optional, trimmed to 120 chars — omit it and the standard name 'Master Onboarding' / 'Master Offboarding' is used), type ('onboarding'|'offboarding'), description, nodes[], edges[], automationMode ('manual'|'provisioning'|'auto'; anything else is treated as 'manual'), personaOverrides (can only leave items OUT of a persona, never add), status ('draft'|'pending'). version/isCurrent/createdBy/approvedBy are server-owned; other status values are ignored (forced 'draft').",
   "response": "201 Workflow row. Versions are numbered per name, so a new name starts its own pipeline at v1; isCurrent true only if first of that name.",
   "responses": [
    {
     "status": 201,
     "text": "Workflow row. Versions are numbered per name, so a new name starts its own pipeline at v1; isCurrent true only if first of that name."
    }
   ],
   "errors": [],
   "guard": "mass-assignment guard: only the fields above are picked; status clamped to draft|pending (approved/rejected unreachable here)."
  },
  {
   "id": "PUT /user-management/workflows/:id",
   "group": "workflow",
   "method": "PUT",
   "path": "/user-management/workflows/:id",
   "purpose": "Update a draft version. Status is not writable here.",
   "auth": {
    "text": "Session + manage_workflows + sudo",
    "kind": "session",
    "permissions": [
     "manage_workflows"
    ],
    "stepUp": "required",
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "Client-writable only: type, description, nodes[], edges[], automationMode, personaOverrides, status ('draft'|'pending') — the WorkflowWrite keys without name. status not settable to approved/rejected here.",
   "response": "200 Workflow. If editing nodes/edges of an approved version, FORKS a new 'pending' version (fresh row) instead of mutating in place; else 200 updated row. 404 if not found.",
   "responses": [
    {
     "status": 200,
     "text": "Workflow. If editing nodes/edges of an approved version, FORKS a new 'pending' version (fresh row) instead of mutating in place; else 200 updated row. 404 if not found."
    }
   ],
   "errors": [],
   "guard": "manage_workflows + sudo. Same field whitelist as create; approved versions immutable in place (edits fork)."
  },
  {
   "id": "DELETE /user-management/workflows/:id",
   "group": "workflow",
   "method": "DELETE",
   "path": "/user-management/workflows/:id",
   "purpose": "Delete a workflow.",
   "auth": {
    "text": "Session + manage_workflows + sudo",
    "kind": "session",
    "permissions": [
     "manage_workflows"
    ],
    "stepUp": "required",
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "204 no content (soft-delete/paranoid: sets deletedAt, retained for audit). 400 if version isCurrent (promote another first). 404 if not found.",
   "responses": [
    {
     "status": 204,
     "text": "no content (soft-delete/paranoid: sets deletedAt, retained for audit). 400 if version isCurrent (promote another first). 404 if not found."
    }
   ],
   "errors": [],
   "guard": "manage_workflows + sudo. Refuses to archive the live (isCurrent) version."
  },
  {
   "id": "POST /user-management/workflows/:id/promote",
   "group": "workflow",
   "method": "POST",
   "path": "/user-management/workflows/:id/promote",
   "purpose": "Approve a pending version and put it live.",
   "auth": {
    "text": "Session + approve_workflows + sudo",
    "kind": "session",
    "permissions": [
     "approve_workflows"
    ],
    "stepUp": "required",
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body. Approver identity read from x-user header.",
   "response": "200 promoted Workflow (isCurrent/approved/isActive, approvedBy/At stamped; prior versions soft-archived). 403 no approver / SoD; 400 if definition invalid; 404 not found.",
   "responses": [
    {
     "status": 200,
     "text": "promoted Workflow (isCurrent/approved/isActive, approvedBy/At stamped; prior versions soft-archived). 403 no approver / SoD; 400 if definition invalid; 404 not found."
    }
   ],
   "errors": [],
   "guard": "approve_workflows + sudo. Separation of duties: author cannot approve own workflow unless administrator, and SoD can be toggled off per-install (fails CLOSED if policy unreadable). Definition must validate or 400."
  },
  {
   "id": "POST /user-management/workflows/:id/reject",
   "group": "workflow",
   "method": "POST",
   "path": "/user-management/workflows/:id/reject",
   "purpose": "Reject a version awaiting approval.",
   "auth": {
    "text": "Session + approve_workflows + sudo",
    "kind": "session",
    "permissions": [
     "approve_workflows"
    ],
    "stepUp": "required",
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "A WorkflowReject: { reason } required (non-empty string).",
   "response": "200 rejected Workflow (status 'rejected', rejectionReason stored, isCurrent/isActive false). 400 if reason missing or version already approved; 404 not found.",
   "responses": [
    {
     "status": 200,
     "text": "rejected Workflow (status 'rejected', rejectionReason stored, isCurrent/isActive false). 400 if reason missing or version already approved; 404 not found."
    }
   ],
   "errors": [],
   "guard": "approve_workflows + sudo. Reason mandatory; cannot reject an already-approved version."
  },
  {
   "id": "POST /user-management/workflows/:id/dry-run",
   "group": "workflow",
   "method": "POST",
   "path": "/user-management/workflows/:id/dry-run",
   "purpose": "Validate a definition with no side effects: graph integrity, per-node readiness, provider configuration.",
   "auth": {
    "text": "Session + manage_workflows",
    "kind": "session",
    "permissions": [
     "manage_workflows"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body. Validates the stored definition of :id.",
   "response": "200 { workflow:{id,name,version}, valid:bool, errors[], warnings[], nodes[{id,label,provider,status,messages}] }. 404 if not found. No side effects.",
   "responses": [
    {
     "status": 200,
     "text": "{ workflow:{id,name,version}, valid:bool, errors[], warnings[], nodes[{id,label,provider,status,messages}] }. 404 if not found. No side effects."
    }
   ],
   "errors": [],
   "guard": "manage_workflows. Pure validation (graph integrity + per-node provider readiness); executes nothing."
  },
  {
   "id": "GET /user-management/workflows/:id/export",
   "group": "workflow",
   "method": "GET",
   "path": "/user-management/workflows/:id/export",
   "purpose": "Export a workflow as portable JSON.",
   "auth": {
    "text": "Session + manage_workflows",
    "kind": "session",
    "permissions": [
     "manage_workflows"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { zonitixWorkflow:1, name, type, version, description, automationMode, personaOverrides, nodes[], edges[], exportedAt } — the shape POST /user-management/workflows/import accepts | 404 'Workflow not found'",
   "responses": [
    {
     "status": 200,
     "text": "{ zonitixWorkflow:1, name, type, version, description, automationMode, personaOverrides, nodes[], edges[], exportedAt } — the shape POST /user-management/workflows/import accepts"
    },
    {
     "status": 404,
     "text": "'Workflow not found'"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'Workflow not found'"
    }
   ],
   "guard": null
  },
  {
   "id": "POST /user-management/workflows/import",
   "group": "workflow",
   "method": "POST",
   "path": "/user-management/workflows/import",
   "purpose": "Import a JSON definition as a new draft version.",
   "auth": {
    "text": "Session + manage_workflows + sudo",
    "kind": "session",
    "permissions": [
     "manage_workflows"
    ],
    "stepUp": "required",
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ type ('onboarding'|'offboarding'), nodes[] (non-empty) } required; edges[], name (trimmed, max 120 chars), description, automationMode, personaOverrides optional. status forced to 'draft'.",
   "response": "201 Workflow created as a new DRAFT version (never auto-activated). 400 if payload invalid / type wrong / nodes empty / edges not array.",
   "responses": [
    {
     "status": 201,
     "text": "Workflow created as a new DRAFT version (never auto-activated). 400 if payload invalid / type wrong / nodes empty / edges not array."
    }
   ],
   "errors": [],
   "guard": "manage_workflows + sudo. Structural validation only; delegates to createWorkflow: the imported name is honoured (an omitted name falls back to the standard name) and versions are numbered per name, so only version and status (always 'draft') are server-owned."
  },
  {
   "id": "POST /user-management/workflows/bulk-decision",
   "group": "workflow",
   "method": "POST",
   "path": "/user-management/workflows/bulk-decision",
   "purpose": "Promote or reject several pending versions in one call.",
   "auth": {
    "text": "Session + approve_workflows + sudo",
    "kind": "session",
    "permissions": [
     "approve_workflows"
    ],
    "stepUp": "required",
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "A WorkflowBulkDecision: { ids:[] (1 to 200 workflow ids; duplicates are processed once), action:'promote'|'reject' } required; reason required (non-empty) when action is 'reject'",
   "response": "200 { succeeded:[id], failed:[{ id, error }] } — one failure does not stop the rest | 400 unknown action, empty ids, more than 200 ids, or reject without a reason",
   "responses": [
    {
     "status": 200,
     "text": "{ succeeded:[id], failed:[{ id, error }] } — one failure does not stop the rest"
    },
    {
     "status": 400,
     "text": "unknown action, empty ids, more than 200 ids, or reject without a reason"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "unknown action, empty ids, more than 200 ids, or reject without a reason"
    }
   ],
   "guard": "same guards as the single approve/reject; separation of duties and validate-before-promote are enforced per version"
  },
  {
   "id": "POST /user-management/workflows/:id/clone",
   "group": "workflow",
   "method": "POST",
   "path": "/user-management/workflows/:id/clone",
   "purpose": "Copy an existing pipeline into a new named draft. It still has to pass approval before it runs.",
   "auth": {
    "text": "Session + manage_workflows + sudo",
    "kind": "session",
    "permissions": [
     "manage_workflows"
    ],
    "stepUp": "required",
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "A WorkflowClone: { name? } — optional name for the copy, trimmed to 120 characters; omitted, the copy is named '<source name> (copy)'",
   "response": "201 the Workflow row of the copy, status 'draft', as version 1 of a new pipeline | 404 'Workflow not found' | 409 the name already belongs to a pipeline (live or archived)",
   "responses": [
    {
     "status": 201,
     "text": "the Workflow row of the copy, status 'draft', as version 1 of a new pipeline"
    },
    {
     "status": 404,
     "text": "'Workflow not found'"
    },
    {
     "status": 409,
     "text": "the name already belongs to a pipeline (live or archived)"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'Workflow not found'"
    },
    {
     "status": 409,
     "text": "the name already belongs to a pipeline (live or archived)"
    }
   ],
   "guard": "manage_workflows + sudo. Not repeatable with the same name: the second call is refused with 409 instead of creating a duplicate"
  },
  {
   "id": "POST /user-management/workflows/:id/retire",
   "group": "workflow",
   "method": "POST",
   "path": "/user-management/workflows/:id/retire",
   "purpose": "Take a whole named pipeline out of service, or bring it back.",
   "auth": {
    "text": "Session + manage_workflows + sudo",
    "kind": "session",
    "permissions": [
     "manage_workflows"
    ],
    "stepUp": "required",
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "A WorkflowRetire: { retired:boolean } — true retires, anything else reinstates",
   "response": "200 { name, type, isActive, versionsChanged } | 404 'Workflow not found' | 400 the pipeline is the default for its type, or it is the only active pipeline of its type",
   "responses": [
    {
     "status": 200,
     "text": "{ name, type, isActive, versionsChanged }"
    },
    {
     "status": 404,
     "text": "'Workflow not found'"
    },
    {
     "status": 400,
     "text": "the pipeline is the default for its type, or it is the only active pipeline of its type"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'Workflow not found'"
    },
    {
     "status": 400,
     "text": "the pipeline is the default for its type, or it is the only active pipeline of its type"
    }
   ],
   "guard": "acts on the pipeline name; refuses the default pipeline and the last active pipeline of a type; safe to repeat (it sets the state on every version of the name)"
  },
  {
   "id": "POST /user-management/workflows/:id/default",
   "group": "workflow",
   "method": "POST",
   "path": "/user-management/workflows/:id/default",
   "purpose": "Choose which pipeline every unnamed run uses.",
   "auth": {
    "text": "Session + manage_workflows",
    "kind": "session",
    "permissions": [
     "manage_workflows"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 the Workflow row, now isDefault:true (the flag is cleared on every other pipeline of the same type) | 404 'Workflow not found' | 400 the version is not the live approved one, or the pipeline is not active",
   "responses": [
    {
     "status": 200,
     "text": "the Workflow row, now isDefault:true (the flag is cleared on every other pipeline of the same type)"
    },
    {
     "status": 404,
     "text": "'Workflow not found'"
    },
    {
     "status": 400,
     "text": "the version is not the live approved one, or the pipeline is not active"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'Workflow not found'"
    },
    {
     "status": 400,
     "text": "the version is not the live approved one, or the pipeline is not active"
    }
   ],
   "guard": "safe to repeat; audited"
  },
  {
   "id": "DELETE /user-management/workflows/:id/purge",
   "group": "workflow",
   "method": "DELETE",
   "path": "/user-management/workflows/:id/purge",
   "purpose": "Permanently delete a never-used draft pipeline (hard delete, unlike the soft archive above).",
   "auth": {
    "text": "Session + manage_workflows + sudo",
    "kind": "session",
    "permissions": [
     "manage_workflows"
    ],
    "stepUp": "required",
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { name, type, versionsPurged } — every version of that pipeline name is hard-deleted | 404 'Workflow not found' | 400 the pipeline is the default, has an approved version, or has been used by a run",
   "responses": [
    {
     "status": 200,
     "text": "{ name, type, versionsPurged } — every version of that pipeline name is hard-deleted"
    },
    {
     "status": 404,
     "text": "'Workflow not found'"
    },
    {
     "status": 400,
     "text": "the pipeline is the default, has an approved version, or has been used by a run"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'Workflow not found'"
    },
    {
     "status": 400,
     "text": "the pipeline is the default, has an approved version, or has been used by a run"
    }
   ],
   "guard": "refused for anything approved, default, or ever run; a second call answers 404 (the rows are gone)"
  },
  {
   "id": "GET /user-management/tasks",
   "group": "workflow",
   "method": "GET",
   "path": "/user-management/tasks",
   "purpose": "List workflow runs / task queue.",
   "auth": {
    "text": "Session + execute_workflows | manage_approvals | manage_workflows",
    "kind": "session",
    "permissions": [
     "execute_workflows",
     "manage_approvals",
     "manage_workflows"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [
    "limit",
    "offset"
   ],
   "query": [
    {
     "name": "limit",
     "type": "integer",
     "default": 100,
     "minimum": 1,
     "maximum": 500
    },
    {
     "name": "offset",
     "type": "integer",
     "default": 0,
     "minimum": 0
    },
    {
     "name": "status",
     "type": "string",
     "note": "One status or a comma-separated list; at most 10 are read."
    },
    {
     "name": "q",
     "type": "string",
     "note": "Free text. A longer value is cut to 100 characters."
    },
    {
     "name": "envelope",
     "type": "string",
     "enum": [
      "1",
      "true"
     ],
     "note": "Answer { rows, total } and not a bare array."
    }
   ],
   "replyShapes": [
    "array",
    "{ rows: array, total: integer }"
   ],
   "request": "Query: ?limit (default 100, max 500), ?offset (default 0), ?status (one value or a comma-separated list, at most 10), ?q (free text, at most 100 characters; matches title, assignedTo, createdBy and string values in the run data), ?envelope=1 to get the total. No sort parameter: newest createdAt first",
   "response": "200 [ Task ] (bare array, the default) | with ?envelope=1: { rows:[ Task ], total } — list rows are trimmed: each node is reduced to { id, data:{ label, type } } and edges are omitted",
   "responses": [
    {
     "status": 200,
     "text": "[ Task ] (bare array, the default) | with ?envelope=1: { rows:[ Task ], total } — list rows are trimmed: each node is reduced to { id, data:{ label, type } } and edges are omitted"
    }
   ],
   "errors": [],
   "guard": "an administrator or manage_workflows holder sees every run; anyone else sees runs in their own queues, runs assigned to them, and runs they took part in"
  },
  {
   "id": "GET /user-management/tasks/notifications",
   "group": "workflow",
   "method": "GET",
   "path": "/user-management/tasks/notifications",
   "purpose": "Bounded notification feed for the dashboard badge.",
   "auth": {
    "text": "Session + execute_workflows | manage_approvals | manage_workflows",
    "kind": "session",
    "permissions": [
     "execute_workflows",
     "manage_approvals",
     "manage_workflows"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 [ Task ] — active runs only, newest first, at most 50, edges omitted",
   "responses": [
    {
     "status": 200,
     "text": "[ Task ] — active runs only, newest first, at most 50, edges omitted"
    }
   ],
   "errors": [],
   "guard": "scoped to the caller's queues unless they are an administrator or hold manage_workflows"
  },
  {
   "id": "POST /user-management/tasks",
   "group": "workflow",
   "method": "POST",
   "path": "/user-management/tasks",
   "purpose": "Start a workflow run.",
   "auth": {
    "text": "Session + execute_workflows",
    "kind": "session",
    "permissions": [
     "execute_workflows"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "A WorkflowRunCreate. Allowlisted: workflowId (required), title (required), description, status, assignedTo, currentStep, data, userId, sourceCandidateId, startDate, dueDate (dates as YYYY-MM-DD). Any other key is dropped; the run's steps come from the stored pipeline, never from the request.",
   "response": "201 a WorkflowRun (the Task row) | 400 body is not a JSON object, title missing, workflowId missing or invalid, or a date that is not a real YYYY-MM-DD date | 403 the pipeline is rejected, retired, archived or not yet approved | 404 pipeline not found | 422 a leaver run on a pipeline with no revocation step that is not linked to an employee | 403 the persona confers access the caller may not grant",
   "responses": [
    {
     "status": 201,
     "text": "a WorkflowRun (the Task row)"
    },
    {
     "status": 400,
     "text": "body is not a JSON object, title missing, workflowId missing or invalid, or a date that is not a real YYYY-MM-DD date"
    },
    {
     "status": 403,
     "text": "the pipeline is rejected, retired, archived or not yet approved"
    },
    {
     "status": 404,
     "text": "pipeline not found"
    },
    {
     "status": 422,
     "text": "a leaver run on a pipeline with no revocation step that is not linked to an employee"
    },
    {
     "status": 403,
     "text": "the persona confers access the caller may not grant"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "body is not a JSON object, title missing, workflowId missing or invalid, or a date that is not a real YYYY-MM-DD date"
    },
    {
     "status": 403,
     "text": "the pipeline is rejected, retired, archived or not yet approved"
    },
    {
     "status": 404,
     "text": "pipeline not found"
    },
    {
     "status": 422,
     "text": "a leaver run on a pipeline with no revocation step that is not linked to an employee"
    },
    {
     "status": 403,
     "text": "the persona confers access the caller may not grant"
    }
   ],
   "guard": "execute_workflows. createdBy is set by the server. Not deduplicated: each call starts a new run."
  },
  {
   "id": "GET /user-management/tasks/:id",
   "group": "workflow",
   "method": "GET",
   "path": "/user-management/tasks/:id",
   "purpose": "One run by id. Managers and administrators see any run; others only a run in their own queue.",
   "auth": {
    "text": "Session + execute_workflows | manage_approvals | manage_workflows",
    "kind": "session",
    "permissions": [
     "execute_workflows",
     "manage_approvals",
     "manage_workflows"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 a WorkflowRun (the Task row, typed under Response Objects: title, status, assignedTo, currentStep, data, nodes, edges, startDate, dueDate, …) | 404 'Task not found' | 403 'Access Denied' for a run outside the caller's queues",
   "responses": [
    {
     "status": 200,
     "text": "a WorkflowRun (the Task row, typed under Response Objects: title, status, assignedTo, currentStep, data, nodes, edges, startDate, dueDate, …)"
    },
    {
     "status": 404,
     "text": "'Task not found'"
    },
    {
     "status": 403,
     "text": "'Access Denied' for a run outside the caller's queues"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'Task not found'"
    },
    {
     "status": 403,
     "text": "'Access Denied' for a run outside the caller's queues"
    }
   ],
   "guard": null
  },
  {
   "id": "POST /user-management/tasks/:id/claim",
   "group": "workflow",
   "method": "POST",
   "path": "/user-management/tasks/:id/claim",
   "purpose": "Mark a step as being handled by you (audited; it does not change who may act).",
   "auth": {
    "text": "Session + execute_workflows | manage_approvals",
    "kind": "session",
    "permissions": [
     "execute_workflows",
     "manage_approvals"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 the Task row; data gains <currentNodeId>_claimedBy and <currentNodeId>_claimedAt | 404 'Task not found' | 403 the run is not in the caller's queue, or no step is in progress",
   "responses": [
    {
     "status": 200,
     "text": "the Task row; data gains <currentNodeId>_claimedBy and <currentNodeId>_claimedAt"
    },
    {
     "status": 404,
     "text": "'Task not found'"
    },
    {
     "status": 403,
     "text": "the run is not in the caller's queue, or no step is in progress"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'Task not found'"
    },
    {
     "status": 403,
     "text": "the run is not in the caller's queue, or no step is in progress"
    }
   ],
   "guard": "safe to repeat: a later claim overwrites the stamp; audited as TASK_CLAIMED"
  },
  {
   "id": "POST /user-management/tasks/:id/recall",
   "group": "workflow",
   "method": "POST",
   "path": "/user-management/tasks/:id/recall",
   "purpose": "Send a run back to an earlier human step. Only allowed before provisioning has started.",
   "auth": {
    "text": "Session + execute_workflows | manage_approvals",
    "kind": "session",
    "permissions": [
     "execute_workflows",
     "manage_approvals"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "A WorkflowRunRecall: { targetNodeId } required — a human step the run has already passed; reason optional",
   "response": "200 { recalledTo, reopened (steps reopened), notified (people told their completed step was reopened) } | 404 'Task not found' | 403 with the reason: the run is finished, the step is not in this run, is not a human step, is already the current step, is not behind the current step, the caller owns neither that step nor the current one, an automated step is running, or a step in between has already provisioned something or was attempted",
   "responses": [
    {
     "status": 200,
     "text": "{ recalledTo, reopened (steps reopened), notified (people told their completed step was reopened) }"
    },
    {
     "status": 404,
     "text": "'Task not found'"
    },
    {
     "status": 403,
     "text": "with the reason: the run is finished, the step is not in this run, is not a human step, is already the current step, is not behind the current step, the caller owns neither that step nor the current one, an automated step is running, or a step in between has already provisioned something or was attempted"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'Task not found'"
    },
    {
     "status": 403,
     "text": "with the reason: the run is finished, the step is not in this run, is not a human step, is already the current step, is not behind the current step, the caller owns neither that step nor the current one, an automated step is running, or a step in between has already provisioned something or was attempted"
    }
   ],
   "guard": "not repeatable: once recalled, the target is the current step and a second call is refused with 403"
  },
  {
   "id": "PUT /user-management/tasks/:id",
   "group": "workflow",
   "method": "PUT",
   "path": "/user-management/tasks/:id",
   "purpose": "Update a run (status, assignment, data).",
   "auth": {
    "text": "Session + execute_workflows | manage_approvals",
    "kind": "session",
    "permissions": [
     "execute_workflows",
     "manage_approvals"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "A WorkflowRunUpdate. Whitelisted only: status, assignedTo, data, currentStep. Other columns (workflowId, title, createdBy, id) ignored.",
   "response": "200 updated WorkflowRun (the Task row). 403 if caller not in the task's current queue; 403 if assignedTo move skips ahead in the graph; 404 not found.",
   "responses": [
    {
     "status": 200,
     "text": "updated WorkflowRun (the Task row). 403 if caller not in the task's current queue; 403 if assignedTo move skips ahead in the graph; 404 not found."
    }
   ],
   "errors": [],
   "guard": "execute_workflows|manage_approvals. Queue-ownership (canActOnQueue: only administrator or the queue's group; manage_workflows does NOT bypass). completedBy/At forced to verified actor; assertLegalQueueMove blocks skipping an approval stage (fails OPEN if graph indeterminate)."
  },
  {
   "id": "DELETE /user-management/tasks/:id",
   "group": "workflow",
   "method": "DELETE",
   "path": "/user-management/tasks/:id",
   "purpose": "Delete a run.",
   "auth": {
    "text": "Session + manage_workflows + sudo",
    "kind": "session",
    "permissions": [
     "manage_workflows"
    ],
    "stepUp": "required",
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "204 no content (hard destroy of the Task row). Succeeds even if row already gone.",
   "responses": [
    {
     "status": 204,
     "text": "no content (hard destroy of the Task row). Succeeds even if row already gone."
    }
   ],
   "errors": [],
   "guard": "manage_workflows + sudo."
  },
  {
   "id": "POST /user-management/tasks/:id/execute-node",
   "group": "workflow",
   "method": "POST",
   "path": "/user-management/tasks/:id/execute-node",
   "purpose": "Execute one step of a run. This fires real external provisioning.",
   "auth": {
    "text": "Session + execute_workflows | manage_approvals",
    "kind": "session",
    "permissions": [
     "execute_workflows",
     "manage_approvals"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "A WorkflowNodeExecute: { nodeId, taskData } — ONLY these taken from body. action/provider/target derived server-side from the run's stored graph.",
   "response": "200 execution result { success, ... nextStatus, message } (or { success:true, idempotent:true } if node already executed). 403 not in queue; throws if task/node missing. Fires REAL external provisioning.",
   "responses": [
    {
     "status": 200,
     "text": "execution result { success, ... nextStatus, message } (or { success:true, idempotent:true } if node already executed). 403 not in queue; throws if task/node missing. Fires REAL external provisioning."
    }
   ],
   "errors": [],
   "guard": "execute_workflows|manage_approvals + queue-ownership (canActOnQueue). Idempotent: refuses to re-run an already-executed node. Node must exist in the graph; action/provider never trusted from client."
  },
  {
   "id": "POST /user-management/tasks/:id/kickoff",
   "group": "workflow",
   "method": "POST",
   "path": "/user-management/tasks/:id/kickoff",
   "purpose": "Run all currently-eligible automated steps for a run on demand.",
   "auth": {
    "text": "Session + execute_workflows | manage_approvals",
    "kind": "session",
    "permissions": [
     "execute_workflows",
     "manage_approvals"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { ranAutomated:int, message, task } — runs every currently-eligible automated step (stops at human gates). 404 if task not found; 403 if caller not in the task's queue.",
   "responses": [
    {
     "status": 200,
     "text": "{ ranAutomated:int, message, task } — runs every currently-eligible automated step (stops at human gates). 404 if task not found; 403 if caller not in the task's queue."
    }
   ],
   "errors": [],
   "guard": "execute_workflows|manage_approvals + queue-ownership (canActOnQueue on task.assignedTo). Delegates to advanceTask, attributed to caller; fires real provisioning for the steps it runs."
  },
  {
   "id": "PUT /user-management/tasks/:id/schedule",
   "group": "workflow",
   "method": "PUT",
   "path": "/user-management/tasks/:id/schedule",
   "purpose": "Move a run's dates when the start date changes.",
   "auth": {
    "text": "Session + execute_workflows | manage_approvals | manage_workflows",
    "kind": "session",
    "permissions": [
     "execute_workflows",
     "manage_approvals",
     "manage_workflows"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "A WorkflowRunSchedule: { startDate } — a real YYYY-MM-DD date, or null to clear it",
   "response": "200 { id, startDate, redated (outstanding dated steps moved) } | 400 startDate is not a real YYYY-MM-DD date | 403 not in the run's queue | 404 'Task not found'",
   "responses": [
    {
     "status": 200,
     "text": "{ id, startDate, redated (outstanding dated steps moved) }"
    },
    {
     "status": 400,
     "text": "startDate is not a real YYYY-MM-DD date"
    },
    {
     "status": 403,
     "text": "not in the run's queue"
    },
    {
     "status": 404,
     "text": "'Task not found'"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "startDate is not a real YYYY-MM-DD date"
    },
    {
     "status": 403,
     "text": "not in the run's queue"
    },
    {
     "status": 404,
     "text": "'Task not found'"
    }
   ],
   "guard": "safe to repeat (sets the dates; steps already executed keep theirs); audited as TASK_RESCHEDULED"
  },
  {
   "id": "GET /user-management/tasks/:id/evidence",
   "group": "workflow",
   "method": "GET",
   "path": "/user-management/tasks/:id/evidence",
   "purpose": "The deprovisioning record for a run (read-only).",
   "auth": {
    "text": "Session + execute_workflows | manage_approvals | manage_workflows",
    "kind": "session",
    "permissions": [
     "execute_workflows",
     "manage_approvals",
     "manage_workflows"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 the run's record, built from the run and the graph it was started with | 403 the caller cannot see this run | 404 'Task not found'",
   "responses": [
    {
     "status": 200,
     "text": "the run's record, built from the run and the graph it was started with"
    },
    {
     "status": 403,
     "text": "the caller cannot see this run"
    },
    {
     "status": 404,
     "text": "'Task not found'"
    }
   ],
   "errors": [
    {
     "status": 403,
     "text": "the caller cannot see this run"
    },
    {
     "status": 404,
     "text": "'Task not found'"
    }
   ],
   "guard": null
  },
  {
   "id": "POST /user-management/tasks/:id/reveal-credential",
   "group": "workflow",
   "method": "POST",
   "path": "/user-management/tasks/:id/reveal-credential",
   "purpose": "Show a new hire's temporary password once; it is purged after the reveal.",
   "auth": {
    "text": "Session + execute_workflows | manage_approvals | manage_workflows",
    "kind": "session",
    "permissions": [
     "execute_workflows",
     "manage_approvals",
     "manage_workflows"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "A CredentialReveal: { nodeId } required — the step that created the account",
   "response": "200 { password } | 400 'nodeId is required' | 403 not in the run's queue | 404 'Task not found' | 410 the password was already revealed, or it was delivered by email",
   "responses": [
    {
     "status": 200,
     "text": "{ password }"
    },
    {
     "status": 400,
     "text": "'nodeId is required'"
    },
    {
     "status": 403,
     "text": "not in the run's queue"
    },
    {
     "status": 404,
     "text": "'Task not found'"
    },
    {
     "status": 410,
     "text": "the password was already revealed, or it was delivered by email"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "'nodeId is required'"
    },
    {
     "status": 403,
     "text": "not in the run's queue"
    },
    {
     "status": 404,
     "text": "'Task not found'"
    },
    {
     "status": 410,
     "text": "the password was already revealed, or it was delivered by email"
    }
   ],
   "guard": "queue-ownership checked in the controller; single use: the stored value is deleted on the first reveal, so a repeat answers 410; audited as TASK_CREDENTIAL_REVEALED"
  },
  {
   "id": "GET /user-management/providers",
   "group": "integration",
   "method": "GET",
   "path": "/user-management/providers",
   "purpose": "List configured provider hooks (secrets never returned).",
   "auth": {
    "text": "Session + manage_hooks | manage_integrations | manage_alignment_policy | execute_identity_writeback | approve_identity_changes",
    "kind": "session",
    "permissions": [
     "approve_identity_changes",
     "execute_identity_writeback",
     "manage_alignment_policy",
     "manage_hooks",
     "manage_integrations"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 [ HookProvider ] (typed under Response Objects) — every hook in one reply (no paging); secret values are stripped from config and replaced by configuredCredentials[] (the names of the credentials that are set)",
   "responses": [
    {
     "status": 200,
     "text": "[ HookProvider ] (typed under Response Objects) — every hook in one reply (no paging); secret values are stripped from config and replaced by configuredCredentials[] (the names of the credentials that are set)"
    }
   ],
   "errors": [],
   "guard": "bare array, no paging or filter"
  },
  {
   "id": "POST /user-management/providers",
   "group": "integration",
   "method": "POST",
   "path": "/user-management/providers",
   "purpose": "Create a provider hook.",
   "auth": {
    "text": "Session + manage_hooks | manage_integrations",
    "kind": "session",
    "permissions": [
     "manage_hooks",
     "manage_integrations"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ name, type, config, description?, isActive?, syncInterval?, canReadUsers?, canReadGroups?, canWriteUsers?, canWriteGroups?, canReadAllResources? } — only these are persisted",
   "response": "201 the HookProvider with configuredCredentials[] (secret ciphertext stripped)",
   "responses": [
    {
     "status": 201,
     "text": "the HookProvider with configuredCredentials[] (secret ciphertext stripped)"
    }
   ],
   "errors": [],
   "guard": "mass-assignment allowlist (PROVIDER_WRITABLE_FIELDS); isVerified is server-set, never accepted from body; not deduplicated: hook names are not unique, so repeating the call creates a second hook"
  },
  {
   "id": "PUT /user-management/providers/:id",
   "group": "integration",
   "method": "PUT",
   "path": "/user-management/providers/:id",
   "purpose": "Update a provider hook.",
   "auth": {
    "text": "Session + manage_hooks | manage_integrations",
    "kind": "session",
    "permissions": [
     "manage_hooks",
     "manage_integrations"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "same allowlisted fields as create (all optional); config is MERGED into existing, not replaced",
   "response": "200 the HookProvider | 404 'Provider not found'",
   "responses": [
    {
     "status": 200,
     "text": "the HookProvider"
    },
    {
     "status": 404,
     "text": "'Provider not found'"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'Provider not found'"
    }
   ],
   "guard": "allowlist blocks forging isVerified; changing a CONNECTION_FIELD auto-resets isVerified=false; omitted secret keys preserved so config edits don't wipe credentials"
  },
  {
   "id": "DELETE /user-management/providers/:id",
   "group": "integration",
   "method": "DELETE",
   "path": "/user-management/providers/:id",
   "purpose": "Delete a provider hook.",
   "auth": {
    "text": "Session + manage_hooks | manage_integrations",
    "kind": "session",
    "permissions": [
     "manage_hooks",
     "manage_integrations"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "204 empty | 404 'Provider not found'",
   "responses": [
    {
     "status": 204,
     "text": "empty"
    },
    {
     "status": 404,
     "text": "'Provider not found'"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'Provider not found'"
    }
   ],
   "guard": "releases the users and groups the hook owned (they stay in the directory), deletes the hook's SaaS usage rows, and audits PROVIDER_DELETED with the counts"
  },
  {
   "id": "POST /user-management/providers/:id/test",
   "group": "integration",
   "method": "POST",
   "path": "/user-management/providers/:id/test",
   "purpose": "Test connectivity/credentials for a hook.",
   "auth": {
    "text": "Session + manage_hooks | manage_integrations",
    "kind": "session",
    "permissions": [
     "manage_hooks",
     "manage_integrations"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body (id in path).",
   "response": "200 testProviderConnection result | 404 | error passed to next() (logs PROVIDER_TEST_FAILED)",
   "responses": [
    {
     "status": 200,
     "text": "testProviderConnection result"
    },
    {
     "status": 404,
     "text": " | error passed to next() (logs PROVIDER_TEST_FAILED)"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": " | error passed to next() (logs PROVIDER_TEST_FAILED)"
    }
   ],
   "guard": null
  },
  {
   "id": "POST /user-management/providers/:id/validate",
   "group": "integration",
   "method": "POST",
   "path": "/user-management/providers/:id/validate",
   "purpose": "Validate per-capability readiness of a hook.",
   "auth": {
    "text": "Session + manage_hooks | manage_integrations",
    "kind": "session",
    "permissions": [
     "manage_hooks",
     "manage_integrations"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body (id in path).",
   "response": "200 { ...checks result, provider:safe(p) }; persists isVerified=canFinish + force-disables any failed capability | 404",
   "responses": [
    {
     "status": 200,
     "text": "{ ...checks result, provider:safe(p) }; persists isVerified=canFinish + force-disables any failed capability"
    },
    {
     "status": 404,
     "text": ""
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": ""
    }
   ],
   "guard": "only forces capabilities OFF, never ON — an unchecked capability stays the operator's choice"
  },
  {
   "id": "POST /user-management/providers/:id/sync",
   "group": "integration",
   "method": "POST",
   "path": "/user-management/providers/:id/sync",
   "purpose": "Run a directory sync for a hook now.",
   "auth": {
    "text": "Session + manage_hooks | manage_integrations",
    "kind": "session",
    "permissions": [
     "manage_hooks",
     "manage_integrations"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "{ syncType?='delta', isManual?=true, dryRun?=false } all optional",
   "response": "200 { message, skipped?, unsupported?, inFlight?, partial? } | 404 'Provider not found'. A sync that did not run (skipped, unsupported, or one already in flight) and one that imported only part of the directory still answer 200; read the flags, not only message",
   "responses": [
    {
     "status": 200,
     "text": "{ message, skipped?, unsupported?, inFlight?, partial? }"
    },
    {
     "status": 404,
     "text": "'Provider not found'. A sync that did not run (skipped, unsupported, or one already in flight) and one that imported only part of the directory still answer 200; read the flags, not only message"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'Provider not found'. A sync that did not run (skipped, unsupported, or one already in flight) and one that imported only part of the directory still answer 200; read the flags, not only message"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /user-management/providers/:id/logs",
   "group": "integration",
   "method": "GET",
   "path": "/user-management/providers/:id/logs",
   "purpose": "Sync history and failures for a hook.",
   "auth": {
    "text": "Session + manage_hooks | manage_integrations | manage_alignment_policy | execute_identity_writeback | approve_identity_changes",
    "kind": "session",
    "permissions": [
     "approve_identity_changes",
     "execute_identity_writeback",
     "manage_alignment_policy",
     "manage_hooks",
     "manage_integrations"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 [ ProviderSyncLog ] (typed under Response Objects) — the 10 most recent for that hook, newest first",
   "responses": [
    {
     "status": 200,
     "text": "[ ProviderSyncLog ] (typed under Response Objects) — the 10 most recent for that hook, newest first"
    }
   ],
   "errors": [],
   "guard": "fixed at 10 rows; there is no paging parameter"
  },
  {
   "id": "GET /user-management/providers/:id/evidence/:type",
   "group": "integration",
   "method": "GET",
   "path": "/user-management/providers/:id/evidence/:type",
   "purpose": "Pull compliance evidence directly from a connected hook.",
   "auth": {
    "text": "Session + manage_hooks | manage_integrations | manage_alignment_policy | execute_identity_writeback | approve_identity_changes | manage_compliance",
    "kind": "session",
    "permissions": [
     "approve_identity_changes",
     "execute_identity_writeback",
     "manage_alignment_policy",
     "manage_compliance",
     "manage_hooks",
     "manage_integrations"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    },
    {
     "name": "type",
     "type": "string",
     "basis": "unknown"
    }
   ],
   "paging": [],
   "query": [],
   "request": "Path: :type names the evidence set to pull; what is accepted depends on the hook's type",
   "response": "200 the evidence data as returned by the provider adapter | 404 'Provider not found' (plain text) | 400 'Evidence fetching not supported for provider type: …' (plain text) for a hook that is not Okta, Entra ID, AWS, Intune or Jamf",
   "responses": [
    {
     "status": 200,
     "text": "the evidence data as returned by the provider adapter"
    },
    {
     "status": 404,
     "text": "'Provider not found' (plain text)"
    },
    {
     "status": 400,
     "text": "'Evidence fetching not supported for provider type: …' (plain text) for a hook that is not Okta, Entra ID, AWS, Intune or Jamf"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'Provider not found' (plain text)"
    },
    {
     "status": 400,
     "text": "'Evidence fetching not supported for provider type: …' (plain text) for a hook that is not Okta, Entra ID, AWS, Intune or Jamf"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /user-management/providers/calendar/freebusy",
   "group": "integration",
   "method": "GET",
   "path": "/user-management/providers/calendar/freebusy",
   "purpose": "Calendar free/busy for interview scheduling (Google Workspace hook).",
   "auth": {
    "text": "Session + schedule_interviews | conduct_interview | manage_recruitment",
    "kind": "session",
    "permissions": [
     "conduct_interview",
     "manage_recruitment",
     "schedule_interviews"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query: ?emails= (comma-separated), ?from= and ?to= (dates; default now to now + 7 days)",
   "response": "200 { configured:true, calendars:{ [email]: { busy:[{ start, end }] } } } | 200 { configured:false } when no active Google Workspace hook exists | 200 { configured:true, error } when the service account is not authorised for calendars | 400 'from/to must be valid dates.'",
   "responses": [
    {
     "status": 200,
     "text": "{ configured:true, calendars:{ [email]: { busy:[{ start, end }] } } }"
    },
    {
     "status": 200,
     "text": "{ configured:false } when no active Google Workspace hook exists"
    },
    {
     "status": 200,
     "text": "{ configured:true, error } when the service account is not authorised for calendars"
    },
    {
     "status": 400,
     "text": "'from/to must be valid dates.'"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "'from/to must be valid dates.'"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /user-management/providers/lifecycle-events",
   "group": "integration",
   "method": "GET",
   "path": "/user-management/providers/lifecycle-events",
   "purpose": "User-lifecycle change events across connected providers.",
   "auth": {
    "text": "Session + manage_hooks | manage_integrations | manage_alignment_policy | execute_identity_writeback | approve_identity_changes",
    "kind": "session",
    "permissions": [
     "approve_identity_changes",
     "execute_identity_writeback",
     "manage_alignment_policy",
     "manage_hooks",
     "manage_integrations"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query: ?since= (a date; ignored when it does not parse)",
   "response": "200 { events[], sources[] } | 200 { events:[], sources:[], error } when collection fails — the call does not answer 5xx for a provider error",
   "responses": [
    {
     "status": 200,
     "text": "{ events[], sources[] }"
    },
    {
     "status": 200,
     "text": "{ events:[], sources:[], error } when collection fails — the call does not answer 5xx for a provider error"
    }
   ],
   "errors": [],
   "guard": "no paging parameters are read; ?since= is the only way to narrow the reply"
  },
  {
   "id": "GET /user-management/providers/cloud-summary",
   "group": "integration",
   "method": "GET",
   "path": "/user-management/providers/cloud-summary",
   "purpose": "Which cloud hooks (AWS / Azure / GCP) exist and are verified. No configuration is returned.",
   "auth": {
    "text": "Session + manage_hooks | manage_integrations | manage_alignment_policy | execute_identity_writeback | approve_identity_changes | view_cloud_infrastructure",
    "kind": "session",
    "permissions": [
     "approve_identity_changes",
     "execute_identity_writeback",
     "manage_alignment_policy",
     "manage_hooks",
     "manage_integrations",
     "view_cloud_infrastructure"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 [ { id, type, name, isVerified } ] — cloud hooks only, id ascending",
   "responses": [
    {
     "status": 200,
     "text": "[ { id, type, name, isVerified } ] — cloud hooks only, id ascending"
    }
   ],
   "errors": [],
   "guard": "bare array, every cloud hook in one reply (no paging)"
  },
  {
   "id": "GET /user-management/providers/compliance-summary",
   "group": "integration",
   "method": "GET",
   "path": "/user-management/providers/compliance-summary",
   "purpose": "Which hooks are connected, for the compliance pages. No configuration is returned.",
   "auth": {
    "text": "Session + manage_hooks | manage_integrations | manage_alignment_policy | execute_identity_writeback | approve_identity_changes | manage_compliance",
    "kind": "session",
    "permissions": [
     "approve_identity_changes",
     "execute_identity_writeback",
     "manage_alignment_policy",
     "manage_compliance",
     "manage_hooks",
     "manage_integrations"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 [ { id:integer, type:string (for example OKTA, AWS), name:string, isActive:boolean, isVerified:boolean, lastSync:string (date-time)|null } ] — every hook, id ascending",
   "responses": [
    {
     "status": 200,
     "text": "[ { id:integer, type:string (for example OKTA, AWS), name:string, isActive:boolean, isVerified:boolean, lastSync:string (date-time)|null } ] — every hook, id ascending"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /user-management/providers/requestable-apps",
   "group": "integration",
   "method": "GET",
   "path": "/user-management/providers/requestable-apps",
   "purpose": "Id, type and name of the active hooks, for the Access Requests catalogue.",
   "auth": {
    "text": "Session + manage_hooks | manage_integrations | manage_alignment_policy | execute_identity_writeback | approve_identity_changes | request_access | approve_access_request | manage_users",
    "kind": "session",
    "permissions": [
     "approve_access_request",
     "approve_identity_changes",
     "execute_identity_writeback",
     "manage_alignment_policy",
     "manage_hooks",
     "manage_integrations",
     "manage_users",
     "request_access"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 [ { id, type, name } ] — active hooks only, ordered by name",
   "responses": [
    {
     "status": 200,
     "text": "[ { id, type, name } ] — active hooks only, ordered by name"
    }
   ],
   "errors": [],
   "guard": "bare array, every active hook in one reply (no paging)"
  },
  {
   "id": "GET /user-management/align/users",
   "group": "integration",
   "method": "GET",
   "path": "/user-management/align/users",
   "purpose": "Users as seen across connected sources.",
   "auth": {
    "text": "Session + view permission",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [
    "limit",
    "offset"
   ],
   "query": [
    {
     "name": "limit",
     "type": "integer",
     "default": 50,
     "minimum": 1,
     "maximum": 200
    },
    {
     "name": "offset",
     "type": "integer",
     "default": 0,
     "minimum": 0
    }
   ],
   "request": "Query: ?limit (default 50, max 200), ?offset (default 0). No filter or sort parameter: ordered by email",
   "response": "200 [ { id, firstName, lastName, email, department, position, status, source, sourceProviderId, externalId, attributes } ] — bare array, no total",
   "responses": [
    {
     "status": 200,
     "text": "[ { id, firstName, lastName, email, department, position, status, source, sourceProviderId, externalId, attributes } ] — bare array, no total"
    }
   ],
   "errors": [],
   "guard": "alignSync soft-licence gate; 'view permission' = any of manage_hooks, manage_integrations, manage_alignment_policy, execute_identity_writeback, approve_identity_changes"
  },
  {
   "id": "GET /user-management/align/drift",
   "group": "integration",
   "method": "GET",
   "path": "/user-management/align/drift",
   "purpose": "Scan for drift between your source of truth and connected directories.",
   "auth": {
    "text": "Session + view permission",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query: ?refresh=1 (or true) bypasses the 5-minute cache",
   "response": "200 { connected, totalUsers, comparable, inSync, inDrift, localOnly, errors, syncPct, driftPct, byUser, truncated, sourceLabels, unscannedSources, cached:boolean }",
   "responses": [
    {
     "status": 200,
     "text": "{ connected, totalUsers, comparable, inSync, inDrift, localOnly, errors, syncPct, driftPct, byUser, truncated, sourceLabels, unscannedSources, cached:boolean }"
    }
   ],
   "errors": [],
   "guard": "alignSync soft-licence gate; 'view permission' = any of manage_hooks, manage_integrations, manage_alignment_policy, execute_identity_writeback, approve_identity_changes; a fresh scan reads the connected directories and is audit-logged as IDENTITY_DRIFT_SCAN; concurrent callers share one scan"
  },
  {
   "id": "GET /user-management/align/drift/trend",
   "group": "integration",
   "method": "GET",
   "path": "/user-management/align/drift/trend",
   "purpose": "Drift over time.",
   "auth": {
    "text": "Session + view permission",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [
    "limit"
   ],
   "query": [
    {
     "name": "limit",
     "type": "integer",
     "default": 30,
     "minimum": 1,
     "maximum": 365,
     "note": "The number of most recent snapshots."
    }
   ],
   "request": "Query: ?limit (default 30, max 365) — the number of most recent snapshots",
   "response": "200 [ { date, syncPct, driftPct, inDrift, comparable } ], oldest first | 200 [] when the history cannot be read",
   "responses": [
    {
     "status": 200,
     "text": "[ { date, syncPct, driftPct, inDrift, comparable } ], oldest first"
    },
    {
     "status": 200,
     "text": "[] when the history cannot be read"
    }
   ],
   "errors": [],
   "guard": "alignSync soft-licence gate; 'view permission' = any of manage_hooks, manage_integrations, manage_alignment_policy, execute_identity_writeback, approve_identity_changes"
  },
  {
   "id": "GET /user-management/align/orphans",
   "group": "integration",
   "method": "GET",
   "path": "/user-management/align/orphans",
   "purpose": "Accounts present in a directory with no matching source record.",
   "auth": {
    "text": "Session + view permission",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query: ?refresh=1 (or true) bypasses the 5-minute cache",
   "response": "200 { generatedAt, connected, byProvider, totals:{ orphans, missing, statusMismatch }, cached:boolean }",
   "responses": [
    {
     "status": 200,
     "text": "{ generatedAt, connected, byProvider, totals:{ orphans, missing, statusMismatch }, cached:boolean }"
    }
   ],
   "errors": [],
   "guard": "alignSync soft-licence gate; 'view permission' = any of manage_hooks, manage_integrations, manage_alignment_policy, execute_identity_writeback, approve_identity_changes; a fresh scan is audit-logged as IDENTITY_ORPHAN_SCAN"
  },
  {
   "id": "GET /user-management/align/compare",
   "group": "integration",
   "method": "GET",
   "path": "/user-management/align/compare",
   "purpose": "Field-by-field comparison for one user across sources.",
   "auth": {
    "text": "Session + view permission",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query: ?email= required",
   "response": "200 { email, profiles, matrix, hasConflicts } — compared on the fields the alignment policy has enabled | 400 'Email parameter is required'",
   "responses": [
    {
     "status": 200,
     "text": "{ email, profiles, matrix, hasConflicts } — compared on the fields the alignment policy has enabled"
    },
    {
     "status": 400,
     "text": "'Email parameter is required'"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "'Email parameter is required'"
    }
   ],
   "guard": "alignSync soft-licence gate; 'view permission' = any of manage_hooks, manage_integrations, manage_alignment_policy, execute_identity_writeback, approve_identity_changes"
  },
  {
   "id": "GET /user-management/align/policy",
   "group": "integration",
   "method": "GET",
   "path": "/user-management/align/policy",
   "purpose": "Read the alignment policy.",
   "auth": {
    "text": "Session + view permission",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 the alignment policy object, in the shape PUT /user-management/align/policy accepts",
   "responses": [
    {
     "status": 200,
     "text": "the alignment policy object, in the shape PUT /user-management/align/policy accepts"
    }
   ],
   "errors": [],
   "guard": "alignSync soft-licence gate; 'view permission' = any of manage_hooks, manage_integrations, manage_alignment_policy, execute_identity_writeback, approve_identity_changes"
  },
  {
   "id": "PUT /user-management/align/policy",
   "group": "integration",
   "method": "PUT",
   "path": "/user-management/align/policy",
   "purpose": "Update the alignment policy.",
   "auth": {
    "text": "Session + policy permission",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "policy object (e.g. { attributes:[{field,enabled,...}], autoReconcile:{...} }) passed whole to savePolicy",
   "response": "200 saved policy",
   "responses": [
    {
     "status": 200,
     "text": "saved policy"
    }
   ],
   "errors": [],
   "guard": "requireLicenseSoft('alignSyncEnabled') → 403 only if store explicitly disabled; POLICY perm (manage_alignment_policy|manage_hooks)"
  },
  {
   "id": "GET /user-management/align/source-file",
   "group": "integration",
   "method": "GET",
   "path": "/user-management/align/source-file",
   "purpose": "Read the uploaded source-of-truth file.",
   "auth": {
    "text": "Session + view permission",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { loaded:true, fields[], rowCount, uploadedAt, uploadedBy } | 200 { loaded:false } — metadata only, the rows are not returned",
   "responses": [
    {
     "status": 200,
     "text": "{ loaded:true, fields[], rowCount, uploadedAt, uploadedBy }"
    },
    {
     "status": 200,
     "text": "{ loaded:false } — metadata only, the rows are not returned"
    }
   ],
   "errors": [],
   "guard": "alignSync soft-licence gate; 'view permission' = any of manage_hooks, manage_integrations, manage_alignment_policy, execute_identity_writeback, approve_identity_changes"
  },
  {
   "id": "POST /user-management/align/source-file",
   "group": "integration",
   "method": "POST",
   "path": "/user-management/align/source-file",
   "purpose": "Upload a source-of-truth file.",
   "auth": {
    "text": "Session + policy permission",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ csv } required — CSV text string",
   "response": "200 { loaded:true, rowCount, fields[] } | 400 if csv missing/non-string or parse fails",
   "responses": [
    {
     "status": 200,
     "text": "{ loaded:true, rowCount, fields[] }"
    },
    {
     "status": 400,
     "text": "if csv missing/non-string or parse fails"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "if csv missing/non-string or parse fails"
    }
   ],
   "guard": "alignSync soft-license gate; POLICY perm; uploader taken from x-user header"
  },
  {
   "id": "DELETE /user-management/align/source-file",
   "group": "integration",
   "method": "DELETE",
   "path": "/user-management/align/source-file",
   "purpose": "Remove the source-of-truth file.",
   "auth": {
    "text": "Session + policy permission",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { loaded:false }",
   "responses": [
    {
     "status": 200,
     "text": "{ loaded:false }"
    }
   ],
   "errors": [],
   "guard": "alignSync soft-license gate; POLICY perm"
  },
  {
   "id": "POST /user-management/align/sync",
   "group": "integration",
   "method": "POST",
   "path": "/user-management/align/sync",
   "purpose": "Write back corrections for a single user.",
   "auth": {
    "text": "Session + writeback permission",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ email, sourceOfTruth, targets:string[] } required; dryRun? optional",
   "response": "200 alignUserAttributes result (with verification on real apply) | 400 'Invalid input payload'; failed apply audited then re-thrown",
   "responses": [
    {
     "status": 200,
     "text": "alignUserAttributes result (with verification on real apply)"
    },
    {
     "status": 400,
     "text": "'Invalid input payload'; failed apply audited then re-thrown"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "'Invalid input payload'; failed apply audited then re-thrown"
    }
   ],
   "guard": "alignSync soft-license gate; WRITEBACK perm (execute_identity_writeback|manage_hooks); real apply re-reads targets to confirm writes"
  },
  {
   "id": "POST /user-management/align/bulk-sync",
   "group": "integration",
   "method": "POST",
   "path": "/user-management/align/bulk-sync",
   "purpose": "Write back corrections in bulk.",
   "auth": {
    "text": "Session + manage_hooks | approve_identity_changes",
    "kind": "session",
    "permissions": [
     "approve_identity_changes",
     "manage_hooks"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ emails:string[], sourceOfTruth, targets:string[] } required; dryRun?, reason?, override? optional",
   "response": "200 { summary, results[], dryRun } | 200 { pendingApproval:true, requestId } if approval required | 400 invalid | 409 blast_radius_exceeded",
   "responses": [
    {
     "status": 200,
     "text": "{ summary, results[], dryRun }"
    },
    {
     "status": 200,
     "text": "{ pendingApproval:true, requestId } if approval required"
    },
    {
     "status": 400,
     "text": "invalid"
    },
    {
     "status": 409,
     "text": "blast_radius_exceeded"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "invalid"
    },
    {
     "status": 409,
     "text": "blast_radius_exceeded"
    }
   ],
   "guard": "alignSync gate; perm approve_identity_changes|manage_hooks (SoD); real bulk at/over configured size routes to approval queue; blast-radius cap refuses >cap% of directory unless override:true"
  },
  {
   "id": "GET /user-management/align/history",
   "group": "integration",
   "method": "GET",
   "path": "/user-management/align/history",
   "purpose": "History of write-back operations.",
   "auth": {
    "text": "Session + view permission",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [
    "limit",
    "offset"
   ],
   "query": [
    {
     "name": "limit",
     "type": "integer",
     "default": 500,
     "minimum": 1,
     "maximum": 1000
    },
    {
     "name": "offset",
     "type": "integer",
     "default": 0,
     "minimum": 0
    }
   ],
   "request": "Query: ?limit (default 500, max 1000), ?offset (default 0). No filter parameter: newest first",
   "response": "200 [ IdentityAlignmentLog ] (typed under Response Objects) with source labels resolved — bare array, no total",
   "responses": [
    {
     "status": 200,
     "text": "[ IdentityAlignmentLog ] (typed under Response Objects) with source labels resolved — bare array, no total"
    }
   ],
   "errors": [],
   "guard": "alignSync soft-licence gate; 'view permission' = any of manage_hooks, manage_integrations, manage_alignment_policy, execute_identity_writeback, approve_identity_changes"
  },
  {
   "id": "POST /user-management/align/rollback/:id",
   "group": "integration",
   "method": "POST",
   "path": "/user-management/align/rollback/:id",
   "purpose": "Roll back a previous write-back.",
   "auth": {
    "text": "Session + writeback permission",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "string",
     "format": "uuid",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body (log id in path).",
   "response": "200 executeRollback result | 400 'Log ID is required'",
   "responses": [
    {
     "status": 200,
     "text": "executeRollback result"
    },
    {
     "status": 400,
     "text": "'Log ID is required'"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "'Log ID is required'"
    }
   ],
   "guard": "alignSync soft-license gate; WRITEBACK perm; adminUser from x-user header"
  },
  {
   "id": "GET /user-management/align/change-requests",
   "group": "integration",
   "method": "GET",
   "path": "/user-management/align/change-requests",
   "purpose": "List identity change requests awaiting approval.",
   "auth": {
    "text": "Session + approve_identity_changes",
    "kind": "session",
    "permissions": [
     "approve_identity_changes"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query: ?status= (exact match, for example pending); omitted, every status is returned",
   "response": "200 [ AlignmentChangeRequest ] (typed under Response Objects) with source labels resolved — newest first, capped at 200",
   "responses": [
    {
     "status": 200,
     "text": "[ AlignmentChangeRequest ] (typed under Response Objects) with source labels resolved — newest first, capped at 200"
    }
   ],
   "errors": [],
   "guard": "no paging parameter beyond the 200-row cap; when alignSync is switched off on the licence only held releases are listed"
  },
  {
   "id": "POST /user-management/align/change-requests/:id/approve",
   "group": "integration",
   "method": "POST",
   "path": "/user-management/align/change-requests/:id/approve",
   "purpose": "Approve a change request.",
   "auth": {
    "text": "Session + approve_identity_changes",
    "kind": "session",
    "permissions": [
     "approve_identity_changes"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "{ comment? } optional",
   "response": "200 apply outcome (successCount, failed, targets, mismatches...) | 409 { error } on invalid state",
   "responses": [
    {
     "status": 200,
     "text": "apply outcome (successCount, failed, targets, mismatches...)"
    },
    {
     "status": 409,
     "text": "{ error } on invalid state"
    }
   ],
   "errors": [
    {
     "status": 409,
     "text": "{ error } on invalid state"
    }
   ],
   "guard": "alignSync gate; approve_identity_changes only (separation of duties — approver need not be an operator)"
  },
  {
   "id": "POST /user-management/align/change-requests/:id/reject",
   "group": "integration",
   "method": "POST",
   "path": "/user-management/align/change-requests/:id/reject",
   "purpose": "Reject a change request.",
   "auth": {
    "text": "Session + approve_identity_changes",
    "kind": "session",
    "permissions": [
     "approve_identity_changes"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "{ comment? } optional",
   "response": "200 updated request row | 409 { error } on invalid state",
   "responses": [
    {
     "status": 200,
     "text": "updated request row"
    },
    {
     "status": 409,
     "text": "{ error } on invalid state"
    }
   ],
   "errors": [
    {
     "status": 409,
     "text": "{ error } on invalid state"
    }
   ],
   "guard": "alignSync gate; approve_identity_changes only (SoD)"
  },
  {
   "id": "GET /user-management/discovery/summary",
   "group": "integration",
   "method": "GET",
   "path": "/user-management/discovery/summary",
   "purpose": "SaaS Discovery KPI tiles: the SaaS apps your people actually sign in to, read from connected IdP usage signals (Okta System Log, Google Workspace Reports) after a directory sync.",
   "auth": {
    "text": "Session + view_saas_discovery",
    "kind": "session",
    "permissions": [
     "view_saas_discovery"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { appsDiscovered, shadowItApps, dormantAccounts, activeUsers, sources } — degrades to an empty-but-valid shape rather than erroring",
   "responses": [
    {
     "status": 200,
     "text": "{ appsDiscovered, shadowItApps, dormantAccounts, activeUsers, sources } — degrades to an empty-but-valid shape rather than erroring"
    }
   ],
   "errors": [],
   "guard": "alignSync soft-license gate (requireLicenseSoft('alignSyncEnabled') → 403 only on an explicit store disable); view_saas_discovery (a read-only right that implies view_dashboard)"
  },
  {
   "id": "GET /user-management/discovery/apps",
   "group": "integration",
   "method": "GET",
   "path": "/user-management/discovery/apps",
   "purpose": "One record per discovered app. Apps with at least one account that is not a directory user (status 'unmanaged', shown as Unmatched accounts) are sorted to the top.",
   "auth": {
    "text": "Session + view_saas_discovery",
    "kind": "session",
    "permissions": [
     "view_saas_discovery"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { apps: [{ appKey, appName, category, sources, corroborated, userCount, activeUsers, dormantAccounts, status:'approved'|'unmanaged', lastActiveAt }] } — activeUsers = signed in within a rolling ~30-day window; corroborated = seen from 2+ sources; { apps: [] } on no data or on a read failure",
   "responses": [
    {
     "status": 200,
     "text": "{ apps: [{ appKey, appName, category, sources, corroborated, userCount, activeUsers, dormantAccounts, status:'approved'|'unmanaged', lastActiveAt }] } — activeUsers = signed in within a rolling ~30-day window; corroborated = seen from 2+ sources; { apps: [] } on no data or on a read failure"
    }
   ],
   "errors": [],
   "guard": "alignSync soft-license gate; view_saas_discovery. Read-only w.r.t. identity (never writes directory users); source-bounded to connected IdPs — not a network scanner or endpoint agent; every discovered app in one reply (no paging): unmanaged apps first, then by user count"
  },
  {
   "id": "GET /user-management/discovery/apps/:appKey/users",
   "group": "integration",
   "method": "GET",
   "path": "/user-management/discovery/apps/:appKey/users",
   "purpose": "Per-person drilldown for one discovered app.",
   "auth": {
    "text": "Session + view_saas_discovery",
    "kind": "session",
    "permissions": [
     "view_saas_discovery"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "appKey",
     "type": "string",
     "basis": "unknown"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { appKey, users: [{ userId, userEmail, source, status, lastActiveAt }] }, most recently active first",
   "responses": [
    {
     "status": 200,
     "text": "{ appKey, users: [{ userId, userEmail, source, status, lastActiveAt }] }, most recently active first"
    }
   ],
   "errors": [],
   "guard": "alignSync soft-license gate; view_saas_discovery. Active-user counts (matched users only) are pushed best-effort to Finance so the SaaS Spend page can compute reclaimable spend when the finance entitlement is licensed"
  },
  {
   "id": "GET /compliance/metrics/overview",
   "group": "compliance",
   "method": "GET",
   "path": "/compliance/metrics/overview",
   "purpose": "Dashboard compliance overview.",
   "auth": {
    "text": "Session + view_dashboard",
    "kind": "session",
    "permissions": [
     "view_dashboard"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { totalUsers, activeWorkflows, logStats:[{ level, count }], mfaActivations7, mfaActivations30, totalMfaEnabled, totalUsersForMfa, loginSuccess, loginFailure, authWindow, trendWindowDays:7, securityScore, uptimeScore, serviceStatuses[], loginTrends:[{ date, success, failure }] }",
   "responses": [
    {
     "status": 200,
     "text": "{ totalUsers, activeWorkflows, logStats:[{ level, count }], mfaActivations7, mfaActivations30, totalMfaEnabled, totalUsersForMfa, loginSuccess, loginFailure, authWindow, trendWindowDays:7, securityScore, uptimeScore, serviceStatuses[], loginTrends:[{ date, success, failure }] }"
    }
   ],
   "errors": [],
   "guard": "securityScore is service health 50% + MFA coverage 25% + verified hooks 15% + sign-in success 10%; a call may also start a background evidence collection (rate-limited server-side)"
  },
  {
   "id": "GET /compliance/metrics/tco",
   "group": "compliance",
   "method": "GET",
   "path": "/compliance/metrics/tco",
   "purpose": "Total-cost-of-ownership metrics.",
   "auth": {
    "text": "Session + view_finance",
    "kind": "session",
    "permissions": [
     "view_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { currentMonthlyBurn, totalAssetValue, projectedMonthlyAdd, projectedOneTimeAdd, projectedAnnualImpact, pipelineHeadcount, reclaimOpportunity, totalMonthlyWaste, optimizationAlerts[], personaDistribution:[{ name, userCount, monthlySpend }] }",
   "responses": [
    {
     "status": 200,
     "text": "{ currentMonthlyBurn, totalAssetValue, projectedMonthlyAdd, projectedOneTimeAdd, projectedAnnualImpact, pipelineHeadcount, reclaimOpportunity, totalMonthlyWaste, optimizationAlerts[], personaDistribution:[{ name, userCount, monthlySpend }] }"
    }
   ],
   "errors": [],
   "guard": "answers 403 when the Finance module is explicitly switched off on the licence"
  },
  {
   "id": "GET /compliance/metrics/recent-critical-logs",
   "group": "compliance",
   "method": "GET",
   "path": "/compliance/metrics/recent-critical-logs",
   "purpose": "Most recent critical log events.",
   "auth": {
    "text": "Session + view_logs",
    "kind": "session",
    "permissions": [
     "view_logs"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 [ AuditLog ] — the 5 most recent entries at a critical level, newest first",
   "responses": [
    {
     "status": 200,
     "text": "[ AuditLog ] — the 5 most recent entries at a critical level, newest first"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /compliance/reports/log-stats",
   "group": "compliance",
   "method": "GET",
   "path": "/compliance/reports/log-stats",
   "purpose": "Log volume/severity statistics.",
   "auth": {
    "text": "Session + view_logs | view_dashboard",
    "kind": "session",
    "permissions": [
     "view_dashboard",
     "view_logs"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { levelDist:[{ level, count }], actionDist:[{ action, count }] (the 10 most frequent actions) } over the statistics window",
   "responses": [
    {
     "status": 200,
     "text": "{ levelDist:[{ level, count }], actionDist:[{ action, count }] (the 10 most frequent actions) } over the statistics window"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /compliance/reports/stats",
   "group": "compliance",
   "method": "GET",
   "path": "/compliance/reports/stats",
   "purpose": "Headline reporting statistics.",
   "auth": {
    "text": "Session + view_dashboard | view_users",
    "kind": "session",
    "permissions": [
     "view_dashboard",
     "view_users"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { devicesByType:[{ type, count }], usersByGroup:[{ name, userCount }], userStatusDist:[{ status, count }], userSourceDist:[{ source, count }], userSourceStatusDist:[{ source, status, count }], workflowStatusDist:[{ status, count }], taskStatusDist:[{ status, count }], providerHealth:[{ name, type, isVerified, isActive, lastSync, syncCount }] }",
   "responses": [
    {
     "status": 200,
     "text": "{ devicesByType:[{ type, count }], usersByGroup:[{ name, userCount }], userStatusDist:[{ status, count }], userSourceDist:[{ source, count }], userSourceStatusDist:[{ source, status, count }], workflowStatusDist:[{ status, count }], taskStatusDist:[{ status, count }], providerHealth:[{ name, type, isVerified, isActive, lastSync, syncCount }] }"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /compliance/reports/compliance/assessments",
   "group": "compliance",
   "method": "POST",
   "path": "/compliance/reports/compliance/assessments",
   "purpose": "Record a compliance assessment for a framework. The server computes it; nothing you post is scored.",
   "auth": {
    "text": "Session + view_reporting",
    "kind": "session",
    "permissions": [
     "view_reporting"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ framework } required — a framework the instance knows. Any readinessScore / counts / findings in the body are ignored.",
   "response": "201 { id, createdAt, readinessScore, serverComputed:true, passCount, failCount, manualCount, naCount, total } | 400 unknown framework | 503 'Could not compute … — nothing was recorded'",
   "responses": [
    {
     "status": 201,
     "text": "{ id, createdAt, readinessScore, serverComputed:true, passCount, failCount, manualCount, naCount, total }"
    },
    {
     "status": 400,
     "text": "unknown framework"
    },
    {
     "status": 503,
     "text": "'Could not compute … — nothing was recorded'"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "unknown framework"
    },
    {
     "status": 503,
     "text": "'Could not compute … — nothing was recorded'"
    }
   ],
   "guard": "the server re-runs the same report-engine build the interactive report and the sealed artifact use and stores ITS readiness; there is no fallback to client numbers — if the server cannot compute, it records nothing and answers 503"
  },
  {
   "id": "GET /compliance/reports/compliance/assessments",
   "group": "compliance",
   "method": "GET",
   "path": "/compliance/reports/compliance/assessments",
   "purpose": "List past assessment runs.",
   "auth": {
    "text": "Session + view_reporting | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_reporting"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [
    "limit"
   ],
   "query": [
    {
     "name": "framework",
     "type": "string"
    },
    {
     "name": "limit",
     "type": "integer",
     "default": 50,
     "minimum": 1,
     "maximum": 200
    }
   ],
   "request": "Query: ?framework= (exact name), ?limit (default 50, max 200). No offset: newest first, automatic trend snapshots excluded",
   "response": "200 { history:[{ id, framework, readinessScore, passCount, failCount, manualCount, total, naCount, applicable, generatedBy, createdAt }] }",
   "responses": [
    {
     "status": 200,
     "text": "{ history:[{ id, framework, readinessScore, passCount, failCount, manualCount, total, naCount, applicable, generatedBy, createdAt }] }"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /compliance/reports/compliance/assessments/:id",
   "group": "compliance",
   "method": "GET",
   "path": "/compliance/reports/compliance/assessments/:id",
   "purpose": "Drill into one assessment run.",
   "auth": {
    "text": "Session + view_reporting | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_reporting"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { id, framework, readinessScore, passCount, failCount, manualCount, total, naCount, applicable, findings, generatedBy, createdAt } | 404 'Assessment not found'",
   "responses": [
    {
     "status": 200,
     "text": "{ id, framework, readinessScore, passCount, failCount, manualCount, total, naCount, applicable, findings, generatedBy, createdAt }"
    },
    {
     "status": 404,
     "text": "'Assessment not found'"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'Assessment not found'"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /compliance/reports/compliance/trend",
   "group": "compliance",
   "method": "GET",
   "path": "/compliance/reports/compliance/trend",
   "purpose": "Readiness trend over time.",
   "auth": {
    "text": "Session + view_reporting | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_reporting"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [
    "limit"
   ],
   "query": [
    {
     "name": "framework",
     "type": "string"
    },
    {
     "name": "limit",
     "type": "integer",
     "default": 30,
     "minimum": 1,
     "maximum": 365,
     "note": "The number of most recent automatic snapshots."
    }
   ],
   "request": "Query: ?framework= (exact name), ?limit (default 30, max 365) — the number of most recent automatic snapshots",
   "response": "200 { trend:[{ date, readinessScore, failCount, framework, total, applicable, verifiedScore }] }, oldest first",
   "responses": [
    {
     "status": 200,
     "text": "{ trend:[{ date, readinessScore, failCount, framework, total, applicable, verifiedScore }] }, oldest first"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /compliance/reports/compliance/control-states",
   "group": "compliance",
   "method": "GET",
   "path": "/compliance/reports/compliance/control-states",
   "purpose": "Current state of each mapped control.",
   "auth": {
    "text": "Session + view_reporting | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_reporting"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 [ ControlState ] ordered by controlId — fields include controlId, status, origin ('auto' | 'manual'), message, owner, dueDate, frameworks, sourceId, sourceType, signal, freshnessAt, freshnessTtlDays, submittedBy",
   "responses": [
    {
     "status": 200,
     "text": "[ ControlState ] ordered by controlId — fields include controlId, status, origin ('auto' | 'manual'), message, owner, dueDate, frameworks, sourceId, sourceType, signal, freshnessAt, freshnessTtlDays, submittedBy"
    }
   ],
   "errors": [],
   "guard": "bare array, returns every control (no paging or filter)"
  },
  {
   "id": "POST /compliance/reports/compliance/control-states/refresh",
   "group": "compliance",
   "method": "POST",
   "path": "/compliance/reports/compliance/control-states/refresh",
   "purpose": "Re-evaluate automated control checks.",
   "auth": {
    "text": "Session + manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No body. Query: ?async=1 starts the collection in the background instead of waiting for it",
   "response": "200 { updated:int, states[], harvestOk, harvestError, report, failedCollectors:[{ id, label, error }] } | with ?async=1: 202 { running:true, joined:boolean, jobId }",
   "responses": [
    {
     "status": 200,
     "text": "{ updated:int, states[], harvestOk, harvestError, report, failedCollectors:[{ id, label, error }] } | with ?async=1: 202 { running:true, joined:boolean, jobId }"
    }
   ],
   "errors": [],
   "guard": "a collection failure is reported in harvestOk / failedCollectors, not as an error status: the rollup still runs on the last good evidence. Safe to repeat; with ?async=1 a call made while a job is running joins it (joined:true)"
  },
  {
   "id": "PUT /compliance/reports/compliance/control-states/:controlId",
   "group": "compliance",
   "method": "PUT",
   "path": "/compliance/reports/compliance/control-states/:controlId",
   "purpose": "Manually set a control state.",
   "auth": {
    "text": "Session + manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "controlId",
     "type": "string",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "controlId in path; body patch may include status, owner, dueDate, message, frameworks — only those keys applied",
   "response": "200 { control state row } | 400 if controlId missing",
   "responses": [
    {
     "status": 200,
     "text": "{ control state row }"
    },
    {
     "status": 400,
     "text": "if controlId missing"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "if controlId missing"
    }
   ],
   "guard": "marks row origin='manual' so auto-rollup leaves it alone until cleared"
  },
  {
   "id": "POST /compliance/reports/compliance/control-states/:controlId/attest",
   "group": "compliance",
   "method": "POST",
   "path": "/compliance/reports/compliance/control-states/:controlId/attest",
   "purpose": "Record a manual pass on a control, governed by the attestation policy. This replaces the legacy attestation write.",
   "auth": {
    "text": "Session + manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "controlId",
     "type": "string",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "{ framework } required; note — required when the attestation policy is note_required (the default)",
   "response": "200 the ControlState row, now a manual Pass | 400 framework missing, or a note is required | 403 the policy is review_only | 409 { message, reason:'AUTO_FAIL_NEEDS_EVIDENCE' } an automated check is failing and no evidence is attached | 409 { message, reason:'changed' } the control changed since it was read",
   "responses": [
    {
     "status": 200,
     "text": "the ControlState row, now a manual Pass"
    },
    {
     "status": 400,
     "text": "framework missing, or a note is required"
    },
    {
     "status": 403,
     "text": "the policy is review_only"
    },
    {
     "status": 409,
     "text": "{ message, reason:'AUTO_FAIL_NEEDS_EVIDENCE' } an automated check is failing and no evidence is attached"
    },
    {
     "status": 409,
     "text": "{ message, reason:'changed' } the control changed since it was read"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "framework missing, or a note is required"
    },
    {
     "status": 403,
     "text": "the policy is review_only"
    },
    {
     "status": 409,
     "text": "{ message, reason:'AUTO_FAIL_NEEDS_EVIDENCE' } an automated check is failing and no evidence is attached"
    },
    {
     "status": 409,
     "text": "{ message, reason:'changed' } the control changed since it was read"
    }
   ],
   "guard": "audited as COMPLIANCE_CONTROL_ATTESTED, or COMPLIANCE_CONTROL_ATTEST_OVERRODE_REVIEW (warning) when it overrides a review someone else submitted; refusals are audited too"
  },
  {
   "id": "POST /compliance/reports/compliance/control-states/:controlId/submit",
   "group": "compliance",
   "method": "POST",
   "path": "/compliance/reports/compliance/control-states/:controlId/submit",
   "purpose": "Submit a control for review (the assignee side of the submit-then-review path).",
   "auth": {
    "text": "Session + view_assigned_compliance | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_assigned_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "controlId",
     "type": "string",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "{ framework } required",
   "response": "200 the ControlState row, status in_review | 400 framework missing | 403 the control is not assigned to the caller (and the caller lacks manage_compliance) | 409 { message, reason } with reason already_in_review, already_passing, not_applicable or changed | 409 an automated check is failing and no evidence is attached",
   "responses": [
    {
     "status": 200,
     "text": "the ControlState row, status in_review"
    },
    {
     "status": 400,
     "text": "framework missing"
    },
    {
     "status": 403,
     "text": "the control is not assigned to the caller (and the caller lacks manage_compliance)"
    },
    {
     "status": 409,
     "text": "{ message, reason } with reason already_in_review, already_passing, not_applicable or changed"
    },
    {
     "status": 409,
     "text": "an automated check is failing and no evidence is attached"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "framework missing"
    },
    {
     "status": 403,
     "text": "the control is not assigned to the caller (and the caller lacks manage_compliance)"
    },
    {
     "status": 409,
     "text": "{ message, reason } with reason already_in_review, already_passing, not_applicable or changed"
    },
    {
     "status": 409,
     "text": "an automated check is failing and no evidence is attached"
    }
   ],
   "guard": "guarded by a state check, so a repeated submit is refused with 409 already_in_review rather than queued twice; reviewers are notified"
  },
  {
   "id": "POST /compliance/reports/compliance/control-states/:controlId/review",
   "group": "compliance",
   "method": "POST",
   "path": "/compliance/reports/compliance/control-states/:controlId/review",
   "purpose": "Review a submitted control (the reviewer side; separation of duties applies).",
   "auth": {
    "text": "Session + manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "controlId",
     "type": "string",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "{ framework, decision:'approve'|'reopen' } required; reviewNotes required when decision is 'reopen'",
   "response": "200 the ControlState row | 400 a required field is missing or decision is not approve|reopen | 403 separation of duties: the caller submitted this control and is not an administrator (when the review policy requires a separate reviewer) | 409 the control was not submitted, or is not awaiting review | 409 { message, reason:'changed' }",
   "responses": [
    {
     "status": 200,
     "text": "the ControlState row"
    },
    {
     "status": 400,
     "text": "a required field is missing or decision is not approve|reopen"
    },
    {
     "status": 403,
     "text": "separation of duties: the caller submitted this control and is not an administrator (when the review policy requires a separate reviewer)"
    },
    {
     "status": 409,
     "text": "the control was not submitted, or is not awaiting review"
    },
    {
     "status": 409,
     "text": "{ message, reason:'changed' }"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "a required field is missing or decision is not approve|reopen"
    },
    {
     "status": 403,
     "text": "separation of duties: the caller submitted this control and is not an administrator (when the review policy requires a separate reviewer)"
    },
    {
     "status": 409,
     "text": "the control was not submitted, or is not awaiting review"
    },
    {
     "status": 409,
     "text": "{ message, reason:'changed' }"
    }
   ],
   "guard": "guarded by a state check: once decided the control is no longer in review, so a repeat is refused with 409; the owner is notified of the outcome"
  },
  {
   "id": "POST /compliance/reports/compliance/control-states/:controlId/revoke-pass",
   "group": "compliance",
   "method": "POST",
   "path": "/compliance/reports/compliance/control-states/:controlId/revoke-pass",
   "purpose": "Withdraw a manual pass.",
   "auth": {
    "text": "Session + manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "controlId",
     "type": "string",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "{ note } required (at most 1000 characters); framework optional (names the framework in the owner's notification)",
   "response": "200 the ControlState row | 400 note missing or too long | 409 { message, reason } with reason absent, not_manual, not_passing or changed",
   "responses": [
    {
     "status": 200,
     "text": "the ControlState row"
    },
    {
     "status": 400,
     "text": "note missing or too long"
    },
    {
     "status": 409,
     "text": "{ message, reason } with reason absent, not_manual, not_passing or changed"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "note missing or too long"
    },
    {
     "status": 409,
     "text": "{ message, reason } with reason absent, not_manual, not_passing or changed"
    }
   ],
   "guard": "only a manual pass can be revoked; a repeat is refused with 409 not_passing; audited at warning level and the owners are notified"
  },
  {
   "id": "POST /compliance/reports/compliance/control-states/:controlId/return-to-automated",
   "group": "compliance",
   "method": "POST",
   "path": "/compliance/reports/compliance/control-states/:controlId/return-to-automated",
   "purpose": "Hand a manually-set control back to the automated checks.",
   "auth": {
    "text": "Session + manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "controlId",
     "type": "string",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { returned:true } | 409 { message, reason } with reason in_review, already_automated, no_collector or changed",
   "responses": [
    {
     "status": 200,
     "text": "{ returned:true }"
    },
    {
     "status": 409,
     "text": "{ message, reason } with reason in_review, already_automated, no_collector or changed"
    }
   ],
   "errors": [
    {
     "status": 409,
     "text": "{ message, reason } with reason in_review, already_automated, no_collector or changed"
    }
   ],
   "guard": "a repeat is refused with 409 already_automated; audited"
  },
  {
   "id": "GET /compliance/reports/compliance/attestation-policy",
   "group": "compliance",
   "method": "GET",
   "path": "/compliance/reports/compliance/attestation-policy",
   "purpose": "Read the policy that governs manual attestations.",
   "auth": {
    "text": "Session + view_reporting | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_reporting"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { mode:'allow'|'note_required'|'review_only' } — note_required when nothing is saved",
   "responses": [
    {
     "status": 200,
     "text": "{ mode:'allow'|'note_required'|'review_only' } — note_required when nothing is saved"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /compliance/reports/compliance/attestation-policy",
   "group": "compliance",
   "method": "POST",
   "path": "/compliance/reports/compliance/attestation-policy",
   "purpose": "Change the attestation policy.",
   "auth": {
    "text": "Session + manage_security",
    "kind": "session",
    "permissions": [
     "manage_security"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ mode:'allow'|'note_required'|'review_only' }",
   "response": "200 { mode } — the mode now in force",
   "responses": [
    {
     "status": 200,
     "text": "{ mode } — the mode now in force"
    }
   ],
   "errors": [],
   "guard": "an unknown mode is stored as note_required, not rejected; safe to repeat; audited as COMPLIANCE_ATTESTATION_POLICY_SET"
  },
  {
   "id": "GET /compliance/reports/compliance/attestations",
   "group": "compliance",
   "method": "GET",
   "path": "/compliance/reports/compliance/attestations",
   "purpose": "Read-only history of the legacy attestations. New manual verdicts are recorded on the control itself (see control-states/:controlId/attest).",
   "auth": {
    "text": "Session + view_reporting | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_reporting"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query: ?framework= (exact name)",
   "response": "200 { attestations:[{ framework, controlId, satisfied, note, attestedBy, attestedAt }] }",
   "responses": [
    {
     "status": 200,
     "text": "{ attestations:[{ framework, controlId, satisfied, note, attestedBy, attestedAt }] }"
    }
   ],
   "errors": [],
   "guard": "returns every matching row (no paging)"
  },
  {
   "id": "POST /compliance/reports/compliance/attestations",
   "group": "compliance",
   "method": "POST",
   "path": "/compliance/reports/compliance/attestations",
   "purpose": "Retired. Legacy attestation write — use control-states/:controlId/attest, or submit then review.",
   "auth": {
    "text": "Session + manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Ignored.",
   "response": "410 Gone { message, governedPath:{ attest, submit, review } } — nothing is written",
   "responses": [
    {
     "status": 410,
     "text": "Gone { message, governedPath:{ attest, submit, review } } — nothing is written"
    }
   ],
   "errors": [
    {
     "status": 410,
     "text": "Gone { message, governedPath:{ attest, submit, review } } — nothing is written"
    }
   ],
   "guard": null
  },
  {
   "id": "DELETE /compliance/reports/compliance/attestations",
   "group": "compliance",
   "method": "DELETE",
   "path": "/compliance/reports/compliance/attestations",
   "purpose": "Retired. Legacy attestation delete.",
   "auth": {
    "text": "Session + manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Ignored.",
   "response": "410 Gone { message, governedPath } — nothing is deleted",
   "responses": [
    {
     "status": 410,
     "text": "Gone { message, governedPath } — nothing is deleted"
    }
   ],
   "errors": [
    {
     "status": 410,
     "text": "Gone { message, governedPath } — nothing is deleted"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /compliance/reports/compliance/assignments",
   "group": "compliance",
   "method": "GET",
   "path": "/compliance/reports/compliance/assignments",
   "purpose": "List control ownership assignments.",
   "auth": {
    "text": "Session + view_reporting | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_reporting"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query: ?framework= (exact name)",
   "response": "200 [ ComplianceAssignment ] ordered by domain — framework, domain, assigneeId, assigneeName, assigneeEmail, dueDate, note, assignedBy",
   "responses": [
    {
     "status": 200,
     "text": "[ ComplianceAssignment ] ordered by domain — framework, domain, assigneeId, assigneeName, assigneeEmail, dueDate, note, assignedBy"
    }
   ],
   "errors": [],
   "guard": "bare array, no paging"
  },
  {
   "id": "GET /compliance/reports/compliance/my-assignments",
   "group": "compliance",
   "method": "GET",
   "path": "/compliance/reports/compliance/my-assignments",
   "purpose": "Controls assigned to you.",
   "auth": {
    "text": "Session + view_assigned_compliance | manage_compliance | view_reporting",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_assigned_compliance",
     "view_reporting"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 [ ComplianceAssignment ] where the caller is the assignee (matched on user id or email), earliest dueDate first | 200 [] when the caller cannot be identified",
   "responses": [
    {
     "status": 200,
     "text": "[ ComplianceAssignment ] where the caller is the assignee (matched on user id or email), earliest dueDate first"
    },
    {
     "status": 200,
     "text": "[] when the caller cannot be identified"
    }
   ],
   "errors": [],
   "guard": "bare array, every assignment of the caller in one reply (no paging)"
  },
  {
   "id": "POST /compliance/reports/compliance/assignments",
   "group": "compliance",
   "method": "POST",
   "path": "/compliance/reports/compliance/assignments",
   "purpose": "Assign a control to an owner with a deadline.",
   "auth": {
    "text": "Session + manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ framework, domain } required; assigneeId/assigneeName/assigneeEmail/dueDate/note optional",
   "response": "200 { assignment row } | 400 if framework or domain missing",
   "responses": [
    {
     "status": 200,
     "text": "{ assignment row }"
    },
    {
     "status": 400,
     "text": "if framework or domain missing"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "if framework or domain missing"
    }
   ],
   "guard": "assignedBy is set by the server from the session; upsert by framework+domain; safe to repeat (the same framework and domain update the existing row)"
  },
  {
   "id": "DELETE /compliance/reports/compliance/assignments",
   "group": "compliance",
   "method": "DELETE",
   "path": "/compliance/reports/compliance/assignments",
   "purpose": "Remove an assignment.",
   "auth": {
    "text": "Session + manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No body — framework & domain passed as QUERY params (both required)",
   "response": "200 { ok:true } | 400 if framework or domain missing",
   "responses": [
    {
     "status": 200,
     "text": "{ ok:true }"
    },
    {
     "status": 400,
     "text": "if framework or domain missing"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "if framework or domain missing"
    }
   ],
   "guard": null
  },
  {
   "id": "POST /compliance/reports/compliance/reminders/send",
   "group": "compliance",
   "method": "POST",
   "path": "/compliance/reports/compliance/reminders/send",
   "purpose": "Email deadline reminders to control owners.",
   "auth": {
    "text": "Session + manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No body (uses session for audit context).",
   "response": "200 { result } summarizing reminder emails sent to assignees",
   "responses": [
    {
     "status": 200,
     "text": "{ result } summarizing reminder emails sent to assignees"
    }
   ],
   "errors": [],
   "guard": "email is best-effort/gated: nothing sends unless SMTP + recipients configured"
  },
  {
   "id": "GET /compliance/reports/compliance/remediations",
   "group": "compliance",
   "method": "GET",
   "path": "/compliance/reports/compliance/remediations",
   "purpose": "List tracked remediations.",
   "auth": {
    "text": "Session + view_reporting | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_reporting"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query: ?framework= (exact name)",
   "response": "200 { remediations:[{ id, framework, controlId, title, owner, dueDate, status, createdBy, updatedAt }] }, earliest dueDate first",
   "responses": [
    {
     "status": 200,
     "text": "{ remediations:[{ id, framework, controlId, title, owner, dueDate, status, createdBy, updatedAt }] }, earliest dueDate first"
    }
   ],
   "errors": [],
   "guard": "returns every matching row (no paging)"
  },
  {
   "id": "POST /compliance/reports/compliance/remediations",
   "group": "compliance",
   "method": "POST",
   "path": "/compliance/reports/compliance/remediations",
   "purpose": "Create or update a remediation item.",
   "auth": {
    "text": "Session + manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ framework, controlId, title } required; owner/dueDate/status optional (status default 'open')",
   "response": "201 { id, framework, controlId, title, owner, dueDate, status } | 400 if framework/controlId/title missing",
   "responses": [
    {
     "status": 201,
     "text": "{ id, framework, controlId, title, owner, dueDate, status }"
    },
    {
     "status": 400,
     "text": "if framework/controlId/title missing"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "if framework/controlId/title missing"
    }
   ],
   "guard": "upsert by framework+controlId; safe to repeat (the same framework and controlId update the existing row)"
  },
  {
   "id": "DELETE /compliance/reports/compliance/remediations",
   "group": "compliance",
   "method": "DELETE",
   "path": "/compliance/reports/compliance/remediations",
   "purpose": "Delete a remediation item.",
   "auth": {
    "text": "Session + manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No body — framework & controlId passed as QUERY params",
   "response": "200 { ok:bool } (false if nothing matched)",
   "responses": [
    {
     "status": 200,
     "text": "{ ok:bool } (false if nothing matched)"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /compliance/reports/compliance/digest/config",
   "group": "compliance",
   "method": "GET",
   "path": "/compliance/reports/compliance/digest/config",
   "purpose": "Read the stored configuration of the retired compliance digest (read-only; the digest no longer sends).",
   "auth": {
    "text": "Session + view_reporting | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_reporting"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { enabled, recipients[], intervalHours, framework, lastSent, smtpConfigured, retired:true, replacement }",
   "responses": [
    {
     "status": 200,
     "text": "{ enabled, recipients[], intervalHours, framework, lastSent, smtpConfigured, retired:true, replacement }"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /compliance/reports/compliance/digest/config",
   "group": "compliance",
   "method": "POST",
   "path": "/compliance/reports/compliance/digest/config",
   "purpose": "Retired. The compliance digest was replaced by Scheduled reports (/compliance/reports/schedules).",
   "auth": {
    "text": "Session + manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Ignored.",
   "response": "410 Gone { error, code:'DIGEST_RETIRED', replacement:{ ui, api:'/reports/schedules' } } — nothing is saved; the call is audit-logged",
   "responses": [
    {
     "status": 410,
     "text": "Gone { error, code:'DIGEST_RETIRED', replacement:{ ui, api:'/reports/schedules' } } — nothing is saved; the call is audit-logged"
    }
   ],
   "errors": [
    {
     "status": 410,
     "text": "Gone { error, code:'DIGEST_RETIRED', replacement:{ ui, api:'/reports/schedules' } } — nothing is saved; the call is audit-logged"
    }
   ],
   "guard": "an enabled digest was carried into a scheduled report automatically when the digest was retired"
  },
  {
   "id": "POST /compliance/reports/compliance/digest/send",
   "group": "compliance",
   "method": "POST",
   "path": "/compliance/reports/compliance/digest/send",
   "purpose": "Retired. Use Scheduled reports to send a report now (/compliance/reports/schedules/:id/run).",
   "auth": {
    "text": "Session + manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Ignored.",
   "response": "410 Gone { error, code:'DIGEST_RETIRED', replacement } — nothing is sent; the call is audit-logged",
   "responses": [
    {
     "status": 410,
     "text": "Gone { error, code:'DIGEST_RETIRED', replacement } — nothing is sent; the call is audit-logged"
    }
   ],
   "errors": [
    {
     "status": 410,
     "text": "Gone { error, code:'DIGEST_RETIRED', replacement } — nothing is sent; the call is audit-logged"
    }
   ],
   "guard": null
  },
  {
   "id": "POST /compliance/reports/compliance/audit",
   "group": "compliance",
   "method": "POST",
   "path": "/compliance/reports/compliance/audit",
   "purpose": "Write a report-engine action to the audit trail.",
   "auth": {
    "text": "Session + view_reporting",
    "kind": "session",
    "permissions": [
     "view_reporting"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ event: 'EXPORT'|'REMEDIATION' } required (case-insensitive); detail (optional, truncated 300), framework optional",
   "response": "200 { ok:true } | 400 { error:'Unknown report audit event' }",
   "responses": [
    {
     "status": 200,
     "text": "{ ok:true }"
    },
    {
     "status": 400,
     "text": "{ error:'Unknown report audit event' }"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ error:'Unknown report audit event' }"
    }
   ],
   "guard": "event whitelist: only EXPORT/REMEDIATION map to an audit action, anything else rejected"
  },
  {
   "id": "GET /compliance/reports/system-health",
   "group": "compliance",
   "method": "GET",
   "path": "/compliance/reports/system-health",
   "purpose": "Live host load and container status (via a read-only Docker socket proxy).",
   "auth": {
    "text": "Session + view_system",
    "kind": "session",
    "permissions": [
     "view_system"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { at, host:{ cpuCores, load1, load5, load15, loadPct, memTotal, memUsed, memPct, swapTotal, swapUsed, swapPct, disk }, dockerAvailable, containerCount, runningCount, unhealthyCount, containers:[{ name, image, state, status, cpuPct, memBytes, memPct, unhealthy }] } — containers sorted by CPU, highest first",
   "responses": [
    {
     "status": 200,
     "text": "{ at, host:{ cpuCores, load1, load5, load15, loadPct, memTotal, memUsed, memPct, swapTotal, swapUsed, swapPct, disk }, dockerAvailable, containerCount, runningCount, unhealthyCount, containers:[{ name, image, state, status, cpuPct, memBytes, memPct, unhealthy }] } — containers sorted by CPU, highest first"
    }
   ],
   "errors": [],
   "guard": "the result is cached for 25 seconds, so polling faster returns the same reading"
  },
  {
   "id": "GET /compliance/reports/patch-status",
   "group": "compliance",
   "method": "GET",
   "path": "/compliance/reports/patch-status",
   "purpose": "Application (git) update status.",
   "auth": {
    "text": "Session + view_system",
    "kind": "session",
    "permissions": [
     "view_system"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { current:{ commit, tag, committedAt, deployedAt }, repo, branch, upToDate (true | false | null when the check itself failed), behind, securityBehind, updates[], checkedAt, source, … }",
   "responses": [
    {
     "status": 200,
     "text": "{ current:{ commit, tag, committedAt, deployedAt }, repo, branch, upToDate (true | false | null when the check itself failed), behind, securityBehind, updates[], checkedAt, source, … }"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /compliance/reports/host-patches",
   "group": "compliance",
   "method": "GET",
   "path": "/compliance/reports/host-patches",
   "purpose": "Host OS patch status.",
   "auth": {
    "text": "Session + view_system",
    "kind": "session",
    "permissions": [
     "view_system"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { available:true, os:string|null, manager:string, total:integer, security:integer, phased:integer, packages:array, refreshFailed:boolean, checkedAt:string|null } | 200 { available:false, os:null, manager:'unknown', total:0, security:0, packages:[], checkedAt:null } when the host agent has not reported yet",
   "responses": [
    {
     "status": 200,
     "text": "{ available:true, os:string|null, manager:string, total:integer, security:integer, phased:integer, packages:array, refreshFailed:boolean, checkedAt:string|null }"
    },
    {
     "status": 200,
     "text": "{ available:false, os:null, manager:'unknown', total:0, security:0, packages:[], checkedAt:null } when the host agent has not reported yet"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /compliance/reports/update-status",
   "group": "compliance",
   "method": "GET",
   "path": "/compliance/reports/update-status",
   "purpose": "State of any pending update request.",
   "auth": {
    "text": "Session + view_system",
    "kind": "session",
    "permissions": [
     "view_system"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { status, pending, pendingApproval, backupConfig:{ offboxConfigured, … }, pendingTtlMs } — status is the host agent's last report; pending is a request in flight; pendingApproval is one waiting for an approver",
   "responses": [
    {
     "status": 200,
     "text": "{ status, pending, pendingApproval, backupConfig:{ offboxConfigured, … }, pendingTtlMs } — status is the host agent's last report; pending is a request in flight; pendingApproval is one waiting for an approver"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /compliance/reports/update-request",
   "group": "compliance",
   "method": "POST",
   "path": "/compliance/reports/update-request",
   "purpose": "Request a backup, patch, or OS update.",
   "auth": {
    "text": "Session + request_system_updates",
    "kind": "session",
    "permissions": [
     "request_system_updates"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ mode } required — one of backup|security|full|os-security|os-full",
   "response": "202 { status:'dispatched'|'pending_approval', request } | 400 invalid mode | 409 if one already queued/awaiting approval",
   "responses": [
    {
     "status": 202,
     "text": "{ status:'dispatched'|'pending_approval', request }"
    },
    {
     "status": 400,
     "text": "invalid mode"
    },
    {
     "status": 409,
     "text": "if one already queued/awaiting approval"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "invalid mode"
    },
    {
     "status": 409,
     "text": "if one already queued/awaiting approval"
    }
   ],
   "guard": "admins auto-approve (dispatched immediately); non-admins parked for approval; approver notify is best-effort/non-blocking; not repeatable while one is open: a second request is refused with 409 until the first finishes, is rejected, or expires"
  },
  {
   "id": "POST /compliance/reports/update-approve",
   "group": "compliance",
   "method": "POST",
   "path": "/compliance/reports/update-approve",
   "purpose": "Approve a requested update so the host agent may run it.",
   "auth": {
    "text": "Session + approve_system_updates",
    "kind": "session",
    "permissions": [
     "approve_system_updates"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No body (approver = session user).",
   "response": "200 { approved:true, request } | 404 if none awaiting | 409 if op already in progress",
   "responses": [
    {
     "status": 200,
     "text": "{ approved:true, request }"
    },
    {
     "status": 404,
     "text": "if none awaiting"
    },
    {
     "status": 409,
     "text": "if op already in progress"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "if none awaiting"
    },
    {
     "status": 409,
     "text": "if op already in progress"
    }
   ],
   "guard": "for destructive restore/delete-backup, approver must differ from requester (403); dispatches to host agent; acts on the single pending request, so a repeat answers 404 (nothing awaiting) or 409 (already in progress) and never runs it twice"
  },
  {
   "id": "POST /compliance/reports/update-reject",
   "group": "compliance",
   "method": "POST",
   "path": "/compliance/reports/update-reject",
   "purpose": "Reject a requested update.",
   "auth": {
    "text": "Session + approve_system_updates",
    "kind": "session",
    "permissions": [
     "approve_system_updates"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No body (rejecter = session user).",
   "response": "200 { rejected:true, request } | 404 if none awaiting approval",
   "responses": [
    {
     "status": 200,
     "text": "{ rejected:true, request }"
    },
    {
     "status": 404,
     "text": "if none awaiting approval"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "if none awaiting approval"
    }
   ],
   "guard": "a repeat answers 404 (nothing awaiting approval)"
  },
  {
   "id": "GET /compliance/reports/maintenance-status",
   "group": "compliance",
   "method": "GET",
   "path": "/compliance/reports/maintenance-status",
   "purpose": "State of the app-wide maintenance notice.",
   "auth": {
    "text": "Session",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { state:'none' } | { state:'scheduled', at } (a maintenance window starts within 60 minutes) | { state:'running', kind:'update'|'restore', rollingBack, scheduled }",
   "responses": [
    {
     "status": 200,
     "text": "{ state:'none' } | { state:'scheduled', at } (a maintenance window starts within 60 minutes) | { state:'running', kind:'update'|'restore', rollingBack, scheduled }"
    }
   ],
   "errors": [],
   "guard": "readable by every signed-in user; carries no release tag, host detail or requester; any internal failure reads as state 'none'"
  },
  {
   "id": "GET /compliance/reports/auto-update-config",
   "group": "compliance",
   "method": "GET",
   "path": "/compliance/reports/auto-update-config",
   "purpose": "Read the automatic update schedule.",
   "auth": {
    "text": "Session + view_system | request_system_updates",
    "kind": "session",
    "permissions": [
     "request_system_updates",
     "view_system"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { enabled:boolean, day:'sun'|'mon'|'tue'|'wed'|'thu'|'fri'|'sat', time:string (HH:MM), timezone:string (IANA zone), timezoneSaved:boolean, osSecurity:boolean, osFull:boolean, maxMinutes:integer, appSecurity:boolean, appFull:boolean, smtpConfigured:boolean }",
   "responses": [
    {
     "status": 200,
     "text": "{ enabled:boolean, day:'sun'|'mon'|'tue'|'wed'|'thu'|'fri'|'sat', time:string (HH:MM), timezone:string (IANA zone), timezoneSaved:boolean, osSecurity:boolean, osFull:boolean, maxMinutes:integer, appSecurity:boolean, appFull:boolean, smtpConfigured:boolean }"
    }
   ],
   "errors": [],
   "guard": "a setting never saved reads as enabled:false, appSecurity:false, appFull:false, osSecurity:true, osFull:false, day:'sat', time:'02:00', maxMinutes:60; a new instance is seeded with enabled, appSecurity, appFull and osSecurity on; appFull:true implies appSecurity:true; with no timezone saved, timezone is the instance's regional zone and timezoneSaved is false"
  },
  {
   "id": "POST /compliance/reports/auto-update-config",
   "group": "compliance",
   "method": "POST",
   "path": "/compliance/reports/auto-update-config",
   "purpose": "Save the automatic update schedule: the weekly maintenance window and what it installs.",
   "auth": {
    "text": "Session + request_system_updates",
    "kind": "session",
    "permissions": [
     "request_system_updates"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ enabled:boolean, day:'sun'..'sat', time:string (24-hour HH:MM), timezone:string (IANA zone), osSecurity:boolean, osFull:boolean, appSecurity:boolean, appFull:boolean, maxMinutes:integer } — every field optional; only the fields sent are changed",
   "response": "200 the object the GET answers | 400 { error } for a day, time or timezone the server cannot schedule (nothing is stored) | 400 { error:'Configure email (SMTP) first — automatic updates require it to send reminders.' } when enabled:true is sent while the schedule is off and no email is configured",
   "responses": [
    {
     "status": 200,
     "text": "the object the GET answers"
    },
    {
     "status": 400,
     "text": "{ error } for a day, time or timezone the server cannot schedule (nothing is stored)"
    },
    {
     "status": 400,
     "text": "{ error:'Configure email (SMTP) first — automatic updates require it to send reminders.' } when enabled:true is sent while the schedule is off and no email is configured"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ error } for a day, time or timezone the server cannot schedule (nothing is stored)"
    },
    {
     "status": 400,
     "text": "{ error:'Configure email (SMTP) first — automatic updates require it to send reminders.' } when enabled:true is sent while the schedule is off and no email is configured"
    }
   ],
   "guard": "maxMinutes is held between 30 and 240; an instance that is already on may move its window before email is set up, and the scheduler starts nothing until email works; safe to repeat; audited as AUTO_UPDATE_CONFIGURED"
  },
  {
   "id": "GET /compliance/reports/file-archives",
   "group": "compliance",
   "method": "GET",
   "path": "/compliance/reports/file-archives",
   "purpose": "List file-store archives (signed documents and the signing key, compliance evidence, candidate résumés), newest first.",
   "auth": {
    "text": "Session + view_system",
    "kind": "session",
    "permissions": [
     "view_system"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 [ { filename, sizeBytes, modifiedAt, local, offbox, restorable, fetch, bucket, foundInStorage? } ]",
   "responses": [
    {
     "status": 200,
     "text": "[ { filename, sizeBytes, modifiedAt, local, offbox, restorable, fetch, bucket, foundInStorage? } ]"
    }
   ],
   "errors": [],
   "guard": "bare array; no paging parameters are read"
  },
  {
   "id": "POST /compliance/reports/restore-files-request",
   "group": "compliance",
   "method": "POST",
   "path": "/compliance/reports/restore-files-request",
   "purpose": "Request a restore of the file store from one archive. A different approver must approve it.",
   "auth": {
    "text": "Session + approve_system_updates",
    "kind": "session",
    "permissions": [
     "approve_system_updates"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ target } required — an archive filename matching zonitix_files_*.tar.gz, no path",
   "response": "202 { status:'pending_approval', request } | 400 invalid target | 409 another request is pending or running",
   "responses": [
    {
     "status": 202,
     "text": "{ status:'pending_approval', request }"
    },
    {
     "status": 400,
     "text": "invalid target"
    },
    {
     "status": 409,
     "text": "another request is pending or running"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "invalid target"
    },
    {
     "status": 409,
     "text": "another request is pending or running"
    }
   ],
   "guard": "always parked for a second administrator; audited as FILES_RESTORE_REQUESTED; expires after 30 minutes"
  },
  {
   "id": "POST /compliance/reports/restore-files-approve",
   "group": "compliance",
   "method": "POST",
   "path": "/compliance/reports/restore-files-approve",
   "purpose": "Approve the pending file restore so the host agent may run it.",
   "auth": {
    "text": "Session + approve_system_updates",
    "kind": "session",
    "permissions": [
     "approve_system_updates"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No body (approver = session user).",
   "response": "200 { approved:true, request } | 403 approver is the requester | 404 nothing awaiting approval | 409 the pending request is not a file restore",
   "responses": [
    {
     "status": 200,
     "text": "{ approved:true, request }"
    },
    {
     "status": 403,
     "text": "approver is the requester"
    },
    {
     "status": 404,
     "text": "nothing awaiting approval"
    },
    {
     "status": 409,
     "text": "the pending request is not a file restore"
    }
   ],
   "errors": [
    {
     "status": 403,
     "text": "approver is the requester"
    },
    {
     "status": 404,
     "text": "nothing awaiting approval"
    },
    {
     "status": 409,
     "text": "the pending request is not a file restore"
    }
   ],
   "guard": "audited as FILES_RESTORE_APPROVED; the host agent takes a safety copy of the current files first"
  },
  {
   "id": "GET /compliance/reports/backup-destination",
   "group": "compliance",
   "method": "GET",
   "path": "/compliance/reports/backup-destination",
   "purpose": "Read the configured off-box backup destination.",
   "auth": {
    "text": "Session + view_system",
    "kind": "session",
    "permissions": [
     "view_system"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { enabled, provider, credentialSource:'manual'|'provider', providerId, endpoint, region, bucket, prefix, accessKeyId, hasSecret:boolean, encrypted:boolean, destinationChangedAt, restoreTest:{ status, ageDays, stale, … }, restoreDrill } — with nothing saved: enabled:false, provider:'s3', empty strings",
   "responses": [
    {
     "status": 200,
     "text": "{ enabled, provider, credentialSource:'manual'|'provider', providerId, endpoint, region, bucket, prefix, accessKeyId, hasSecret:boolean, encrypted:boolean, destinationChangedAt, restoreTest:{ status, ageDays, stale, … }, restoreDrill } — with nothing saved: enabled:false, provider:'s3', empty strings"
    }
   ],
   "errors": [],
   "guard": "the secret access key is never returned, only hasSecret; restoreTest.stale is true when the last restore test is more than 14 days old, and status is 'never' when none has run"
  },
  {
   "id": "POST /compliance/reports/backup-destination",
   "group": "compliance",
   "method": "POST",
   "path": "/compliance/reports/backup-destination",
   "purpose": "Configure the backup destination: an S3-compatible bucket or an Azure Blob Storage container that you own.",
   "auth": {
    "text": "Session + approve_system_updates",
    "kind": "session",
    "permissions": [
     "approve_system_updates"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ enabled:bool, provider, credentialSource ('manual'|'provider'), providerId, endpoint, region, bucket, prefix, accessKeyId, secretAccessKey } — all optional but validated when enabled",
   "response": "200 { enabled, provider, credentialSource, providerId, endpoint, region, bucket, prefix, accessKeyId, hasSecret } (secret never returned) | 400 missing bucket/creds when enabled",
   "responses": [
    {
     "status": 200,
     "text": "{ enabled, provider, credentialSource, providerId, endpoint, region, bucket, prefix, accessKeyId, hasSecret } (secret never returned)"
    },
    {
     "status": 400,
     "text": "missing bucket/creds when enabled"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "missing bucket/creds when enabled"
    }
   ],
   "guard": "when enabled: bucket required; manual needs accessKeyId+secretAccessKey; provider source needs providerId; blank secret keeps stored one, switching provider discards it; safe to repeat (it overwrites the one stored destination)"
  },
  {
   "id": "POST /compliance/reports/backup-destination/test",
   "group": "compliance",
   "method": "POST",
   "path": "/compliance/reports/backup-destination/test",
   "purpose": "Verify the backup destination is reachable and writable.",
   "auth": {
    "text": "Session + approve_system_updates",
    "kind": "session",
    "permissions": [
     "approve_system_updates"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Same shape as save (transient, never persisted): provider, credentialSource, providerId, endpoint, region, bucket, prefix, accessKeyId, secretAccessKey",
   "response": "200 { bucket, creds, write, read, prune } per-check {ok,error} results | 400 if bucket missing or provider without providerId",
   "responses": [
    {
     "status": 200,
     "text": "{ bucket, creds, write, read, prune } per-check {ok,error} results"
    },
    {
     "status": 400,
     "text": "if bucket missing or provider without providerId"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "if bucket missing or provider without providerId"
    }
   ],
   "guard": "tests the POSTed values, not stored config; runs a real write/read/prune probe against the bucket"
  },
  {
   "id": "GET /compliance/reports/backup-setup",
   "group": "compliance",
   "method": "GET",
   "path": "/compliance/reports/backup-setup",
   "purpose": "Mandatory backup setup: the gate every screen of the dashboard reads, plus the checklist for people who can see the System screens.",
   "auth": {
    "text": "Session",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { gate:{ phase:'storage'|'escrow'|'complete', mode:'fresh'|'existing'|null, completed:boolean, proven:boolean, graceEndsAt:string|null, escrowDeadlineAt:string|null, locked:boolean, inGrace:boolean, reason:string } } — with view_system or the administrator role also steps:{ destination:{ done, bucket, provider }, encryption:{ done }, firstBackup:{ done, provenAt, file, uploaded, lastVerify }, keys:{ done, items[] }, agentInstalled }, health:{ state:'ok'|'failing'|'unknown', reason, detail, since, lastCheckAt, lastGoodAt }, policy:{ graceDays:30, escrowDays:14 }",
   "responses": [
    {
     "status": 200,
     "text": "{ gate:{ phase:'storage'|'escrow'|'complete', mode:'fresh'|'existing'|null, completed:boolean, proven:boolean, graceEndsAt:string|null, escrowDeadlineAt:string|null, locked:boolean, inGrace:boolean, reason:string } } — with view_system or the administrator role also steps:{ destination:{ done, bucket, provider }, encryption:{ done }, firstBackup:{ done, provenAt, file, uploaded, lastVerify }, keys:{ done, items[] }, agentInstalled }, health:{ state:'ok'|'failing'|'unknown', reason, detail, since, lastCheckAt, lastGoodAt }, policy:{ graceDays:30, escrowDays:14 }"
    }
   ],
   "errors": [],
   "guard": "readable by every signed-in user; reason is one of origin_unknown, complete, storage_required, storage_grace, escrow_required, escrow_pending; with no origin recorded the gate answers locked:false"
  },
  {
   "id": "POST /compliance/reports/backup-setup/verify",
   "group": "compliance",
   "method": "POST",
   "path": "/compliance/reports/backup-setup/verify",
   "purpose": "Check again: read the newest uploaded backup back from the destination, verify it, and advance backup setup if the facts allow it.",
   "auth": {
    "text": "Session + approve_system_updates",
    "kind": "session",
    "permissions": [
     "approve_system_updates"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { result:{ advanced:('proven'|'completed')[], reason:string|null, error? }, gate, steps, health, policy } — the same gate, steps, health and policy the GET answers",
   "responses": [
    {
     "status": 200,
     "text": "{ result:{ advanced:('proven'|'completed')[], reason:string|null, error? }, gate, steps, health, policy } — the same gate, steps, health and policy the GET answers"
    }
   ],
   "errors": [],
   "guard": "result.reason when nothing advanced: no_destination, no_backup_key, no_uploaded_backup, verify_failed (with error), keys_not_escrowed or complete, all with 200; safe to repeat; a first verification is audited as BACKUP_SETUP_PROVEN, a failed read-back as BACKUP_SETUP_VERIFY_FAILED, completion as BACKUP_SETUP_COMPLETED"
  },
  {
   "id": "GET /compliance/reports/backup-providers",
   "group": "compliance",
   "method": "GET",
   "path": "/compliance/reports/backup-providers",
   "purpose": "List already-connected AWS providers reusable as backup credentials.",
   "auth": {
    "text": "Session + approve_system_updates",
    "kind": "session",
    "permissions": [
     "approve_system_updates"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 [ { id, name, region, verified } ] — AWS hooks only",
   "responses": [
    {
     "status": 200,
     "text": "[ { id, name, region, verified } ] — AWS hooks only"
    }
   ],
   "errors": [],
   "guard": "bare array; no paging parameters are read"
  },
  {
   "id": "GET /compliance/devices",
   "group": "compliance",
   "method": "GET",
   "path": "/compliance/devices",
   "purpose": "Compliance view of registered devices.",
   "auth": {
    "text": "Session + view_dashboard | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_dashboard"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 [ Device ] — each with Owner:{ id, email }; devices whose enrolment was explicitly not approved are left out",
   "responses": [
    {
     "status": 200,
     "text": "[ Device ] — each with Owner:{ id, email }; devices whose enrolment was explicitly not approved are left out"
    }
   ],
   "errors": [],
   "guard": "bare array, returns every device (no paging). For a paged fleet list use GET /user-management/devices"
  },
  {
   "id": "POST /compliance/devices",
   "group": "compliance",
   "method": "POST",
   "path": "/compliance/devices",
   "purpose": "Register a device for compliance tracking.",
   "auth": {
    "text": "Session + manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Allowlisted: name (the Device model does not allow it to be empty), type, serialNumber (unique), status (default 'available'), manufacturer, userId, assetTag, department, location, costCenter. Any other key is dropped and the audit entry DEVICE_INVENTORY_CREATED lists it in droppedFields.",
   "response": "201 { created device row }",
   "responses": [
    {
     "status": 201,
     "text": "{ created device row }"
    }
   ],
   "errors": [],
   "guard": "serialNumber has a unique constraint (DB error on duplicate); a repeat with the same serialNumber fails on the unique constraint instead of creating a duplicate, and a repeat without one creates a second device"
  },
  {
   "id": "POST /evidence/upload",
   "group": "compliance",
   "method": "POST",
   "path": "/evidence/upload",
   "purpose": "Upload an evidence file (multipart).",
   "auth": {
    "text": "Session + manage_compliance | view_assigned_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_assigned_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "multipart/form-data, single field 'file' (multer). No JSON body.",
   "response": "200 { attachmentUrl, sha256 } | 400 'No file uploaded' if file missing | 403 a caller holding only view_assigned_compliance who does not own the control | 413 over 25 MB",
   "responses": [
    {
     "status": 200,
     "text": "{ attachmentUrl, sha256 }"
    },
    {
     "status": 400,
     "text": "'No file uploaded' if file missing"
    },
    {
     "status": 403,
     "text": "a caller holding only view_assigned_compliance who does not own the control"
    },
    {
     "status": 413,
     "text": "over 25 MB"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "'No file uploaded' if file missing"
    },
    {
     "status": 403,
     "text": "a caller holding only view_assigned_compliance who does not own the control"
    },
    {
     "status": 413,
     "text": "over 25 MB"
    }
   ],
   "guard": "one file per request; needs the Compliance Hub licence; a caller without manage_compliance must send controlId and framework and may attach only to a control assigned to them; logs COMPLIANCE_EVIDENCE_UPLOADED (SOC2 CC7.2) with uploader identity"
  },
  {
   "id": "GET /evidence/files/:filename",
   "group": "compliance",
   "method": "GET",
   "path": "/evidence/files/:filename",
   "purpose": "Download a stored evidence file.",
   "auth": {
    "text": "Session + manage_compliance | view_assigned_compliance | view_reporting",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_assigned_compliance",
     "view_reporting"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "filename",
     "type": "string",
     "basis": "unknown"
    }
   ],
   "paging": [],
   "query": [],
   "request": "Path: :filename — the stored name returned by the upload, with no path separators",
   "response": "200 the file as a download | 400 'Invalid filename' (plain text) when the name contains .., / or \\ | 404 'File not found' (plain text)",
   "responses": [
    {
     "status": 200,
     "text": "the file as a download"
    },
    {
     "status": 400,
     "text": "'Invalid filename' (plain text) when the name contains .., / or \\"
    },
    {
     "status": 404,
     "text": "'File not found' (plain text)"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "'Invalid filename' (plain text) when the name contains .., / or \\"
    },
    {
     "status": 404,
     "text": "'File not found' (plain text)"
    }
   ],
   "guard": "not licence-gated, so evidence uploaded while licensed stays readable; every download is audit-logged as COMPLIANCE_EVIDENCE_DOWNLOADED"
  },
  {
   "id": "POST /auth/security-lab/launch",
   "group": "security-lab",
   "method": "POST",
   "path": "/auth/security-lab/launch",
   "purpose": "Launch a probe run.",
   "auth": {
    "text": "Session + run_security_scan | manage_compliance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "run_security_scan"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ attackType } required, targetUrl? optional. attackType selects the probe; targetUrl null/omitted = internal (local) probe, present = remote target.",
   "response": "200 { ok:true, result } | probe result shape from launchAttack. Writes SECURITY_PROBE_LAUNCHED audit log.",
   "responses": [
    {
     "status": 200,
     "text": "{ ok:true, result } | probe result shape from launchAttack. Writes SECURITY_PROBE_LAUNCHED audit log."
    }
   ],
   "errors": [],
   "guard": "canRun perm (run_security_scan OR manage_compliance) + requireLicense('securityLabEnabled')"
  },
  {
   "id": "GET /auth/security-lab/results",
   "group": "security-lab",
   "method": "GET",
   "path": "/auth/security-lab/results",
   "purpose": "Latest probe results (compliant vs at-risk).",
   "auth": {
    "text": "Session + view_security_findings | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_security_findings"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters (the gateway forwards none).",
   "response": "200 { findings:array (each a SecurityFinding plus its own riskScore), riskScore:number|null (null = nothing to score, not 'secure'), lastScanAt:string (date-time)|null, lastScanBy:string|null, total:integer (the length of findings) } — archived findings excluded, most recently seen first",
   "responses": [
    {
     "status": 200,
     "text": "{ findings:array (each a SecurityFinding plus its own riskScore), riskScore:number|null (null = nothing to score, not 'secure'), lastScanAt:string (date-time)|null, lastScanBy:string|null, total:integer (the length of findings) } — archived findings excluded, most recently seen first"
    }
   ],
   "errors": [],
   "guard": "Returns every open finding (no paging)"
  },
  {
   "id": "GET /auth/security-lab/assets",
   "group": "security-lab",
   "method": "GET",
   "path": "/auth/security-lab/assets",
   "purpose": "Discovered assets in scope.",
   "auth": {
    "text": "Session + view_security_findings | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_security_findings"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters (the gateway forwards none).",
   "response": "200 [ asset ]",
   "responses": [
    {
     "status": 200,
     "text": "[ asset ]"
    }
   ],
   "errors": [],
   "guard": "bare array, every asset in one reply (no paging), ordered by type then name"
  },
  {
   "id": "GET /auth/security-lab/scopes",
   "group": "security-lab",
   "method": "GET",
   "path": "/auth/security-lab/scopes",
   "purpose": "List authorised scan scopes.",
   "auth": {
    "text": "Session + view_security_findings | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_security_findings"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters (the gateway forwards none).",
   "response": "200 [ SecurityScope ] — the stored scope rows (name, description, allowedHosts, allowedCidrs, maxProfile, ownerAttestation, enabled, …) with any credential inside testEndpoints masked",
   "responses": [
    {
     "status": 200,
     "text": "[ SecurityScope ] — the stored scope rows (name, description, allowedHosts, allowedCidrs, maxProfile, ownerAttestation, enabled, …) with any credential inside testEndpoints masked"
    }
   ],
   "errors": [],
   "guard": "bare array, every scope in one reply (no paging), newest first"
  },
  {
   "id": "POST /auth/security-lab/scopes",
   "group": "security-lab",
   "method": "POST",
   "path": "/auth/security-lab/scopes",
   "purpose": "Define a scan scope you are authorised to test.",
   "auth": {
    "text": "Session + manage_security_scopes | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "manage_security_scopes"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ name } required; description?, allowedHosts?:string[], allowedCidrs?:string[], maxProfile?='standard', ownerAttestation?:bool=false, enabled?:bool=true.",
   "response": "201 SecurityScope object | 400 {error} if name missing. Writes SECURITY_SCOPE_CREATED log.",
   "responses": [
    {
     "status": 201,
     "text": "SecurityScope object"
    },
    {
     "status": 400,
     "text": "{error} if name missing. Writes SECURITY_SCOPE_CREATED log."
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{error} if name missing. Writes SECURITY_SCOPE_CREATED log."
    }
   ],
   "guard": "canManageScopes perm (manage_security_scopes OR manage_compliance)"
  },
  {
   "id": "POST /auth/security-lab/scans",
   "group": "security-lab",
   "method": "POST",
   "path": "/auth/security-lab/scans",
   "purpose": "Create a scan.",
   "auth": {
    "text": "Session + run_security_scan | manage_compliance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "run_security_scan"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ suite?='full', profile?='safe'|'standard'|'aggressive' (default standard), targetMode?='local'|'remote', targetUrl? (required when remote, ignored otherwise), attestation?: true, targetAuth? (remote only; used for that scan), allowInsecureTLS? (remote only; audit-logged) }",
   "response": "202 SecurityScan | 400 if remote w/o targetUrl | 401 SUDO_REQUIRED if profile=aggressive w/o a fresh step-up | 403 NOT_AUTHORIZED_TARGET | 403 SCOPE_PROFILE_EXCEEDED",
   "responses": [
    {
     "status": 202,
     "text": "SecurityScan"
    },
    {
     "status": 400,
     "text": "if remote w/o targetUrl"
    },
    {
     "status": 401,
     "text": "SUDO_REQUIRED if profile=aggressive w/o a fresh step-up"
    },
    {
     "status": 403,
     "text": "NOT_AUTHORIZED_TARGET"
    },
    {
     "status": 403,
     "text": "SCOPE_PROFILE_EXCEEDED"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "if remote w/o targetUrl"
    },
    {
     "status": 401,
     "text": "SUDO_REQUIRED if profile=aggressive w/o a fresh step-up"
    },
    {
     "status": 403,
     "text": "NOT_AUTHORIZED_TARGET"
    },
    {
     "status": 403,
     "text": "SCOPE_PROFILE_EXCEEDED"
    }
   ],
   "guard": "canRun + requireLicense('securityLabEnabled'). The target guard fails CLOSED: a remote host outside an enabled, ownership-verified scope is refused (403 NOT_AUTHORIZED_TARGET) unless the body carries attestation:true, which clamps the scan to the safe profile and is logged as SECURITY_REMOTE_ATTESTATION. A profile above the scope's maxProfile is 403 SCOPE_PROFILE_EXCEEDED. Aggressive needs a fresh step-up, local or remote."
  },
  {
   "id": "GET /auth/security-lab/scans",
   "group": "security-lab",
   "method": "GET",
   "path": "/auth/security-lab/scans",
   "purpose": "List scans.",
   "auth": {
    "text": "Session + view_security_findings | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_security_findings"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters (the gateway forwards none).",
   "response": "200 [ SecurityScan ] — the 50 most recent, newest first",
   "responses": [
    {
     "status": 200,
     "text": "[ SecurityScan ] — the 50 most recent, newest first"
    }
   ],
   "errors": [],
   "guard": "Fixed at 50 rows, there is no paging parameter"
  },
  {
   "id": "GET /auth/security-lab/scans/:id",
   "group": "security-lab",
   "method": "GET",
   "path": "/auth/security-lab/scans/:id",
   "purpose": "Scan detail and live status.",
   "auth": {
    "text": "Session + view_security_findings | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_security_findings"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "string",
     "format": "uuid",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { scan, findings[], findingsSource:'live'|'snapshot' } — 'snapshot' when the frozen copy taken for the report holds more findings than the live table | 404 'Scan not found'",
   "responses": [
    {
     "status": 200,
     "text": "{ scan, findings[], findingsSource:'live'|'snapshot' } — 'snapshot' when the frozen copy taken for the report holds more findings than the live table"
    },
    {
     "status": 404,
     "text": "'Scan not found'"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'Scan not found'"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /auth/security-lab/scans/:id/report",
   "group": "security-lab",
   "method": "GET",
   "path": "/auth/security-lab/scans/:id/report",
   "purpose": "Export a scan report.",
   "auth": {
    "text": "Session + export_security_report | view_security_findings | manage_compliance",
    "kind": "session",
    "permissions": [
     "export_security_report",
     "manage_compliance",
     "view_security_findings"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "string",
     "format": "uuid",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "Query: ?format= json (default) | markdown (or md) | sarif | csv",
   "response": "200 the report as JSON, Markdown (text/markdown), SARIF (JSON) or CSV (attachment zonitix-scan-<id>.csv) | 404 'Scan not found'",
   "responses": [
    {
     "status": 200,
     "text": "the report as JSON, Markdown (text/markdown), SARIF (JSON) or CSV (attachment zonitix-scan-<id>.csv)"
    },
    {
     "status": 404,
     "text": "'Scan not found'"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'Scan not found'"
    }
   ],
   "guard": "The report is built from a snapshot of the scan's findings, taken on first request"
  },
  {
   "id": "POST /auth/security-lab/scans/:id/abort",
   "group": "security-lab",
   "method": "POST",
   "path": "/auth/security-lab/scans/:id/abort",
   "purpose": "Abort a running scan.",
   "auth": {
    "text": "Session + run_security_scan | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "run_security_scan"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "string",
     "format": "uuid",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body. Scan id in path.",
   "response": "200 updated SecurityScan | 404 if scan not found. Writes SECURITY_SCAN_ABORTED log (warning).",
   "responses": [
    {
     "status": 200,
     "text": "updated SecurityScan"
    },
    {
     "status": 404,
     "text": "if scan not found. Writes SECURITY_SCAN_ABORTED log (warning)."
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "if scan not found. Writes SECURITY_SCAN_ABORTED log (warning)."
    }
   ],
   "guard": "canRun perm (run_security_scan OR manage_compliance)."
  },
  {
   "id": "PATCH /auth/security-lab/findings/:id",
   "group": "security-lab",
   "method": "PATCH",
   "path": "/auth/security-lab/findings/:id",
   "purpose": "Update a finding status (triage, accept risk, close).",
   "auth": {
    "text": "Session + manage_security_findings | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "manage_security_findings"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "string",
     "format": "uuid",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "All optional: status (open|acknowledged|in_remediation|retest_pending|risk_accepted|false_positive), statusNote, owner, ticketRef. Only supplied fields update.",
   "response": "200 updated SecurityFinding | 404 not found | 400 invalid status | 400 if risk_accepted/false_positive without a statusNote justification | for risk_accepted also 400 EXPIRY_REQUIRED without acceptedUntil (a future date) and 409 EXCEPTION_EXISTS if an active or pending exception already exists.",
   "responses": [
    {
     "status": 200,
     "text": "updated SecurityFinding"
    },
    {
     "status": 404,
     "text": "not found"
    },
    {
     "status": 400,
     "text": "invalid status"
    },
    {
     "status": 400,
     "text": "if risk_accepted/false_positive without a statusNote justification | for risk_accepted also 400 EXPIRY_REQUIRED without acceptedUntil (a future date) and 409 EXCEPTION_EXISTS if an active or pending exception already exists."
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "not found"
    },
    {
     "status": 400,
     "text": "invalid status"
    },
    {
     "status": 400,
     "text": "if risk_accepted/false_positive without a statusNote justification | for risk_accepted also 400 EXPIRY_REQUIRED without acceptedUntil (a future date) and 409 EXCEPTION_EXISTS if an active or pending exception already exists."
    }
   ],
   "guard": "risk_accepted is written through the exception register: every acceptance expires, and for Critical and High findings it is created pending and the finding stays open until a different person approves. canManageFindings perm (manage_security_findings OR manage_compliance). 'verified_fixed' is NOT settable here — only a clean re-scan reaches it."
  },
  {
   "id": "POST /auth/security-lab/findings/:id/retest",
   "group": "security-lab",
   "method": "POST",
   "path": "/auth/security-lab/findings/:id/retest",
   "purpose": "Retest a single finding.",
   "auth": {
    "text": "Session + run_security_scan | manage_compliance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "run_security_scan"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "string",
     "format": "uuid",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body. Finding id in path; probe+target derived from the stored finding.",
   "response": "200 { result, finding } (re-fetched finding, may become verified_fixed on clean result) | 404 if finding not found.",
   "responses": [
    {
     "status": 200,
     "text": "{ result, finding } (re-fetched finding, may become verified_fixed on clean result)"
    },
    {
     "status": 404,
     "text": "if finding not found."
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "if finding not found."
    }
   ],
   "guard": "canRun perm (run_security_scan OR manage_compliance) + requireLicense('securityLabEnabled')"
  },
  {
   "id": "GET /auth/aws-resources/:providerId",
   "group": "cloud",
   "method": "GET",
   "path": "/auth/aws-resources/:providerId",
   "purpose": "Current AWS inventory and topology for a connected account.",
   "auth": {
    "text": "Session + view_cloud_infrastructure (licensed)",
    "kind": "session",
    "permissions": [
     "view_cloud_infrastructure"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "providerId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "Query: ?refresh=true forces a fresh discovery; otherwise the stored map is returned when one exists",
   "response": "200 the resource map for that AWS hook — the stored copy (a CloudResourceMap, typed under Response Objects: { providerId, nodes, edges, billingData, lastUpdated }), or the result of a new discovery when none is stored or refresh is set",
   "responses": [
    {
     "status": 200,
     "text": "the resource map for that AWS hook — the stored copy (a CloudResourceMap, typed under Response Objects: { providerId, nodes, edges, billingData, lastUpdated }), or the result of a new discovery when none is stored or refresh is set"
    }
   ],
   "errors": [],
   "guard": "a refresh calls the cloud provider and can take long; the gateway treats this as a long-running request"
  },
  {
   "id": "GET /auth/aws-snapshots/:providerId",
   "group": "cloud",
   "method": "GET",
   "path": "/auth/aws-snapshots/:providerId",
   "purpose": "List AWS discovery snapshots.",
   "auth": {
    "text": "Session + view_cloud_infrastructure (licensed)",
    "kind": "session",
    "permissions": [
     "view_cloud_infrastructure"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "providerId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 [ CloudSnapshot cut down to { id, snapshotDate, metadata } ] for that AWS hook, newest first — the captured inventory is not included; fetch one snapshot for it",
   "responses": [
    {
     "status": 200,
     "text": "[ CloudSnapshot cut down to { id, snapshotDate, metadata } ] for that AWS hook, newest first — the captured inventory is not included; fetch one snapshot for it"
    }
   ],
   "errors": [],
   "guard": "bare array, returns every snapshot (no paging)"
  },
  {
   "id": "GET /auth/aws-snapshot-detail/:id",
   "group": "cloud",
   "method": "GET",
   "path": "/auth/aws-snapshot-detail/:id",
   "purpose": "One AWS snapshot in full.",
   "auth": {
    "text": "Session + view_cloud_infrastructure (licensed)",
    "kind": "session",
    "permissions": [
     "view_cloud_infrastructure"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "string",
     "basis": "unknown"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 the CloudSnapshot (typed under Response Objects) in full: { id, providerId, snapshotDate, metadata, nodes, edges, billingData } | 404 'Snapshot not found'",
   "responses": [
    {
     "status": 200,
     "text": "the CloudSnapshot (typed under Response Objects) in full: { id, providerId, snapshotDate, metadata, nodes, edges, billingData }"
    },
    {
     "status": 404,
     "text": "'Snapshot not found'"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'Snapshot not found'"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /auth/aws-changes/:providerId",
   "group": "cloud",
   "method": "GET",
   "path": "/auth/aws-changes/:providerId",
   "purpose": "What changed in the AWS inventory between two discovery snapshots.",
   "auth": {
    "text": "Session + view_cloud_infrastructure (licensed)",
    "kind": "session",
    "permissions": [
     "view_cloud_infrastructure"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "providerId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "Query: ?a= and ?b= (two snapshot ids of this hook) compare those two; with neither, the current map is compared with the snapshot before it",
   "response": "200 { available:true, since, until, compared?:true (present when ?a and ?b were used), …the difference between the two } | 200 { available:false, reason } when there is no current map, no earlier snapshot, or a named snapshot no longer exists | 400 'Snapshots belong to a different provider.'",
   "responses": [
    {
     "status": 200,
     "text": "{ available:true, since, until, compared?:true (present when ?a and ?b were used), …the difference between the two }"
    },
    {
     "status": 200,
     "text": "{ available:false, reason } when there is no current map, no earlier snapshot, or a named snapshot no longer exists"
    },
    {
     "status": 400,
     "text": "'Snapshots belong to a different provider.'"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "'Snapshots belong to a different provider.'"
    }
   ],
   "guard": "read-only; one object, not a list"
  },
  {
   "id": "GET /auth/aws-identity-findings/:providerId",
   "group": "cloud",
   "method": "GET",
   "path": "/auth/aws-identity-findings/:providerId",
   "purpose": "Identity-fusion findings (IAM principals cross-referenced with your employee directory).",
   "auth": {
    "text": "Session + view_cloud_infrastructure (licensed)",
    "kind": "session",
    "permissions": [
     "view_cloud_infrastructure"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "providerId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { providerId, cloud, scannedAt, totalPrincipals, matchedEmployees, summary, findings[], incomplete:boolean, … } — incomplete is true when part of the directory could not be read, so the result must not be taken as a clean bill | 404 code PROVIDER_NOT_FOUND",
   "responses": [
    {
     "status": 200,
     "text": "{ providerId, cloud, scannedAt, totalPrincipals, matchedEmployees, summary, findings[], incomplete:boolean, … } — incomplete is true when part of the directory could not be read, so the result must not be taken as a clean bill"
    },
    {
     "status": 404,
     "text": "code PROVIDER_NOT_FOUND"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "code PROVIDER_NOT_FOUND"
    }
   ],
   "guard": "computed on request against the cloud provider (long-running); nothing is stored by this call"
  },
  {
   "id": "POST /auth/aws-identity-remediate/:providerId",
   "group": "cloud",
   "method": "POST",
   "path": "/auth/aws-identity-remediate/:providerId",
   "purpose": "Execute a governed remediation for an AWS identity finding.",
   "auth": {
    "text": "Session + manage_compliance | manage_security + sudo",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "manage_security"
    ],
    "stepUp": "required",
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "providerId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "{ finding: { remediable:boolean, keyIds:string[], principal:string } } required — the finding object from the identity-findings GET (req.body.finding)",
   "response": "200 dry-run(default) { ok:true, dryRun:true, planned[], message } | 200 executed { ok:true, dryRun:false, executed[], message } | 404 provider not found | 400 if finding not remediable / no keyIds",
   "responses": [
    {
     "status": 200,
     "text": "dry-run(default) { ok:true, dryRun:true, planned[], message }"
    },
    {
     "status": 200,
     "text": "executed { ok:true, dryRun:false, executed[], message }"
    },
    {
     "status": 404,
     "text": "provider not found"
    },
    {
     "status": 400,
     "text": "if finding not remediable / no keyIds"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "provider not found"
    },
    {
     "status": 400,
     "text": "if finding not remediable / no keyIds"
    }
   ],
   "guard": "manage_compliance OR manage_security + sudo + license awsMappingEnabled; DRY RUN by default — only mutates AWS keys when REMEDIATION_DRY_RUN=false"
  },
  {
   "id": "POST /auth/aws-resources/:providerId/sync-invoices",
   "group": "cloud",
   "method": "POST",
   "path": "/auth/aws-resources/:providerId/sync-invoices",
   "purpose": "Pull AWS cost/billing data into the finance ledger.",
   "auth": {
    "text": "Session + manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "providerId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body. providerId in path.",
   "response": "200 { message, ingested:number } | 200 { message:'No invoices found...', ingested:0 } | 500 'Invalid AWS Provider' or 'AWS Permission Denied: ...invoicing:ListInvoiceSummaries...'",
   "responses": [
    {
     "status": 200,
     "text": "{ message, ingested:number }"
    },
    {
     "status": 200,
     "text": "{ message:'No invoices found...', ingested:0 }"
    },
    {
     "status": 500,
     "text": "'Invalid AWS Provider' or 'AWS Permission Denied: ...invoicing:ListInvoiceSummaries...'"
    }
   ],
   "errors": [
    {
     "status": 500,
     "text": "'Invalid AWS Provider' or 'AWS Permission Denied: ...invoicing:ListInvoiceSummaries...'"
    }
   ],
   "guard": "manage_compliance. Parser hand-off best-effort: per-invoice ingest errors are logged, not surfaced"
  },
  {
   "id": "GET /auth/azure-resources/:providerId",
   "group": "cloud",
   "method": "GET",
   "path": "/auth/azure-resources/:providerId",
   "purpose": "Current Azure inventory and topology.",
   "auth": {
    "text": "Session + view_cloud_infrastructure (licensed)",
    "kind": "session",
    "permissions": [
     "view_cloud_infrastructure"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "providerId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "Query: ?refresh=true forces a fresh discovery; otherwise the stored map is returned when one exists",
   "response": "200 the resource map for that Azure hook — the stored copy (a CloudResourceMap, typed under Response Objects: { providerId, nodes, edges, billingData, lastUpdated }), or the result of a new discovery when none is stored or refresh is set",
   "responses": [
    {
     "status": 200,
     "text": "the resource map for that Azure hook — the stored copy (a CloudResourceMap, typed under Response Objects: { providerId, nodes, edges, billingData, lastUpdated }), or the result of a new discovery when none is stored or refresh is set"
    }
   ],
   "errors": [],
   "guard": "a refresh calls the cloud provider and can take long; the gateway treats this as a long-running request"
  },
  {
   "id": "GET /auth/azure-snapshots/:providerId",
   "group": "cloud",
   "method": "GET",
   "path": "/auth/azure-snapshots/:providerId",
   "purpose": "List Azure discovery snapshots.",
   "auth": {
    "text": "Session + view_cloud_infrastructure (licensed)",
    "kind": "session",
    "permissions": [
     "view_cloud_infrastructure"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "providerId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 [ CloudSnapshot cut down to { id, snapshotDate, metadata } ] for that Azure hook, newest first — the captured inventory is not included; fetch one snapshot for it",
   "responses": [
    {
     "status": 200,
     "text": "[ CloudSnapshot cut down to { id, snapshotDate, metadata } ] for that Azure hook, newest first — the captured inventory is not included; fetch one snapshot for it"
    }
   ],
   "errors": [],
   "guard": "bare array, returns every snapshot (no paging)"
  },
  {
   "id": "GET /auth/azure-snapshot-detail/:id",
   "group": "cloud",
   "method": "GET",
   "path": "/auth/azure-snapshot-detail/:id",
   "purpose": "One Azure snapshot in full.",
   "auth": {
    "text": "Session + view_cloud_infrastructure (licensed)",
    "kind": "session",
    "permissions": [
     "view_cloud_infrastructure"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "string",
     "basis": "unknown"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 the CloudSnapshot (typed under Response Objects) in full: { id, providerId, snapshotDate, metadata, nodes, edges, billingData } | 404 'Snapshot not found'",
   "responses": [
    {
     "status": 200,
     "text": "the CloudSnapshot (typed under Response Objects) in full: { id, providerId, snapshotDate, metadata, nodes, edges, billingData }"
    },
    {
     "status": 404,
     "text": "'Snapshot not found'"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'Snapshot not found'"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /auth/azure-changes/:providerId",
   "group": "cloud",
   "method": "GET",
   "path": "/auth/azure-changes/:providerId",
   "purpose": "What changed in the Azure inventory between two discovery snapshots.",
   "auth": {
    "text": "Session + view_cloud_infrastructure (licensed)",
    "kind": "session",
    "permissions": [
     "view_cloud_infrastructure"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "providerId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "Query: ?a= and ?b= (two snapshot ids of this hook) compare those two; with neither, the current map is compared with the snapshot before it",
   "response": "200 { available:true, since, until, compared?:true (present when ?a and ?b were used), …the difference between the two } | 200 { available:false, reason } when there is no current map, no earlier snapshot, or a named snapshot no longer exists | 400 'Snapshots belong to a different provider.'",
   "responses": [
    {
     "status": 200,
     "text": "{ available:true, since, until, compared?:true (present when ?a and ?b were used), …the difference between the two }"
    },
    {
     "status": 200,
     "text": "{ available:false, reason } when there is no current map, no earlier snapshot, or a named snapshot no longer exists"
    },
    {
     "status": 400,
     "text": "'Snapshots belong to a different provider.'"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "'Snapshots belong to a different provider.'"
    }
   ],
   "guard": "read-only; one object, not a list"
  },
  {
   "id": "GET /auth/azure-identity-findings/:providerId",
   "group": "cloud",
   "method": "GET",
   "path": "/auth/azure-identity-findings/:providerId",
   "purpose": "Azure/Entra ID identity findings.",
   "auth": {
    "text": "Session + view_cloud_infrastructure (licensed)",
    "kind": "session",
    "permissions": [
     "view_cloud_infrastructure"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "providerId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { providerId, cloud, scannedAt, totalPrincipals, matchedEmployees, summary, findings[], incomplete:boolean, … } — incomplete is true when part of the directory could not be read, so the result must not be taken as a clean bill | 404 code PROVIDER_NOT_FOUND",
   "responses": [
    {
     "status": 200,
     "text": "{ providerId, cloud, scannedAt, totalPrincipals, matchedEmployees, summary, findings[], incomplete:boolean, … } — incomplete is true when part of the directory could not be read, so the result must not be taken as a clean bill"
    },
    {
     "status": 404,
     "text": "code PROVIDER_NOT_FOUND"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "code PROVIDER_NOT_FOUND"
    }
   ],
   "guard": "computed on request against the cloud provider (long-running); nothing is stored by this call"
  },
  {
   "id": "POST /auth/azure-identity-remediate/:providerId",
   "group": "cloud",
   "method": "POST",
   "path": "/auth/azure-identity-remediate/:providerId",
   "purpose": "Execute a governed remediation for an Azure identity finding.",
   "auth": {
    "text": "Session + manage_compliance | manage_security + sudo",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "manage_security"
    ],
    "stepUp": "required",
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "providerId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "{ finding: { remediable:boolean, keyIds:string[], principal:string } } required — same shared cloud-agnostic handler as AWS (req.body.finding)",
   "response": "200 { ok:false, automated:false, planned[], message:'Automated remediation for AZURE_AD isnt available yet...' } — only AWS is automated | 404 provider not found | 400 if finding not remediable / no keyIds",
   "responses": [
    {
     "status": 200,
     "text": "{ ok:false, automated:false, planned[], message:'Automated remediation for AZURE_AD isnt available yet...' } — only AWS is automated"
    },
    {
     "status": 404,
     "text": "provider not found"
    },
    {
     "status": 400,
     "text": "if finding not remediable / no keyIds"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "provider not found"
    },
    {
     "status": 400,
     "text": "if finding not remediable / no keyIds"
    }
   ],
   "guard": "manage_compliance OR manage_security + sudo + license awsMappingEnabled. Azure path always returns honest not-automated (no cloud mutation)"
  },
  {
   "id": "POST /auth/azure-resources/:providerId/sync-invoices",
   "group": "cloud",
   "method": "POST",
   "path": "/auth/azure-resources/:providerId/sync-invoices",
   "purpose": "Pull Azure cost data into the finance ledger.",
   "auth": {
    "text": "Session + manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "providerId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body. providerId in path.",
   "response": "200 { message, ingested:number, totalCost:number, currency, subscriptionId, periodFrom, periodTo } | 500 'Invalid Azure Provider' / 'No Azure Subscription ID...' / 'Azure Cost Management query failed...'",
   "responses": [
    {
     "status": 200,
     "text": "{ message, ingested:number, totalCost:number, currency, subscriptionId, periodFrom, periodTo }"
    },
    {
     "status": 500,
     "text": "'Invalid Azure Provider' / 'No Azure Subscription ID...' / 'Azure Cost Management query failed...'"
    }
   ],
   "errors": [
    {
     "status": 500,
     "text": "'Invalid Azure Provider' / 'No Azure Subscription ID...' / 'Azure Cost Management query failed...'"
    }
   ],
   "guard": "manage_compliance. Requires azureSubscriptionId on provider config and Cost Management Reader role"
  },
  {
   "id": "GET /auth/gcp-resources/:providerId",
   "group": "cloud",
   "method": "GET",
   "path": "/auth/gcp-resources/:providerId",
   "purpose": "Current GCP inventory and topology.",
   "auth": {
    "text": "Session + view_cloud_infrastructure (licensed)",
    "kind": "session",
    "permissions": [
     "view_cloud_infrastructure"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "providerId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "Query: ?refresh=true forces a fresh discovery; otherwise the stored map is returned when one exists",
   "response": "200 the resource map for that GCP hook — the stored copy (a CloudResourceMap, typed under Response Objects: { providerId, nodes, edges, billingData, lastUpdated }), or the result of a new discovery when none is stored or refresh is set",
   "responses": [
    {
     "status": 200,
     "text": "the resource map for that GCP hook — the stored copy (a CloudResourceMap, typed under Response Objects: { providerId, nodes, edges, billingData, lastUpdated }), or the result of a new discovery when none is stored or refresh is set"
    }
   ],
   "errors": [],
   "guard": "a refresh calls the cloud provider and can take long; the gateway treats this as a long-running request"
  },
  {
   "id": "GET /auth/gcp-snapshots/:providerId",
   "group": "cloud",
   "method": "GET",
   "path": "/auth/gcp-snapshots/:providerId",
   "purpose": "List GCP discovery snapshots.",
   "auth": {
    "text": "Session + view_cloud_infrastructure (licensed)",
    "kind": "session",
    "permissions": [
     "view_cloud_infrastructure"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "providerId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 [ CloudSnapshot cut down to { id, snapshotDate, metadata } ] for that GCP hook, newest first — the captured inventory is not included; fetch one snapshot for it",
   "responses": [
    {
     "status": 200,
     "text": "[ CloudSnapshot cut down to { id, snapshotDate, metadata } ] for that GCP hook, newest first — the captured inventory is not included; fetch one snapshot for it"
    }
   ],
   "errors": [],
   "guard": "bare array, returns every snapshot (no paging)"
  },
  {
   "id": "GET /auth/gcp-snapshot-detail/:id",
   "group": "cloud",
   "method": "GET",
   "path": "/auth/gcp-snapshot-detail/:id",
   "purpose": "One GCP snapshot in full.",
   "auth": {
    "text": "Session + view_cloud_infrastructure (licensed)",
    "kind": "session",
    "permissions": [
     "view_cloud_infrastructure"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "string",
     "basis": "unknown"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 the CloudSnapshot (typed under Response Objects) in full: { id, providerId, snapshotDate, metadata, nodes, edges, billingData } | 404 'Snapshot not found'",
   "responses": [
    {
     "status": 200,
     "text": "the CloudSnapshot (typed under Response Objects) in full: { id, providerId, snapshotDate, metadata, nodes, edges, billingData }"
    },
    {
     "status": 404,
     "text": "'Snapshot not found'"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'Snapshot not found'"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /auth/gcp-changes/:providerId",
   "group": "cloud",
   "method": "GET",
   "path": "/auth/gcp-changes/:providerId",
   "purpose": "What changed in the GCP inventory between two discovery snapshots.",
   "auth": {
    "text": "Session + view_cloud_infrastructure (licensed)",
    "kind": "session",
    "permissions": [
     "view_cloud_infrastructure"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "providerId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "Query: ?a= and ?b= (two snapshot ids of this hook) compare those two; with neither, the current map is compared with the snapshot before it",
   "response": "200 { available:true, since, until, compared?:true (present when ?a and ?b were used), …the difference between the two } | 200 { available:false, reason } when there is no current map, no earlier snapshot, or a named snapshot no longer exists | 400 'Snapshots belong to a different provider.'",
   "responses": [
    {
     "status": 200,
     "text": "{ available:true, since, until, compared?:true (present when ?a and ?b were used), …the difference between the two }"
    },
    {
     "status": 200,
     "text": "{ available:false, reason } when there is no current map, no earlier snapshot, or a named snapshot no longer exists"
    },
    {
     "status": 400,
     "text": "'Snapshots belong to a different provider.'"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "'Snapshots belong to a different provider.'"
    }
   ],
   "guard": "read-only; one object, not a list"
  },
  {
   "id": "GET /auth/gcp-identity-findings/:providerId",
   "group": "cloud",
   "method": "GET",
   "path": "/auth/gcp-identity-findings/:providerId",
   "purpose": "GCP identity findings.",
   "auth": {
    "text": "Session + view_cloud_infrastructure (licensed)",
    "kind": "session",
    "permissions": [
     "view_cloud_infrastructure"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "providerId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { providerId, cloud, scannedAt, totalPrincipals, matchedEmployees, summary, findings[], incomplete:boolean, … } — incomplete is true when part of the directory could not be read, so the result must not be taken as a clean bill | 404 code PROVIDER_NOT_FOUND",
   "responses": [
    {
     "status": 200,
     "text": "{ providerId, cloud, scannedAt, totalPrincipals, matchedEmployees, summary, findings[], incomplete:boolean, … } — incomplete is true when part of the directory could not be read, so the result must not be taken as a clean bill"
    },
    {
     "status": 404,
     "text": "code PROVIDER_NOT_FOUND"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "code PROVIDER_NOT_FOUND"
    }
   ],
   "guard": "computed on request against the cloud provider (long-running); nothing is stored by this call"
  },
  {
   "id": "POST /auth/gcp-identity-remediate/:providerId",
   "group": "cloud",
   "method": "POST",
   "path": "/auth/gcp-identity-remediate/:providerId",
   "purpose": "Execute a governed remediation for a GCP identity finding.",
   "auth": {
    "text": "Session + manage_compliance | manage_security + sudo",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "manage_security"
    ],
    "stepUp": "required",
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "providerId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "{ finding: { remediable:boolean, keyIds:string[], principal:string } } required — same shared cloud-agnostic handler as AWS (req.body.finding)",
   "response": "200 { ok:false, automated:false, planned[], message:'Automated remediation for GCP isnt available yet...' } — only AWS automated | 404 provider not found | 400 if finding not remediable / no keyIds",
   "responses": [
    {
     "status": 200,
     "text": "{ ok:false, automated:false, planned[], message:'Automated remediation for GCP isnt available yet...' } — only AWS automated"
    },
    {
     "status": 404,
     "text": "provider not found"
    },
    {
     "status": 400,
     "text": "if finding not remediable / no keyIds"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "provider not found"
    },
    {
     "status": 400,
     "text": "if finding not remediable / no keyIds"
    }
   ],
   "guard": "manage_compliance OR manage_security + sudo + license awsMappingEnabled. GCP path always returns honest not-automated (no cloud mutation)"
  },
  {
   "id": "GET /recruitment/job-postings",
   "group": "recruitment",
   "method": "GET",
   "path": "/recruitment/job-postings",
   "purpose": "List job postings.",
   "auth": {
    "text": "Session + manage_recruitment | view_recruitment | manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance",
     "manage_recruitment",
     "view_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters are read: the list is not paged, filtered or sorted by the caller.",
   "response": "200 array of JobPosting — a bare array with no envelope. Every caller the route admits gets every requisition; the Candidates preview holds only candidates in that caller's own candidate scope. The talent-pool requisition is never listed. Each row carries candidateCount, stageCounts and a Candidates preview of at most the 6 newest applicants",
   "responses": [
    {
     "status": 200,
     "text": "array of JobPosting — a bare array with no envelope. Every caller the route admits gets every requisition; the Candidates preview holds only candidates in that caller's own candidate scope. The talent-pool requisition is never listed. Each row carries candidateCount, stageCounts and a Candidates preview of at most the 6 newest applicants"
    }
   ],
   "errors": [],
   "guard": "a caller who holds manage_finance but none of manage_recruitment, view_recruitment or conduct_interview gets every posting with an empty Candidates list"
  },
  {
   "id": "GET /recruitment/job-postings/options",
   "group": "recruitment",
   "method": "GET",
   "path": "/recruitment/job-postings/options",
   "purpose": "Requisition choices for the Add candidate picker.",
   "auth": {
    "text": "Session + manage_recruitment | view_recruitment | source_candidates (licensed)",
    "kind": "session",
    "permissions": [
     "manage_recruitment",
     "source_candidates",
     "view_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters; not paged.",
   "response": "200 [{ id:integer, title:string, department:string|null, status:string }], ordered by title — live requisitions only, the talent pool excluded",
   "responses": [
    {
     "status": 200,
     "text": "[{ id:integer, title:string, department:string|null, status:string }], ordered by title — live requisitions only, the talent pool excluded"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /recruitment/job-postings",
   "group": "recruitment",
   "method": "POST",
   "path": "/recruitment/job-postings",
   "purpose": "Create a job posting.",
   "auth": {
    "text": "Session + manage_recruitment (licensed)",
    "kind": "session",
    "permissions": [
     "manage_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The JobPostingWrite object: { title:string } required; description?, department?, status?, location?, employmentType?, seniority?, workMode?, currency?, salaryRange?, payPeriod? (strings); minExperienceMonths?, salaryMin?, salaryMax? (whole numbers, 0 to 2147483647; blank is stored as empty); competencies?:array, requirements?:array, applicationQuestions?:object[]; hiringManagerId?, ownerId?, personaId? (integers). Any other key is ignored; createdById is set by the server.",
   "response": "201 JobPosting | 409 REQUISITION_NOT_BUDGET_APPROVED if status is Open/Published (new reqs are Pending). 400 code INVALID_NUMBER when minExperienceMonths, salaryMin or salaryMax is not a number in range",
   "responses": [
    {
     "status": 201,
     "text": "JobPosting"
    },
    {
     "status": 409,
     "text": "REQUISITION_NOT_BUDGET_APPROVED if status is Open/Published (new reqs are Pending). 400 code INVALID_NUMBER when minExperienceMonths, salaryMin or salaryMax is not a number in range"
    }
   ],
   "errors": [
    {
     "status": 409,
     "text": "REQUISITION_NOT_BUDGET_APPROVED if status is Open/Published (new reqs are Pending). 400 code INVALID_NUMBER when minExperienceMonths, salaryMin or salaryMax is not a number in range"
    }
   ],
   "guard": "finance fields (financeApprovalStatus/ApprovedBy/At) NOT body-writable — mass-assign to skip finance blocked; live status refused until budget approved. Repeat: not deduplicated: every call creates another requisition, also with the same title"
  },
  {
   "id": "PUT /recruitment/job-postings/:id",
   "group": "recruitment",
   "method": "PUT",
   "path": "/recruitment/job-postings/:id",
   "purpose": "Update a job posting.",
   "auth": {
    "text": "Session + manage_recruitment (licensed)",
    "kind": "session",
    "permissions": [
     "manage_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The JobPostingWrite object, all keys optional; only the keys sent are changed and any other key is ignored.",
   "response": "200 updated JobPosting | 404 'Job Posting not found'",
   "responses": [
    {
     "status": 200,
     "text": "updated JobPosting"
    },
    {
     "status": 404,
     "text": "'Job Posting not found'"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'Job Posting not found'"
    }
   ],
   "guard": "setting status to Open/Published requires financeApprovalStatus==='Approved' else 409 REQUISITION_NOT_BUDGET_APPROVED; finance fields not writable"
  },
  {
   "id": "PUT /recruitment/job-postings/:id/owner",
   "group": "recruitment",
   "method": "PUT",
   "path": "/recruitment/job-postings/:id/owner",
   "purpose": "Reassign the HR owner of a requisition, optionally cascading to its candidates.",
   "auth": {
    "text": "Session + manage_recruitment (licensed)",
    "kind": "session",
    "permissions": [
     "manage_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The RequisitionOwnerWrite object: { ownerId:integer|null, cascade?:boolean }. null removes the owner; cascade true also moves the requisition's candidates that belonged to the previous owner or had none.",
   "response": "200 { id:integer, ownerId:integer|null, cascaded:integer } (cascaded is the count of candidates moved to the new owner)",
   "responses": [
    {
     "status": 200,
     "text": "{ id:integer, ownerId:integer|null, cascaded:integer } (cascaded is the count of candidates moved to the new owner)"
    }
   ],
   "errors": [],
   "guard": "Repeat: the same owner is stored again, the answer is 200 and another RECRUITMENT_REQUISITION_REASSIGNED audit entry is written"
  },
  {
   "id": "DELETE /recruitment/job-postings/:id",
   "group": "recruitment",
   "method": "DELETE",
   "path": "/recruitment/job-postings/:id",
   "purpose": "Delete a requisition.",
   "auth": {
    "text": "Session + manage_recruitment (licensed)",
    "kind": "session",
    "permissions": [
     "manage_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 result | 409 { error:string } when candidates are still linked | 404 { error:string } when it does not exist",
   "responses": [
    {
     "status": 200,
     "text": "result"
    },
    {
     "status": 409,
     "text": "{ error:string } when candidates are still linked"
    },
    {
     "status": 404,
     "text": "{ error:string } when it does not exist"
    }
   ],
   "errors": [
    {
     "status": 409,
     "text": "{ error:string } when candidates are still linked"
    },
    {
     "status": 404,
     "text": "{ error:string } when it does not exist"
    }
   ],
   "guard": "Repeat: a second call answers 404, because the requisition is gone"
  },
  {
   "id": "POST /recruitment/job-postings/:id/approve-finance",
   "group": "recruitment",
   "method": "POST",
   "path": "/recruitment/job-postings/:id/approve-finance",
   "purpose": "Independent finance approval of headcount/budget.",
   "auth": {
    "text": "Session + manage_finance (only) (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 updated JobPosting (financeApprovalStatus=Approved) | 404 'Job Posting not found' | 409 code ALREADY_APPROVED — so a repeat changes nothing",
   "responses": [
    {
     "status": 200,
     "text": "updated JobPosting (financeApprovalStatus=Approved)"
    },
    {
     "status": 404,
     "text": "'Job Posting not found'"
    },
    {
     "status": 409,
     "text": "code ALREADY_APPROVED — so a repeat changes nothing"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'Job Posting not found'"
    },
    {
     "status": 409,
     "text": "code ALREADY_APPROVED — so a repeat changes nothing"
    }
   ],
   "guard": "requires manage_finance ALONE (manage_recruitment does NOT satisfy — SoD); assertNotSelfBudgetApproval: requisition creator cannot approve own budget (root-admin exempt). Repeat: a second call answers 409 with code ALREADY_APPROVED and changes nothing"
  },
  {
   "id": "POST /recruitment/job-postings/:id/reject-finance",
   "group": "recruitment",
   "method": "POST",
   "path": "/recruitment/job-postings/:id/reject-finance",
   "purpose": "Refuse the headcount budget of a requisition.",
   "auth": {
    "text": "Session + manage_finance (only) (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The BudgetRefusal object: { reason:string } required: at least 3 characters, cut to 1000.",
   "response": "200 updated JobPosting (the reason is stored on it) | 404 'Job Posting not found' | 409 code ALREADY_REJECTED — so a repeat changes nothing | 400 code REASON_REQUIRED when the reason is shorter than 3 characters (it is cut to 1000)",
   "responses": [
    {
     "status": 200,
     "text": "updated JobPosting (the reason is stored on it)"
    },
    {
     "status": 404,
     "text": "'Job Posting not found'"
    },
    {
     "status": 409,
     "text": "code ALREADY_REJECTED — so a repeat changes nothing"
    },
    {
     "status": 400,
     "text": "code REASON_REQUIRED when the reason is shorter than 3 characters (it is cut to 1000)"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'Job Posting not found'"
    },
    {
     "status": 409,
     "text": "code ALREADY_REJECTED — so a repeat changes nothing"
    },
    {
     "status": 400,
     "text": "code REASON_REQUIRED when the reason is shorter than 3 characters (it is cut to 1000)"
    }
   ],
   "guard": "the person who created the requisition cannot refuse its budget (same self-approval check as approve-finance). Repeat: a second call answers 409 with code ALREADY_REJECTED and changes nothing"
  },
  {
   "id": "POST /recruitment/job-postings/:id/resubmit-budget",
   "group": "recruitment",
   "method": "POST",
   "path": "/recruitment/job-postings/:id/resubmit-budget",
   "purpose": "Send a refused budget back to Finance after changing the terms (Rejected back to Pending).",
   "auth": {
    "text": "Session + manage_recruitment (licensed)",
    "kind": "session",
    "permissions": [
     "manage_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 updated JobPosting | 404 { error:string } | 409 code NOT_REJECTED when the budget is not currently refused",
   "responses": [
    {
     "status": 200,
     "text": "updated JobPosting"
    },
    {
     "status": 404,
     "text": "{ error:string }"
    },
    {
     "status": 409,
     "text": "code NOT_REJECTED when the budget is not currently refused"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "{ error:string }"
    },
    {
     "status": 409,
     "text": "code NOT_REJECTED when the budget is not currently refused"
    }
   ],
   "guard": "Repeat: a second call answers 409 with code NOT_REJECTED, because the budget is Pending again"
  },
  {
   "id": "GET /recruitment/job-postings/:id/activity",
   "group": "recruitment",
   "method": "GET",
   "path": "/recruitment/job-postings/:id/activity",
   "purpose": "Activity trail of one requisition.",
   "auth": {
    "text": "Session + manage_recruitment | view_recruitment | manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance",
     "manage_recruitment",
     "view_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [
    "limit"
   ],
   "query": [
    {
     "name": "limit",
     "type": "integer",
     "default": 50,
     "minimum": 1,
     "maximum": 200
    }
   ],
   "request": "Query: limit (default 50, clamped to 1–200).",
   "response": "200 { items:[{ action:string, label:string, details:string, actor:string (an email address, or 'system'), at:string (date-time), status?:string }] } — the newest 50 unless ?limit says otherwise (1 to 200)",
   "responses": [
    {
     "status": 200,
     "text": "{ items:[{ action:string, label:string, details:string, actor:string (an email address, or 'system'), at:string (date-time), status?:string }] } — the newest 50 unless ?limit says otherwise (1 to 200)"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /recruitment/competencies/suggest",
   "group": "recruitment",
   "method": "POST",
   "path": "/recruitment/competencies/suggest",
   "purpose": "Suggest scorecard competencies for a role.",
   "auth": {
    "text": "Session + manage_recruitment (licensed)",
    "kind": "session",
    "permissions": [
     "manage_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The CompetencySuggest object: { jobTitle:string } required; jobDescription?:string.",
   "response": "200 { competencies } | 400 if jobTitle missing | 502 if AI generation fails",
   "responses": [
    {
     "status": 200,
     "text": "{ competencies }"
    },
    {
     "status": 400,
     "text": "if jobTitle missing"
    },
    {
     "status": 502,
     "text": "if AI generation fails"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "if jobTitle missing"
    },
    {
     "status": 502,
     "text": "if AI generation fails"
    }
   ],
   "guard": null
  },
  {
   "id": "POST /recruitment/job-description/suggest",
   "group": "recruitment",
   "method": "POST",
   "path": "/recruitment/job-description/suggest",
   "purpose": "Draft a job description with the local LLM.",
   "auth": {
    "text": "Session + manage_recruitment (licensed)",
    "kind": "session",
    "permissions": [
     "manage_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The JobDescriptionSuggest object: { jobTitle:string } required; the other role fields of the create-job form are passed through to the model.",
   "response": "200 { description:string } | 400 when jobTitle is missing | 502 when generation fails",
   "responses": [
    {
     "status": 200,
     "text": "{ description:string }"
    },
    {
     "status": 400,
     "text": "when jobTitle is missing"
    },
    {
     "status": 502,
     "text": "when generation fails"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "when jobTitle is missing"
    },
    {
     "status": 502,
     "text": "when generation fails"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /recruitment/candidates",
   "group": "recruitment-candidates",
   "method": "GET",
   "path": "/recruitment/candidates",
   "purpose": "List candidates in the pipeline.",
   "auth": {
    "text": "Session + manage_recruitment | view_recruitment | source_candidates | schedule_interviews | review_executive_packet | conduct_interview (licensed)",
    "kind": "session",
    "permissions": [
     "conduct_interview",
     "manage_recruitment",
     "review_executive_packet",
     "schedule_interviews",
     "source_candidates",
     "view_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [
    "limit",
    "offset"
   ],
   "query": [
    {
     "name": "limit",
     "type": "integer",
     "default": 200,
     "minimum": 1,
     "maximum": 500
    },
    {
     "name": "offset",
     "type": "integer",
     "default": 0,
     "minimum": 0
    },
    {
     "name": "ownerId",
     "type": "string"
    },
    {
     "name": "search",
     "type": "string"
    },
    {
     "name": "sort",
     "type": "string"
    },
    {
     "name": "stage",
     "type": "string"
    },
    {
     "name": "tag",
     "type": "string"
    },
    {
     "name": "jobPostingId",
     "type": "string",
     "note": "A requisition id."
    }
   ],
   "request": "Query: limit (default 200, clamped to 1–500), offset (default 0), ownerId, search, sort, stage, tag, jobPostingId. sort is score (highest match first) or name; any other value, or none, is newest first. ownerId takes a user id, or none | unassigned for candidates with no owner.",
   "response": "200 { candidates:[ Candidate ], total:integer } — total counts every candidate matching the filters, not the page. Page with limit and offset against total; there is no page parameter. A call with no query parameters at all may return more than 200 rows: when the first 200 fill the page, candidates the caller is assigned to interview that the cap left out are appended (up to 300 more). Send limit or offset to get exactly one page",
   "responses": [
    {
     "status": 200,
     "text": "{ candidates:[ Candidate ], total:integer } — total counts every candidate matching the filters, not the page. Page with limit and offset against total; there is no page parameter. A call with no query parameters at all may return more than 200 rows: when the first 200 fill the page, candidates the caller is assigned to interview that the cap left out are appended (up to 300 more). Send limit or offset to get exactly one page"
    }
   ],
   "errors": [],
   "guard": "the service applies the caller's candidate scope, so the list holds only candidates that caller may see"
  },
  {
   "id": "GET /recruitment/candidates/summary",
   "group": "recruitment-candidates",
   "method": "GET",
   "path": "/recruitment/candidates/summary",
   "purpose": "Pipeline counts for the dashboard, scoped like the list.",
   "auth": {
    "text": "Session + manage_recruitment | view_recruitment | source_candidates | schedule_interviews | review_executive_packet | conduct_interview (licensed)",
    "kind": "session",
    "permissions": [
     "conduct_interview",
     "manage_recruitment",
     "review_executive_packet",
     "schedule_interviews",
     "source_candidates",
     "view_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { byStage:{ <stage>: integer } (only stages that hold a candidate), total:integer, hired:integer, rejected:integer, hiredThisMonth:integer, hiredLastMonth:integer }",
   "responses": [
    {
     "status": 200,
     "text": "{ byStage:{ <stage>: integer } (only stages that hold a candidate), total:integer, hired:integer, rejected:integer, hiredThisMonth:integer, hiredLastMonth:integer }"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /recruitment/candidates/filter-options",
   "group": "recruitment-candidates",
   "method": "GET",
   "path": "/recruitment/candidates/filter-options",
   "purpose": "Owner and tag choices for the pipeline filters, over the same scoped set as the list.",
   "auth": {
    "text": "Session + manage_recruitment | view_recruitment | source_candidates | schedule_interviews | review_executive_packet | conduct_interview (licensed)",
    "kind": "session",
    "permissions": [
     "conduct_interview",
     "manage_recruitment",
     "review_executive_packet",
     "schedule_interviews",
     "source_candidates",
     "view_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { owners:[{ id:integer, name:string }] sorted by name, tags:string[] sorted, hasUnassigned:boolean } — built from at most the newest 20,000 candidates in the caller's scope",
   "responses": [
    {
     "status": 200,
     "text": "{ owners:[{ id:integer, name:string }] sorted by name, tags:string[] sorted, hasUnassigned:boolean } — built from at most the newest 20,000 candidates in the caller's scope"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /recruitment/candidates/bulk",
   "group": "recruitment-candidates",
   "method": "POST",
   "path": "/recruitment/candidates/bulk",
   "purpose": "Apply one action to many candidates: move stage, reject, reassign or tag.",
   "auth": {
    "text": "Session + manage_recruitment | conduct_interview | source_candidates (licensed)",
    "kind": "session",
    "permissions": [
     "conduct_interview",
     "manage_recruitment",
     "source_candidates"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The CandidateBulkAction object: { ids:integer[] (1 to 200), action: stage | reject | reassign | tag } plus, per action: stage:string (stage); sendEmail?:boolean, reason?:string (reject: the email is sent unless sendEmail is false); ownerId:integer|null (reassign); add?:string[], remove?:string[] (tag).",
   "response": "200 { succeeded:integer[], failed:[{ id:integer, error:string }], skipped:[{ id:integer, reason:string }] } | 400 empty list, more than 200 ids, or an unknown action",
   "responses": [
    {
     "status": 200,
     "text": "{ succeeded:integer[], failed:[{ id:integer, error:string }], skipped:[{ id:integer, reason:string }] }"
    },
    {
     "status": 400,
     "text": "empty list, more than 200 ids, or an unknown action"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "empty list, more than 200 ids, or an unknown action"
    }
   ],
   "guard": "each item runs the same per-candidate check as the single route, so a row the caller may not act on lands in failed; a reassign item fails unless the caller holds manage_recruitment itself"
  },
  {
   "id": "POST /recruitment/candidates/bulk-offer-decision",
   "group": "recruitment-candidates",
   "method": "POST",
   "path": "/recruitment/candidates/bulk-offer-decision",
   "purpose": "Approve or refuse many offers that are awaiting approval.",
   "auth": {
    "text": "Session + manage_recruitment | approve_offer | manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "approve_offer",
     "manage_finance",
     "manage_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The OfferBulkDecision object: { ids:integer[] (1 to 200), approve:boolean, reason?:string, termsVersions?:{ <candidateId>: string } }. Only approve:true approves; anything else refuses.",
   "response": "200 { succeeded:integer[], failed:[{ id:integer, error:string, code?:string }] } | 400 empty list or more than 200 ids",
   "responses": [
    {
     "status": 200,
     "text": "{ succeeded:integer[], failed:[{ id:integer, error:string, code?:string }] }"
    },
    {
     "status": 400,
     "text": "empty list or more than 200 ids"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "empty list or more than 200 ids"
    }
   ],
   "guard": "each offer goes through the same service call as the single approve and refuse routes, including the self-approval block"
  },
  {
   "id": "POST /recruitment/candidates",
   "group": "recruitment-candidates",
   "method": "POST",
   "path": "/recruitment/candidates",
   "purpose": "Add a candidate.",
   "auth": {
    "text": "Session + manage_recruitment | source_candidates (licensed)",
    "kind": "session",
    "permissions": [
     "manage_recruitment",
     "source_candidates"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The CandidateCreate object: { firstName:string, lastName:string, email:string, jobPostingId:integer } required; phoneNumber?:string (stored encrypted), source?:string, ownerId?:integer. No other candidate field is read from the body: the stage, the scores and the consent fields are set by the server. ownerId is honoured only for a caller holding manage_recruitment; otherwise the candidate takes the requisition's owner, else the caller.",
   "response": "201 Candidate | 409 code CANDIDATE_ALREADY_EXISTS when that email is already on the requisition",
   "responses": [
    {
     "status": 201,
     "text": "Candidate"
    },
    {
     "status": 409,
     "text": "code CANDIDATE_ALREADY_EXISTS when that email is already on the requisition"
    }
   ],
   "errors": [
    {
     "status": 409,
     "text": "code CANDIDATE_ALREADY_EXISTS when that email is already on the requisition"
    }
   ],
   "guard": "duplicate-email check on create; a repeat with the same email on the same requisition answers 409 and creates nothing"
  },
  {
   "id": "POST /recruitment/candidates/ai-screen",
   "group": "recruitment-candidates",
   "method": "POST",
   "path": "/recruitment/candidates/ai-screen",
   "purpose": "Screen a candidate against the job's requirements (demographic fields stripped first).",
   "auth": {
    "text": "Session + manage_recruitment | source_candidates (licensed)",
    "kind": "session",
    "permissions": [
     "manage_recruitment",
     "source_candidates"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "multipart/form-data: resume (one file), jobPostingId (required) and the text fields of the CandidateCreate object: firstName, lastName, email, phoneNumber?, source?, ownerId?. ownerId is honoured only for a caller holding manage_recruitment; otherwise the candidate takes the requisition's owner, else the caller.",
   "response": "201 Candidate with matchScore | 400 if jobPostingId missing or screen error | 404 'Job Posting not found' | 409 code CANDIDATE_ALREADY_EXISTS when that email is already on the requisition",
   "responses": [
    {
     "status": 201,
     "text": "Candidate with matchScore"
    },
    {
     "status": 400,
     "text": "if jobPostingId missing or screen error"
    },
    {
     "status": 404,
     "text": "'Job Posting not found'"
    },
    {
     "status": 409,
     "text": "code CANDIDATE_ALREADY_EXISTS when that email is already on the requisition"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "if jobPostingId missing or screen error"
    },
    {
     "status": 404,
     "text": "'Job Posting not found'"
    },
    {
     "status": 409,
     "text": "code CANDIDATE_ALREADY_EXISTS when that email is already on the requisition"
    }
   ],
   "guard": "AI screening strips demographic fields before scoring; the screening decision is audited"
  },
  {
   "id": "POST /recruitment/candidates/:id/rescreen",
   "group": "recruitment-candidates",
   "method": "POST",
   "path": "/recruitment/candidates/:id/rescreen",
   "purpose": "Re-run screening for a candidate.",
   "auth": {
    "text": "Session + manage_recruitment | source_candidates (licensed)",
    "kind": "session",
    "permissions": [
     "manage_recruitment",
     "source_candidates"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body — re-screens using the candidate's stored résumé.",
   "response": "200 { status:string } (e.g. 'started')",
   "responses": [
    {
     "status": 200,
     "text": "{ status:string } (e.g. 'started')"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /recruitment/candidates/parse-resume",
   "group": "recruitment-candidates",
   "method": "POST",
   "path": "/recruitment/candidates/parse-resume",
   "purpose": "Parse an uploaded resume into structured fields.",
   "auth": {
    "text": "Session + manage_recruitment | source_candidates (licensed)",
    "kind": "session",
    "permissions": [
     "manage_recruitment",
     "source_candidates"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "multipart/form-data: resume (single file). No other fields.",
   "response": "200 { firstName:string, lastName:string, email:string, phone:string } (empty strings, plus warning:string, when the file could not be read) | 400 'No resume uploaded'",
   "responses": [
    {
     "status": 200,
     "text": "{ firstName:string, lastName:string, email:string, phone:string } (empty strings, plus warning:string, when the file could not be read)"
    },
    {
     "status": 400,
     "text": "'No resume uploaded'"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "'No resume uploaded'"
    }
   ],
   "guard": "fail-open: on extraction failure returns empty fields + warning (200), never 500"
  },
  {
   "id": "GET /recruitment/candidates/:id/onboarding-status",
   "group": "recruitment-candidates",
   "method": "GET",
   "path": "/recruitment/candidates/:id/onboarding-status",
   "purpose": "Onboarding run status and step progress for a hired candidate.",
   "auth": {
    "text": "Session + manage_recruitment | view_recruitment (licensed)",
    "kind": "session",
    "permissions": [
     "manage_recruitment",
     "view_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 onboarding status | 404 'Candidate not found' when the caller has no broad access and is not related to the candidate",
   "responses": [
    {
     "status": 200,
     "text": "onboarding status"
    },
    {
     "status": 404,
     "text": "'Candidate not found' when the caller has no broad access and is not related to the candidate"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'Candidate not found' when the caller has no broad access and is not related to the candidate"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /recruitment/candidates/:id/history",
   "group": "recruitment-candidates",
   "method": "GET",
   "path": "/recruitment/candidates/:id/history",
   "purpose": "This person's earlier applications.",
   "auth": {
    "text": "Session + manage_recruitment | view_recruitment | conduct_interview | source_candidates | review_executive_packet (licensed)",
    "kind": "session",
    "permissions": [
     "conduct_interview",
     "manage_recruitment",
     "review_executive_packet",
     "source_candidates",
     "view_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { email:string, priorCount:integer, priorApplications:[{ id:integer, jobPostingId:integer, jobTitle:string, department:string|null, stage:string, source:string|null, appliedAt:string (date-time) }], everHired:boolean, everRejected:boolean } — other applications under the same email that the caller may see, newest first, at most 50 | 404 'Candidate not found'",
   "responses": [
    {
     "status": 200,
     "text": "{ email:string, priorCount:integer, priorApplications:[{ id:integer, jobPostingId:integer, jobTitle:string, department:string|null, stage:string, source:string|null, appliedAt:string (date-time) }], everHired:boolean, everRejected:boolean } — other applications under the same email that the caller may see, newest first, at most 50"
    },
    {
     "status": 404,
     "text": "'Candidate not found'"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'Candidate not found'"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /recruitment/candidates/:id/messages",
   "group": "recruitment-candidates",
   "method": "GET",
   "path": "/recruitment/candidates/:id/messages",
   "purpose": "Email thread with a candidate.",
   "auth": {
    "text": "Session + manage_recruitment | view_recruitment | conduct_interview | source_candidates | review_executive_packet (licensed)",
    "kind": "session",
    "permissions": [
     "conduct_interview",
     "manage_recruitment",
     "review_executive_packet",
     "source_candidates",
     "view_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 [ CandidateMessage ], oldest first, at most 200; not paged",
   "responses": [
    {
     "status": 200,
     "text": "[ CandidateMessage ], oldest first, at most 200; not paged"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /recruitment/candidates/:id/messages",
   "group": "recruitment-candidates",
   "method": "POST",
   "path": "/recruitment/candidates/:id/messages",
   "purpose": "Send an email to a candidate from the thread.",
   "auth": {
    "text": "Session + manage_recruitment | conduct_interview | source_candidates (licensed)",
    "kind": "session",
    "permissions": [
     "conduct_interview",
     "manage_recruitment",
     "source_candidates"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The CandidateMessageSend object: { subject:string, body:string }, both required and not blank; subject is cut to 500 characters.",
   "response": "201 the stored message | 409 code SMTP_OFF when outgoing email is not configured. 400 when the subject or body is blank, or the candidate has no email address",
   "responses": [
    {
     "status": 201,
     "text": "the stored message"
    },
    {
     "status": 409,
     "text": "code SMTP_OFF when outgoing email is not configured. 400 when the subject or body is blank, or the candidate has no email address"
    }
   ],
   "errors": [
    {
     "status": 409,
     "text": "code SMTP_OFF when outgoing email is not configured. 400 when the subject or body is blank, or the candidate has no email address"
    }
   ],
   "guard": "Repeat: not deduplicated: every call sends another email and stores another message"
  },
  {
   "id": "PATCH /recruitment/candidates/:id/stage",
   "group": "recruitment-candidates",
   "method": "PATCH",
   "path": "/recruitment/candidates/:id/stage",
   "purpose": "Move a candidate between pipeline stages.",
   "auth": {
    "text": "Session + manage_recruitment | conduct_interview | source_candidates (licensed)",
    "kind": "session",
    "permissions": [
     "conduct_interview",
     "manage_recruitment",
     "source_candidates"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The CandidateStageWrite object: { stage:string } required: Sourced | Screen | Interview | Offer | Hired | Rejected, in any capitalisation.",
   "response": "200 updated Candidate | 409 code OFFER_NOT_SIGNED when moving to Hired while the candidate's offer is not yet signed",
   "responses": [
    {
     "status": 200,
     "text": "updated Candidate"
    },
    {
     "status": 409,
     "text": "code OFFER_NOT_SIGNED when moving to Hired while the candidate's offer is not yet signed"
    }
   ],
   "errors": [
    {
     "status": 409,
     "text": "code OFFER_NOT_SIGNED when moving to Hired while the candidate's offer is not yet signed"
    }
   ],
   "guard": "hiring-decision stages (e.g. Hired) restricted to HR; interviewer/sourcer scope enforced in service"
  },
  {
   "id": "PATCH /recruitment/candidates/:id/tags",
   "group": "recruitment-candidates",
   "method": "PATCH",
   "path": "/recruitment/candidates/:id/tags",
   "purpose": "Add or remove recruiter tags on a candidate.",
   "auth": {
    "text": "Session + manage_recruitment | conduct_interview | source_candidates (licensed)",
    "kind": "session",
    "permissions": [
     "conduct_interview",
     "manage_recruitment",
     "source_candidates"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The CandidateTagsUpdate object: { add?:string[], remove?:string[] }. A label is trimmed and cut to 40 characters; a candidate keeps at most 30.",
   "response": "200 { tags:string[] }",
   "responses": [
    {
     "status": 200,
     "text": "{ tags:string[] }"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /recruitment/candidates/:id/notes",
   "group": "recruitment-candidates",
   "method": "GET",
   "path": "/recruitment/candidates/:id/notes",
   "purpose": "Internal hiring-team notes on a candidate.",
   "auth": {
    "text": "Session + manage_recruitment | conduct_interview | source_candidates (licensed)",
    "kind": "session",
    "permissions": [
     "conduct_interview",
     "manage_recruitment",
     "source_candidates"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 [ CandidateNote ], newest first, each with its Author; not paged",
   "responses": [
    {
     "status": 200,
     "text": "[ CandidateNote ], newest first, each with its Author; not paged"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /recruitment/candidates/:id/notes",
   "group": "recruitment-candidates",
   "method": "POST",
   "path": "/recruitment/candidates/:id/notes",
   "purpose": "Add an internal note, optionally mentioning colleagues.",
   "auth": {
    "text": "Session + manage_recruitment | conduct_interview | source_candidates (licensed)",
    "kind": "session",
    "permissions": [
     "conduct_interview",
     "manage_recruitment",
     "source_candidates"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The CandidateNoteCreate object: { body:string } required (trimmed, cut to 5000 characters); mentions?:integer[] (user ids to notify).",
   "response": "201 the stored CandidateNote. 400 'A note cannot be empty.'",
   "responses": [
    {
     "status": 201,
     "text": "the stored CandidateNote. 400 'A note cannot be empty.'"
    }
   ],
   "errors": [],
   "guard": "Repeat: not deduplicated: every call stores another note and notifies the people mentioned again"
  },
  {
   "id": "POST /recruitment/candidates/:id/reject",
   "group": "recruitment-candidates",
   "method": "POST",
   "path": "/recruitment/candidates/:id/reject",
   "purpose": "Reject a candidate.",
   "auth": {
    "text": "Session + manage_recruitment | conduct_interview | source_candidates (licensed)",
    "kind": "session",
    "permissions": [
     "conduct_interview",
     "manage_recruitment",
     "source_candidates"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The CandidateReject object: { sendEmail?:boolean, reason?:string } — all optional.",
   "response": "200 result (adverse-action email sent if sendEmail)",
   "responses": [
    {
     "status": 200,
     "text": "result (adverse-action email sent if sendEmail)"
    }
   ],
   "errors": [],
   "guard": "assertCanActOnCandidate: caller must be related to candidate (or HR/admin), fails closed. Repeat: a candidate who is already Rejected is left unchanged, no second email is sent, and the answer is 200"
  },
  {
   "id": "POST /recruitment/candidates/:id/calibrate",
   "group": "recruitment-candidates",
   "method": "POST",
   "path": "/recruitment/candidates/:id/calibrate",
   "purpose": "Calibrate scorecards across interviewers.",
   "auth": {
    "text": "Session + manage_recruitment | view_recruitment (licensed)",
    "kind": "session",
    "permissions": [
     "manage_recruitment",
     "view_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 calibration result (interviewer names/scores/feedback)",
   "responses": [
    {
     "status": 200,
     "text": "calibration result (interviewer names/scores/feedback)"
    }
   ],
   "errors": [],
   "guard": "assertCanActOnCandidate — real relationship required, 404 fail-closed"
  },
  {
   "id": "POST /recruitment/candidates/:id/send-rejection-email",
   "group": "recruitment-candidates",
   "method": "POST",
   "path": "/recruitment/candidates/:id/send-rejection-email",
   "purpose": "Send a rejection email.",
   "auth": {
    "text": "Session + manage_recruitment | conduct_interview (licensed)",
    "kind": "session",
    "permissions": [
     "conduct_interview",
     "manage_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 result — sends the adverse-action/rejection email | 409 code SMTP_OFF when outgoing email is not configured (nothing is sent)",
   "responses": [
    {
     "status": 200,
     "text": "result — sends the adverse-action/rejection email"
    },
    {
     "status": 409,
     "text": "code SMTP_OFF when outgoing email is not configured (nothing is sent)"
    }
   ],
   "errors": [
    {
     "status": 409,
     "text": "code SMTP_OFF when outgoing email is not configured (nothing is sent)"
    }
   ],
   "guard": null
  },
  {
   "id": "PUT /recruitment/candidates/:id/owner",
   "group": "recruitment-candidates",
   "method": "PUT",
   "path": "/recruitment/candidates/:id/owner",
   "purpose": "Reassign a candidate's HR owner.",
   "auth": {
    "text": "Session + manage_recruitment (licensed)",
    "kind": "session",
    "permissions": [
     "manage_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The CandidateOwnerWrite object: { ownerId:integer|null }.",
   "response": "200 { id:integer, ownerId:integer|null }",
   "responses": [
    {
     "status": 200,
     "text": "{ id:integer, ownerId:integer|null }"
    }
   ],
   "errors": [],
   "guard": "Repeat: the same owner is stored again, the answer is 200 and another RECRUITMENT_CANDIDATE_REASSIGNED audit entry is written"
  },
  {
   "id": "DELETE /recruitment/candidates/:id",
   "group": "recruitment-candidates",
   "method": "DELETE",
   "path": "/recruitment/candidates/:id",
   "purpose": "Purge a candidate and their files (GDPR erasure).",
   "auth": {
    "text": "Session + manage_recruitment",
    "kind": "session",
    "permissions": [
     "manage_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { result } — permanent purge including the résumé file, audited | 207 with the same body when the local purge succeeded but a signed document could not be erased downstream",
   "responses": [
    {
     "status": 200,
     "text": "{ result } — permanent purge including the résumé file, audited"
    },
    {
     "status": 207,
     "text": "with the same body when the local purge succeeded but a signed document could not be erased downstream"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /recruitment/candidates/:id/data-export",
   "group": "recruitment-candidates",
   "method": "GET",
   "path": "/recruitment/candidates/:id/data-export",
   "purpose": "Export everything held about one candidate (data-subject access request).",
   "auth": {
    "text": "Session + manage_recruitment",
    "kind": "session",
    "permissions": [
     "manage_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 JSON attachment candidate-<id>-data-export.json | the service error status with { error:string }",
   "responses": [
    {
     "status": 200,
     "text": "JSON attachment candidate-<id>-data-export.json | the service error status with { error:string }"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /recruitment/candidates/:id/resume",
   "group": "recruitment-candidates",
   "method": "GET",
   "path": "/recruitment/candidates/:id/resume",
   "purpose": "Stream a CV to the in-app viewer.",
   "auth": {
    "text": "Session + manage_recruitment | view_recruitment | conduct_interview | source_candidates | review_executive_packet",
    "kind": "session",
    "permissions": [
     "conduct_interview",
     "manage_recruitment",
     "review_executive_packet",
     "source_candidates",
     "view_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 the file, served inline with a Content-Type chosen from its extension | 404 text 'Resume not found' — also for a caller with no relationship to the candidate | 404 text 'File not found on disk' | 400 text 'Invalid filename' | 500 text 'Could not open resume'",
   "responses": [
    {
     "status": 200,
     "text": "the file, served inline with a Content-Type chosen from its extension"
    },
    {
     "status": 404,
     "text": "text 'Resume not found' — also for a caller with no relationship to the candidate"
    },
    {
     "status": 404,
     "text": "text 'File not found on disk'"
    },
    {
     "status": 400,
     "text": "text 'Invalid filename'"
    },
    {
     "status": 500,
     "text": "text 'Could not open resume'"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "text 'Resume not found' — also for a caller with no relationship to the candidate"
    },
    {
     "status": 404,
     "text": "text 'File not found on disk'"
    },
    {
     "status": 400,
     "text": "text 'Invalid filename'"
    },
    {
     "status": 500,
     "text": "text 'Could not open resume'"
    }
   ],
   "guard": "a caller without broad recruitment access must be the candidate's owner, the requisition's owner or hiring manager, or an assigned interviewer; every read is audited as RESUME_ACCESSED"
  },
  {
   "id": "GET /recruitment/interviews/:id",
   "group": "recruitment-interviews",
   "method": "GET",
   "path": "/recruitment/interviews/:id",
   "purpose": "Interview detail and scorecards.",
   "auth": {
    "text": "Session + manage_recruitment | view_recruitment | conduct_interview | schedule_interviews | review_executive_packet (licensed)",
    "kind": "session",
    "permissions": [
     "conduct_interview",
     "manage_recruitment",
     "review_executive_packet",
     "schedule_interviews",
     "view_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 Interview | 404 'Interview not found' — also returned to a caller who is not on that interview's panel",
   "responses": [
    {
     "status": 200,
     "text": "Interview"
    },
    {
     "status": 404,
     "text": "'Interview not found' — also returned to a caller who is not on that interview's panel"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'Interview not found' — also returned to a caller who is not on that interview's panel"
    }
   ],
   "guard": null
  },
  {
   "id": "POST /recruitment/candidates/:id/interviews",
   "group": "recruitment-interviews",
   "method": "POST",
   "path": "/recruitment/candidates/:id/interviews",
   "purpose": "Schedule an interview.",
   "auth": {
    "text": "Session + manage_recruitment | conduct_interview | schedule_interviews (licensed)",
    "kind": "session",
    "permissions": [
     "conduct_interview",
     "manage_recruitment",
     "schedule_interviews"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The InterviewCreate object: { interviewerId:integer } required; scheduledAt?:string (date-time), sequence?:integer, interviewTemplateId?:integer, isShadow?:boolean, durationMins?:integer, allowOverlap?:boolean.",
   "response": "201 Interview (interviewer notified best-effort). 400 code INVALID_TIME when scheduledAt is not a date | 409 code DOUBLE_BOOKED when the interviewer already has a scheduled interview in that time window and allowOverlap is not true",
   "responses": [
    {
     "status": 201,
     "text": "Interview (interviewer notified best-effort). 400 code INVALID_TIME when scheduledAt is not a date"
    },
    {
     "status": 409,
     "text": "code DOUBLE_BOOKED when the interviewer already has a scheduled interview in that time window and allowOverlap is not true"
    }
   ],
   "errors": [
    {
     "status": 409,
     "text": "code DOUBLE_BOOKED when the interviewer already has a scheduled interview in that time window and allowOverlap is not true"
    }
   ],
   "guard": "the caller must be allowed to act on that candidate. Repeat: not deduplicated as such, but a second call for the same interviewer and time answers 409 DOUBLE_BOOKED unless allowOverlap is true; without a time every call creates another interview"
  },
  {
   "id": "POST /recruitment/candidates/:id/interviews/propose",
   "group": "recruitment-interviews",
   "method": "POST",
   "path": "/recruitment/candidates/:id/interviews/propose",
   "purpose": "Propose interview times for the candidate to pick from (self-scheduling).",
   "auth": {
    "text": "Session + manage_recruitment | conduct_interview | schedule_interviews (licensed)",
    "kind": "session",
    "permissions": [
     "conduct_interview",
     "manage_recruitment",
     "schedule_interviews"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The InterviewSlotsPropose object: { interviewerId:integer, slots:string[] (date-times) } required: at least two future times, the first 10 kept; interviewTemplateId?:integer, durationMins?:integer, allowOverlap?:boolean, isShadow?:boolean.",
   "response": "201 { id:integer, proposedSlots:array } | 400 code INTERVIEWER_REQUIRED or NOT_ENOUGH_SLOTS. 409 code DOUBLE_BOOKED when fewer than two of the times are free for the interviewer",
   "responses": [
    {
     "status": 201,
     "text": "{ id:integer, proposedSlots:array }"
    },
    {
     "status": 400,
     "text": "code INTERVIEWER_REQUIRED or NOT_ENOUGH_SLOTS. 409 code DOUBLE_BOOKED when fewer than two of the times are free for the interviewer"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "code INTERVIEWER_REQUIRED or NOT_ENOUGH_SLOTS. 409 code DOUBLE_BOOKED when fewer than two of the times are free for the interviewer"
    }
   ],
   "guard": "the caller must be allowed to act on that candidate; slots that clash with the interviewer's existing bookings are dropped unless allowOverlap is true. Repeat: not deduplicated: every call creates another interview with its own link and sends the candidate another invitation"
  },
  {
   "id": "PATCH /recruitment/interviews/:id/reschedule",
   "group": "recruitment-interviews",
   "method": "PATCH",
   "path": "/recruitment/interviews/:id/reschedule",
   "purpose": "Move an interview to a new time.",
   "auth": {
    "text": "Session + manage_recruitment | conduct_interview | schedule_interviews (licensed)",
    "kind": "session",
    "permissions": [
     "conduct_interview",
     "manage_recruitment",
     "schedule_interviews"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The InterviewReschedule object: { scheduledAt:string (date-time) } required; durationMins?:integer, allowOverlap?:boolean.",
   "response": "200 updated Interview | the service error status with { error:string, code?:string } — 404 'Interview not found', 409 code ALREADY_COMPLETED, 400 code INVALID_TIME, 409 code DOUBLE_BOOKED when the interviewer already has a scheduled interview in that time window, unless allowOverlap is true",
   "responses": [
    {
     "status": 200,
     "text": "updated Interview | the service error status with { error:string, code?:string } — 404 'Interview not found', 409 code ALREADY_COMPLETED, 400 code INVALID_TIME, 409 code DOUBLE_BOOKED when the interviewer already has a scheduled interview in that time window, unless allowOverlap is true"
    }
   ],
   "errors": [],
   "guard": "Repeat: not a no-op: the same time is stored again, the calendar invitation is re-issued with a higher sequence number and the interviewer is notified again"
  },
  {
   "id": "POST /recruitment/interviews/:id/cancel",
   "group": "recruitment-interviews",
   "method": "POST",
   "path": "/recruitment/interviews/:id/cancel",
   "purpose": "Cancel an interview.",
   "auth": {
    "text": "Session + manage_recruitment | conduct_interview | schedule_interviews (licensed)",
    "kind": "session",
    "permissions": [
     "conduct_interview",
     "manage_recruitment",
     "schedule_interviews"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The InterviewCancel object: { reason?:string }, cut to 500 characters.",
   "response": "200 updated Interview | the service error status with { error:string, code?:string } — 404 'Interview not found', 409 code ALREADY_COMPLETED. Safe to repeat: an interview that is already cancelled is returned unchanged",
   "responses": [
    {
     "status": 200,
     "text": "updated Interview | the service error status with { error:string, code?:string } — 404 'Interview not found', 409 code ALREADY_COMPLETED. Safe to repeat: an interview that is already cancelled is returned unchanged"
    }
   ],
   "errors": [],
   "guard": "Repeat: safe; an interview that is already cancelled is returned unchanged and the interviewer is not notified again"
  },
  {
   "id": "POST /recruitment/interviews/:id/scorecard",
   "group": "recruitment-interviews",
   "method": "POST",
   "path": "/recruitment/interviews/:id/scorecard",
   "purpose": "Submit an interview scorecard.",
   "auth": {
    "text": "Session + manage_recruitment | conduct_interview (licensed)",
    "kind": "session",
    "permissions": [
     "conduct_interview",
     "manage_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The ScorecardSubmit object: { structuredFeedback?:object, score?:integer (1 to 5), feedback?:string }; the recommendation is carried inside structuredFeedback. Sets status to Completed.",
   "response": "200 updated Interview | 403 code SCORECARD_NOT_ASSIGNED | 409 code INTERVIEW_CANCELLED | 409 code SCORECARD_LOCKED | 400 code INVALID_SCORE (score must be 1–5)",
   "responses": [
    {
     "status": 200,
     "text": "updated Interview"
    },
    {
     "status": 403,
     "text": "code SCORECARD_NOT_ASSIGNED"
    },
    {
     "status": 409,
     "text": "code INTERVIEW_CANCELLED"
    },
    {
     "status": 409,
     "text": "code SCORECARD_LOCKED"
    },
    {
     "status": 400,
     "text": "code INVALID_SCORE (score must be 1–5)"
    }
   ],
   "errors": [
    {
     "status": 403,
     "text": "code SCORECARD_NOT_ASSIGNED"
    },
    {
     "status": 409,
     "text": "code INTERVIEW_CANCELLED"
    },
    {
     "status": 409,
     "text": "code SCORECARD_LOCKED"
    },
    {
     "status": 400,
     "text": "code INVALID_SCORE (score must be 1–5)"
    }
   ],
   "guard": "only the interviewer assigned to that interview can submit its scorecard (the administrator role is exempt); the refusal is audited; a scorecard can be submitted again and replaces the earlier one until the candidate is Hired or Rejected, after which it answers 409 SCORECARD_LOCKED"
  },
  {
   "id": "GET /recruitment/interview-templates",
   "group": "recruitment-interviews",
   "method": "GET",
   "path": "/recruitment/interview-templates",
   "purpose": "List interview templates.",
   "auth": {
    "text": "Session + manage_recruitment | conduct_interview | view_recruitment (licensed)",
    "kind": "session",
    "permissions": [
     "conduct_interview",
     "manage_recruitment",
     "view_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query: jobPostingId?",
   "response": "200 bare array of InterviewTemplate, each with usageCount and its Questions in order; not paged — jobPostingId is the only filter",
   "responses": [
    {
     "status": 200,
     "text": "bare array of InterviewTemplate, each with usageCount and its Questions in order; not paged — jobPostingId is the only filter"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /recruitment/interview-templates",
   "group": "recruitment-interviews",
   "method": "POST",
   "path": "/recruitment/interview-templates",
   "purpose": "Create an interview template.",
   "auth": {
    "text": "Session + manage_recruitment (licensed)",
    "kind": "session",
    "permissions": [
     "manage_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The InterviewTemplateWrite object: { name:string } required; description?:string, roundName?:string, jobPostingId?:integer|null, questions?:[ InterviewQuestionInput ], each { questionText:string, category?, context?, idealAnswer?, weight?:integer, mustHave?:boolean, anchors?:object, order?:integer }.",
   "response": "201 InterviewTemplate (with questions)",
   "responses": [
    {
     "status": 201,
     "text": "InterviewTemplate (with questions)"
    }
   ],
   "errors": [],
   "guard": "Repeat: not deduplicated: every call creates another template, also with the same name"
  },
  {
   "id": "PUT /recruitment/interview-templates/:id",
   "group": "recruitment-interviews",
   "method": "PUT",
   "path": "/recruitment/interview-templates/:id",
   "purpose": "Update an interview template.",
   "auth": {
    "text": "Session + manage_recruitment (licensed)",
    "kind": "session",
    "permissions": [
     "manage_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The InterviewTemplateWrite object, all keys optional. Sending questions replaces the question list; leaving it out keeps the questions as they are.",
   "response": "200 updated InterviewTemplate",
   "responses": [
    {
     "status": 200,
     "text": "updated InterviewTemplate"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "DELETE /recruitment/interview-templates/:id",
   "group": "recruitment-interviews",
   "method": "DELETE",
   "path": "/recruitment/interview-templates/:id",
   "purpose": "Delete an interview template.",
   "auth": {
    "text": "Session + manage_recruitment (licensed)",
    "kind": "session",
    "permissions": [
     "manage_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "204 No Content",
   "responses": [
    {
     "status": 204,
     "text": "No Content"
    }
   ],
   "errors": [],
   "guard": "Repeat: a second call answers 404, because the template is gone"
  },
  {
   "id": "POST /recruitment/interview-templates/ai-generate",
   "group": "recruitment-interviews",
   "method": "POST",
   "path": "/recruitment/interview-templates/ai-generate",
   "purpose": "Generate interview questions with the local LLM.",
   "auth": {
    "text": "Session + manage_recruitment (licensed)",
    "kind": "session",
    "permissions": [
     "manage_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The DraftForRequisition object: { jobPostingId?:integer }, optional; the role is used as context when given.",
   "response": "200 { questions } (LLM-generated, HR-compliance-guarded) | 502 if generation fails",
   "responses": [
    {
     "status": 200,
     "text": "{ questions } (LLM-generated, HR-compliance-guarded)"
    },
    {
     "status": 502,
     "text": "if generation fails"
    }
   ],
   "errors": [
    {
     "status": 502,
     "text": "if generation fails"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /recruitment/settings/offer-gates",
   "group": "recruitment-offers",
   "method": "GET",
   "path": "/recruitment/settings/offer-gates",
   "purpose": "Read the scorecard-before-offer policy.",
   "auth": {
    "text": "Session + manage_recruitment | view_recruitment | approve_offer | manage_security | source_candidates | schedule_interviews | review_executive_packet | conduct_interview (licensed)",
    "kind": "session",
    "permissions": [
     "approve_offer",
     "conduct_interview",
     "manage_recruitment",
     "manage_security",
     "review_executive_packet",
     "schedule_interviews",
     "source_candidates",
     "view_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { requireScorecardsBeforeOffer:boolean, defaultValue:boolean }",
   "responses": [
    {
     "status": 200,
     "text": "{ requireScorecardsBeforeOffer:boolean, defaultValue:boolean }"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "PUT /recruitment/settings/offer-gates",
   "group": "recruitment-offers",
   "method": "PUT",
   "path": "/recruitment/settings/offer-gates",
   "purpose": "Turn the scorecard-before-offer policy on or off.",
   "auth": {
    "text": "Session + manage_recruitment | manage_security (licensed)",
    "kind": "session",
    "permissions": [
     "manage_recruitment",
     "manage_security"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The OfferGatePolicyWrite object: { requireScorecardsBeforeOffer:boolean } required.",
   "response": "200 { requireScorecardsBeforeOffer:boolean } | 400 when the value is not a boolean",
   "responses": [
    {
     "status": 200,
     "text": "{ requireScorecardsBeforeOffer:boolean }"
    },
    {
     "status": 400,
     "text": "when the value is not a boolean"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "when the value is not a boolean"
    }
   ],
   "guard": "the change is audited, at warning level when the policy is switched off. Repeat: safe; the one stored setting is written again with the same value"
  },
  {
   "id": "PUT /recruitment/candidates/:id/offer-terms",
   "group": "recruitment-offers",
   "method": "PUT",
   "path": "/recruitment/candidates/:id/offer-terms",
   "purpose": "Set or change the terms of an offer.",
   "auth": {
    "text": "Session + manage_recruitment | approve_offer (licensed)",
    "kind": "session",
    "permissions": [
     "approve_offer",
     "manage_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The OfferTermsWrite object: { salary?:number, startDate?:string (YYYY-MM-DD), currency?:string, payPeriod?:string, level?:string, targetBonusPct?:integer (0 to 100), signOnBonus?:number, hiringManagerId?:integer|null }. Only the keys sent are changed; null or an empty text clears a value.",
   "response": "200 the offer. 400 { error:string } for a start date that is not YYYY-MM-DD, a negative salary or sign-on bonus, or a target bonus outside 0 to 100 | 404 'Candidate not found'",
   "responses": [
    {
     "status": 200,
     "text": "the offer. 400 { error:string } for a start date that is not YYYY-MM-DD, a negative salary or sign-on bonus, or a target bonus outside 0 to 100"
    },
    {
     "status": 404,
     "text": "'Candidate not found'"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'Candidate not found'"
    }
   ],
   "guard": "the offer records who set the terms; that person cannot approve it"
  },
  {
   "id": "GET /recruitment/offers/pending-approval",
   "group": "recruitment-offers",
   "method": "GET",
   "path": "/recruitment/offers/pending-approval",
   "purpose": "Offers awaiting approval (the approver queue).",
   "auth": {
    "text": "Session + manage_recruitment | approve_offer | manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "approve_offer",
     "manage_finance",
     "manage_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 array of offers — a bare array, most recently updated first, at most 200; only offers that are Draft with approval Pending and whose candidate is still at the Offer stage. Each: { id, candidateId, candidateName, candidateEmail, stage, jobTitle, department, location, postedRange, salary, currency, payPeriod, level, targetBonusPct, signOnBonus, startDate, approvalStatus, status, termsSetBy, termsSetByName, selfApproval, updatedAt, termsVersion, matchScore, scoreSource, aiSummary, seniority, totalYears, skills, interviews }",
   "responses": [
    {
     "status": 200,
     "text": "array of offers — a bare array, most recently updated first, at most 200; only offers that are Draft with approval Pending and whose candidate is still at the Offer stage. Each: { id, candidateId, candidateName, candidateEmail, stage, jobTitle, department, location, postedRange, salary, currency, payPeriod, level, targetBonusPct, signOnBonus, startDate, approvalStatus, status, termsSetBy, termsSetByName, selfApproval, updatedAt, termsVersion, matchScore, scoreSource, aiSummary, seniority, totalYears, skills, interviews }"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /recruitment/offers/:id/signed-document",
   "group": "recruitment-offers",
   "method": "GET",
   "path": "/recruitment/offers/:id/signed-document",
   "purpose": "Download the sealed signed offer, or its certificate of completion.",
   "auth": {
    "text": "Session + manage_recruitment | view_recruitment (licensed)",
    "kind": "session",
    "permissions": [
     "manage_recruitment",
     "view_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "Query: type=certificate for the certificate; anything else returns the document.",
   "response": "200 application/pdf | 403 when the caller may not see offer compensation | 404 when no signed document exists yet",
   "responses": [
    {
     "status": 200,
     "text": "application/pdf"
    },
    {
     "status": 403,
     "text": "when the caller may not see offer compensation"
    },
    {
     "status": 404,
     "text": "when no signed document exists yet"
    }
   ],
   "errors": [
    {
     "status": 403,
     "text": "when the caller may not see offer compensation"
    },
    {
     "status": 404,
     "text": "when no signed document exists yet"
    }
   ],
   "guard": null
  },
  {
   "id": "POST /recruitment/candidates/:id/approve-offer",
   "group": "recruitment-offers",
   "method": "POST",
   "path": "/recruitment/candidates/:id/approve-offer",
   "purpose": "Approve an offer before it is sent.",
   "auth": {
    "text": "Session + manage_recruitment | approve_offer | manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "approve_offer",
     "manage_finance",
     "manage_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The OfferApprove object: { termsVersion?:string }, optional, cut to 64 characters: the version of the terms the approver saw, passed to the service as the expected version.",
   "response": "200 approved Offer | error status from service on failure (self-approval blocked) | 400 code OFFER_TERMS_MISSING when no start date is set | 403 code SELF_APPROVAL | 409 code CANDIDATE_REJECTED | 409 code OFFER_TERMS_CHANGED when termsVersion no longer matches the stored terms (also when the terms change while the approval is being written; the approval is then withdrawn)",
   "responses": [
    {
     "status": 200,
     "text": "approved Offer | error status from service on failure (self-approval blocked)"
    },
    {
     "status": 400,
     "text": "code OFFER_TERMS_MISSING when no start date is set"
    },
    {
     "status": 403,
     "text": "code SELF_APPROVAL"
    },
    {
     "status": 409,
     "text": "code CANDIDATE_REJECTED"
    },
    {
     "status": 409,
     "text": "code OFFER_TERMS_CHANGED when termsVersion no longer matches the stored terms (also when the terms change while the approval is being written; the approval is then withdrawn)"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "code OFFER_TERMS_MISSING when no start date is set"
    },
    {
     "status": 403,
     "text": "code SELF_APPROVAL"
    },
    {
     "status": 409,
     "text": "code CANDIDATE_REJECTED"
    },
    {
     "status": 409,
     "text": "code OFFER_TERMS_CHANGED when termsVersion no longer matches the stored terms (also when the terms change while the approval is being written; the approval is then withdrawn)"
    }
   ],
   "guard": "SoD in offerService: approver must differ from offer.termsSetBy (root-admin exempt); self-approval → warn-audited RECRUITMENT_OFFER_SELF_APPROVAL_BLOCKED"
  },
  {
   "id": "POST /recruitment/candidates/:id/reject-offer",
   "group": "recruitment-offers",
   "method": "POST",
   "path": "/recruitment/candidates/:id/reject-offer",
   "purpose": "Refuse approval of an offer (this does not reject the candidate).",
   "auth": {
    "text": "Session + manage_recruitment | approve_offer | manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "approve_offer",
     "manage_finance",
     "manage_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The OfferReject object: { reason?:string }.",
   "response": "200 the offer | 404 code OFFER_NOT_FOUND when the candidate has no offer | 409 code OFFER_ALREADY_SENT when the offer is no longer a draft. Safe to repeat while the offer is a draft: a repeat answers 200 again",
   "responses": [
    {
     "status": 200,
     "text": "the offer"
    },
    {
     "status": 404,
     "text": "code OFFER_NOT_FOUND when the candidate has no offer"
    },
    {
     "status": 409,
     "text": "code OFFER_ALREADY_SENT when the offer is no longer a draft. Safe to repeat while the offer is a draft: a repeat answers 200 again"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "code OFFER_NOT_FOUND when the candidate has no offer"
    },
    {
     "status": 409,
     "text": "code OFFER_ALREADY_SENT when the offer is no longer a draft. Safe to repeat while the offer is a draft: a repeat answers 200 again"
    }
   ],
   "guard": "Repeat: while the offer is still a draft a second call answers 200 and leaves it Rejected; once the offer has been sent the answer is 409 with code OFFER_ALREADY_SENT"
  },
  {
   "id": "POST /recruitment/candidates/:id/send-offer",
   "group": "recruitment-offers",
   "method": "POST",
   "path": "/recruitment/candidates/:id/send-offer",
   "purpose": "Generate the contract and dispatch it for signing via Zonitix Sign.",
   "auth": {
    "text": "Session + manage_recruitment | approve_offer (licensed)",
    "kind": "session",
    "permissions": [
     "approve_offer",
     "manage_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The OfferSend object: { offerTemplateId?:integer | null }. Generates the contract and sends it through Zonitix Sign.",
   "response": "200 result { submissionId } | 409 when the offer is not Approved | 404 candidate/template missing | 502 signature integration | 503 not configured | 409 code OFFER_TERMINAL when the offer is already accepted or declined",
   "responses": [
    {
     "status": 200,
     "text": "result { submissionId }"
    },
    {
     "status": 409,
     "text": "when the offer is not Approved"
    },
    {
     "status": 404,
     "text": "candidate/template missing"
    },
    {
     "status": 502,
     "text": "signature integration"
    },
    {
     "status": 503,
     "text": "not configured"
    },
    {
     "status": 409,
     "text": "code OFFER_TERMINAL when the offer is already accepted or declined"
    }
   ],
   "errors": [
    {
     "status": 409,
     "text": "when the offer is not Approved"
    },
    {
     "status": 404,
     "text": "candidate/template missing"
    },
    {
     "status": 502,
     "text": "signature integration"
    },
    {
     "status": 503,
     "text": "not configured"
    },
    {
     "status": 409,
     "text": "code OFFER_TERMINAL when the offer is already accepted or declined"
    }
   ],
   "guard": "offer must be in Approved state before send (workflow precondition). Repeat: not a no-op: while the offer is a draft or already sent, a second call sends it for signature again; once it is accepted or declined the answer is 409 with code OFFER_TERMINAL"
  },
  {
   "id": "POST /recruitment/candidates/:id/mark-signed",
   "group": "recruitment-offers",
   "method": "POST",
   "path": "/recruitment/candidates/:id/mark-signed",
   "purpose": "Record an offer as signed outside Zonitix Sign (paper or manual signature).",
   "auth": {
    "text": "Session + manage_recruitment (licensed)",
    "kind": "session",
    "permissions": [
     "manage_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { success:true, offer } | the service error status with { error:string, code?:string } — 404 code OFFER_NOT_FOUND, 409 code OFFER_TERMINAL (declined), 400 code OFFER_TERMS_MISSING (no start date), 409 code OFFER_NOT_APPROVED",
   "responses": [
    {
     "status": 200,
     "text": "{ success:true, offer } | the service error status with { error:string, code?:string } — 404 code OFFER_NOT_FOUND, 409 code OFFER_TERMINAL (declined), 400 code OFFER_TERMS_MISSING (no start date), 409 code OFFER_NOT_APPROVED"
    }
   ],
   "errors": [],
   "guard": "Repeat: safe; an offer that is already accepted is returned unchanged"
  },
  {
   "id": "POST /recruitment/candidates/:id/withdraw-offer",
   "group": "recruitment-offers",
   "method": "POST",
   "path": "/recruitment/candidates/:id/withdraw-offer",
   "purpose": "Withdraw a sent offer.",
   "auth": {
    "text": "Session + manage_recruitment (licensed)",
    "kind": "session",
    "permissions": [
     "manage_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { offer, signingRevoked } | 404 code OFFER_NOT_FOUND | 409 code OFFER_NOT_WITHDRAWABLE unless the offer is currently Sent",
   "responses": [
    {
     "status": 200,
     "text": "{ offer, signingRevoked }"
    },
    {
     "status": 404,
     "text": "code OFFER_NOT_FOUND"
    },
    {
     "status": 409,
     "text": "code OFFER_NOT_WITHDRAWABLE unless the offer is currently Sent"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "code OFFER_NOT_FOUND"
    },
    {
     "status": 409,
     "text": "code OFFER_NOT_WITHDRAWABLE unless the offer is currently Sent"
    }
   ],
   "guard": "Repeat: a second call answers 409 with code OFFER_NOT_WITHDRAWABLE, because the offer is no longer Sent"
  },
  {
   "id": "PATCH /recruitment/candidates/:id/offer/decline-reason",
   "group": "recruitment-offers",
   "method": "PATCH",
   "path": "/recruitment/candidates/:id/offer/decline-reason",
   "purpose": "Record or clear why a candidate declined.",
   "auth": {
    "text": "Session + manage_recruitment (licensed)",
    "kind": "session",
    "permissions": [
     "manage_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The OfferDeclineReason object: { reason:string }; empty clears it.",
   "response": "200 the Offer as stored (salary is null in this reply; read amounts from the candidate list)",
   "responses": [
    {
     "status": 200,
     "text": "the Offer as stored (salary is null in this reply; read amounts from the candidate list)"
    }
   ],
   "errors": [],
   "guard": "Repeat: safe; the same reason is stored again"
  },
  {
   "id": "POST /recruitment/candidates/:id/rescind-request",
   "group": "recruitment-offers",
   "method": "POST",
   "path": "/recruitment/candidates/:id/rescind-request",
   "purpose": "Request rescission of a signed offer (step one of two).",
   "auth": {
    "text": "Session + manage_recruitment | approve_offer (licensed)",
    "kind": "session",
    "permissions": [
     "approve_offer",
     "manage_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The OfferRescindRequest object: { reason:string } required.",
   "response": "200 { status:'requested' } | the service error status with { error:string, code?:string } — 404 code OFFER_NOT_FOUND, 409 code NOT_SIGNED, 409 code ALREADY_REQUESTED (what a repeat gets), 400 code REASON_REQUIRED",
   "responses": [
    {
     "status": 200,
     "text": "{ status:'requested' } | the service error status with { error:string, code?:string } — 404 code OFFER_NOT_FOUND, 409 code NOT_SIGNED, 409 code ALREADY_REQUESTED (what a repeat gets), 400 code REASON_REQUIRED"
    }
   ],
   "errors": [],
   "guard": "Repeat: a second call answers 409 with code ALREADY_REQUESTED while the first request is waiting for approval"
  },
  {
   "id": "POST /recruitment/candidates/:id/rescind-approve",
   "group": "recruitment-offers",
   "method": "POST",
   "path": "/recruitment/candidates/:id/rescind-approve",
   "purpose": "Approve a requested rescission (step two of two).",
   "auth": {
    "text": "Session + manage_recruitment | approve_offer (licensed)",
    "kind": "session",
    "permissions": [
     "approve_offer",
     "manage_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { status:'approved' } | the service error status with { error:string, code?:string } — 404 code OFFER_NOT_FOUND, 409 code NOT_REQUESTED when no rescission is pending, 403 code SELF_APPROVAL for the person who requested it",
   "responses": [
    {
     "status": 200,
     "text": "{ status:'approved' } | the service error status with { error:string, code?:string } — 404 code OFFER_NOT_FOUND, 409 code NOT_REQUESTED when no rescission is pending, 403 code SELF_APPROVAL for the person who requested it"
    }
   ],
   "errors": [],
   "guard": "Repeat: a second call answers 409 with code NOT_REQUESTED, because no rescission is pending any more"
  },
  {
   "id": "GET /recruitment/offer-templates",
   "group": "recruitment-offers",
   "method": "GET",
   "path": "/recruitment/offer-templates",
   "purpose": "List offer templates.",
   "auth": {
    "text": "Session + manage_recruitment | view_recruitment | approve_offer (licensed)",
    "kind": "session",
    "permissions": [
     "approve_offer",
     "manage_recruitment",
     "view_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query: jobPostingId?",
   "response": "200 bare array of OfferTemplate, each with usageCount; not paged — jobPostingId is the only filter",
   "responses": [
    {
     "status": 200,
     "text": "bare array of OfferTemplate, each with usageCount; not paged — jobPostingId is the only filter"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /recruitment/offer-templates",
   "group": "recruitment-offers",
   "method": "POST",
   "path": "/recruitment/offer-templates",
   "purpose": "Create an offer template.",
   "auth": {
    "text": "Session + manage_recruitment (licensed)",
    "kind": "session",
    "permissions": [
     "manage_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The OfferTemplateWrite object: { name:string, body:string } required; jobPostingId?:integer|null (null for every requisition), supportingDocs?:object[] (at most 20).",
   "response": "201 OfferTemplate",
   "responses": [
    {
     "status": 201,
     "text": "OfferTemplate"
    }
   ],
   "errors": [],
   "guard": "Repeat: not deduplicated: every call creates another template, also with the same name"
  },
  {
   "id": "PUT /recruitment/offer-templates/:id",
   "group": "recruitment-offers",
   "method": "PUT",
   "path": "/recruitment/offer-templates/:id",
   "purpose": "Update an offer template.",
   "auth": {
    "text": "Session + manage_recruitment (licensed)",
    "kind": "session",
    "permissions": [
     "manage_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The OfferTemplateWrite object, all keys optional; only the keys sent are changed.",
   "response": "200 updated OfferTemplate",
   "responses": [
    {
     "status": 200,
     "text": "updated OfferTemplate"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "DELETE /recruitment/offer-templates/:id",
   "group": "recruitment-offers",
   "method": "DELETE",
   "path": "/recruitment/offer-templates/:id",
   "purpose": "Delete an offer template.",
   "auth": {
    "text": "Session + manage_recruitment (licensed)",
    "kind": "session",
    "permissions": [
     "manage_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "204 No Content",
   "responses": [
    {
     "status": 204,
     "text": "No Content"
    }
   ],
   "errors": [],
   "guard": "Repeat: a second call answers 404, because the template is gone"
  },
  {
   "id": "POST /recruitment/offer-templates/ai-draft",
   "group": "recruitment-offers",
   "method": "POST",
   "path": "/recruitment/offer-templates/ai-draft",
   "purpose": "Draft an offer template with the local LLM.",
   "auth": {
    "text": "Session + manage_recruitment (licensed)",
    "kind": "session",
    "permissions": [
     "manage_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The DraftForRequisition object: { jobPostingId?:integer }, optional; the role and its hiring manager are used as context when given.",
   "response": "200 { body:string } (LLM-drafted offer body) | 502 if generation fails",
   "responses": [
    {
     "status": 200,
     "text": "{ body:string } (LLM-drafted offer body)"
    },
    {
     "status": 502,
     "text": "if generation fails"
    }
   ],
   "errors": [
    {
     "status": 502,
     "text": "if generation fails"
    }
   ],
   "guard": null
  },
  {
   "id": "POST /recruitment/offer-documents",
   "group": "recruitment-offers",
   "method": "POST",
   "path": "/recruitment/offer-documents",
   "purpose": "Upload a supporting file for an offer template.",
   "auth": {
    "text": "Session + manage_recruitment (licensed)",
    "kind": "session",
    "permissions": [
     "manage_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "multipart/form-data: file (one file, at most 10 MB); label?:string (cut to 120 characters, defaults to the file name).",
   "response": "200 { id:string (a UUID), type:'file', label:string, filename:string, size:integer (bytes), storedName:string } | 400 no file, file too large, or a file type that is not allowed",
   "responses": [
    {
     "status": 200,
     "text": "{ id:string (a UUID), type:'file', label:string, filename:string, size:integer (bytes), storedName:string }"
    },
    {
     "status": 400,
     "text": "no file, file too large, or a file type that is not allowed"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "no file, file too large, or a file type that is not allowed"
    }
   ],
   "guard": "Repeat: not deduplicated: every call stores the file again under a new storedName"
  },
  {
   "id": "GET /recruitment/offer-documents/:storedName",
   "group": "recruitment-offers",
   "method": "GET",
   "path": "/recruitment/offer-documents/:storedName",
   "purpose": "Download a stored offer supporting file.",
   "auth": {
    "text": "Session + manage_recruitment | view_recruitment | approve_offer (licensed)",
    "kind": "session",
    "permissions": [
     "approve_offer",
     "manage_recruitment",
     "view_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "storedName",
     "type": "string",
     "basis": "unknown"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 the file | 404 'File not found'",
   "responses": [
    {
     "status": 200,
     "text": "the file"
    },
    {
     "status": 404,
     "text": "'File not found'"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'File not found'"
    }
   ],
   "guard": null
  },
  {
   "id": "POST /recruitment/chat",
   "group": "recruitment-insights",
   "method": "POST",
   "path": "/recruitment/chat",
   "purpose": "Recruitment assistant chat.",
   "auth": {
    "text": "Session + manage_recruitment | view_recruitment | source_candidates (licensed)",
    "kind": "session",
    "permissions": [
     "manage_recruitment",
     "source_candidates",
     "view_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The AssistantQuery object: { query:string }.",
   "response": "200 { response:string } | 500 { error:string }",
   "responses": [
    {
     "status": 200,
     "text": "{ response:string }"
    },
    {
     "status": 500,
     "text": "{ error:string }"
    }
   ],
   "errors": [
    {
     "status": 500,
     "text": "{ error:string }"
    }
   ],
   "guard": "AI context scoped to candidates/jobs THIS caller may see (no cross-user PII leak)"
  },
  {
   "id": "POST /recruitment/chat/stream",
   "group": "recruitment-insights",
   "method": "POST",
   "path": "/recruitment/chat/stream",
   "purpose": "Recruitment assistant chat, streamed.",
   "auth": {
    "text": "Session + manage_recruitment | view_recruitment | source_candidates (licensed)",
    "kind": "session",
    "permissions": [
     "manage_recruitment",
     "source_candidates",
     "view_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The AssistantQuery object: { query:string }, not empty.",
   "response": "200 application/x-ndjson stream | 400 when query is missing or empty",
   "responses": [
    {
     "status": 200,
     "text": "application/x-ndjson stream"
    },
    {
     "status": 400,
     "text": "when query is missing or empty"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "when query is missing or empty"
    }
   ],
   "guard": null
  },
  {
   "id": "POST /recruitment/chat/refine",
   "group": "recruitment-insights",
   "method": "POST",
   "path": "/recruitment/chat/refine",
   "purpose": "Refine drafted text.",
   "auth": {
    "text": "Session + manage_recruitment | conduct_interview | review_executive_packet (licensed)",
    "kind": "session",
    "permissions": [
     "conduct_interview",
     "manage_recruitment",
     "review_executive_packet"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The FeedbackRefine object: { text:string }.",
   "response": "200 { refinedText:string } | 500 { error:string }",
   "responses": [
    {
     "status": 200,
     "text": "{ refinedText:string }"
    },
    {
     "status": 500,
     "text": "{ error:string }"
    }
   ],
   "errors": [
    {
     "status": 500,
     "text": "{ error:string }"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /recruitment/referrals",
   "group": "recruitment-insights",
   "method": "GET",
   "path": "/recruitment/referrals",
   "purpose": "List your own employee referrals.",
   "auth": {
    "text": "Session + view_referrals | manage_referrals (licensed)",
    "kind": "session",
    "permissions": [
     "manage_referrals",
     "view_referrals"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters; not paged.",
   "response": "200 [ Referral ]: the caller's own, newest first, each with its Candidate",
   "responses": [
    {
     "status": 200,
     "text": "[ Referral ]: the caller's own, newest first, each with its Candidate"
    }
   ],
   "errors": [],
   "guard": "only the signed-in user's own referrals; screening results and internal notes about the candidate are not included"
  },
  {
   "id": "POST /recruitment/referrals",
   "group": "recruitment-insights",
   "method": "POST",
   "path": "/recruitment/referrals",
   "purpose": "Submit a referral with a resume (multipart).",
   "auth": {
    "text": "Session + view_referrals | manage_referrals (licensed)",
    "kind": "session",
    "permissions": [
     "manage_referrals",
     "view_referrals"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "multipart/form-data: firstName, lastName, email, jobPostingId (required), phoneNumber?, notes? as text fields, plus an optional resume file. With a file the referral is screened against the role.",
   "response": "201 Referral (creates a Referral-sourced Candidate; AI-screens if résumé attached) | 409 code CANDIDATE_ALREADY_EXISTS when that person is already a candidate for the role | 404 code JOB_NOT_FOUND",
   "responses": [
    {
     "status": 201,
     "text": "Referral (creates a Referral-sourced Candidate; AI-screens if résumé attached)"
    },
    {
     "status": 409,
     "text": "code CANDIDATE_ALREADY_EXISTS when that person is already a candidate for the role"
    },
    {
     "status": 404,
     "text": "code JOB_NOT_FOUND"
    }
   ],
   "errors": [
    {
     "status": 409,
     "text": "code CANDIDATE_ALREADY_EXISTS when that person is already a candidate for the role"
    },
    {
     "status": 404,
     "text": "code JOB_NOT_FOUND"
    }
   ],
   "guard": "scoped to the signed-in user. Repeat: a second referral of the same email for the same role answers 409 and creates nothing"
  },
  {
   "id": "GET /recruitment/analytics/pipeline",
   "group": "recruitment-insights",
   "method": "GET",
   "path": "/recruitment/analytics/pipeline",
   "purpose": "Pipeline analytics.",
   "auth": {
    "text": "Session + manage_recruitment | view_recruitment (licensed)",
    "kind": "session",
    "permissions": [
     "manage_recruitment",
     "view_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { perStage, bottleneck:{ stage, avgDays } | null, timeToHireDays, timeToHireSamples, stalled, avgInStageDays, agingDays, eventCount }",
   "responses": [
    {
     "status": 200,
     "text": "{ perStage, bottleneck:{ stage, avgDays } | null, timeToHireDays, timeToHireSamples, stalled, avgInStageDays, agingDays, eventCount }"
    }
   ],
   "errors": [],
   "guard": "figures cover only the candidates the caller may see"
  },
  {
   "id": "GET /recruitment/analytics/provisioning-impact/:id",
   "group": "recruitment-insights",
   "method": "GET",
   "path": "/recruitment/analytics/provisioning-impact/:id",
   "purpose": "Cost/provisioning impact of a hire.",
   "auth": {
    "text": "Session + manage_recruitment | view_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_recruitment",
     "view_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "string",
     "basis": "unknown"
    }
   ],
   "paging": [],
   "query": [],
   "request": ":id is the job posting id. No query parameters.",
   "response": "200 { jobId, jobTitle, costSource: persona | none, isUsingPersona, personaName, oneTimeHardwareCost, monthlySaaSBound, annualImpact, availableDevices, unitsNeeded, inventoryStatus: LOW_STOCK | HEALTHY, recommendation } — the three cost figures are null when the requisition has no persona",
   "responses": [
    {
     "status": 200,
     "text": "{ jobId, jobTitle, costSource: persona | none, isUsingPersona, personaName, oneTimeHardwareCost, monthlySaaSBound, annualImpact, availableDevices, unitsNeeded, inventoryStatus: LOW_STOCK | HEALTHY, recommendation } — the three cost figures are null when the requisition has no persona"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /recruitment/reports/hiring",
   "group": "recruitment-insights",
   "method": "GET",
   "path": "/recruitment/reports/hiring",
   "purpose": "Consolidated hiring report: funnel, stage conversion, time to hire, source effectiveness and offers.",
   "auth": {
    "text": "Session + manage_recruitment | view_recruitment | review_executive_packet (licensed)",
    "kind": "session",
    "permissions": [
     "manage_recruitment",
     "review_executive_packet",
     "view_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query: from?, to? (dates).",
   "response": "200 { generatedAt:string (date-time), window:{ from:string|null, to:string|null }|null (null for all time), funnel:[{ stage:string, count:integer }], totals:{ total, hired, rejected, hiredThisMonth, hiredLastMonth } (integers), conversion:[{ from:string, to:string, reached:integer, advanced:integer, rate:integer (a whole percentage) }], efficiency:{ timeToHireDays, timeToHireSamples, bottleneck, avgInStageDays, stalled, agingDays }, sources, offers, openRequisitions:integer } — efficiency, sources and offers are not typed here",
   "responses": [
    {
     "status": 200,
     "text": "{ generatedAt:string (date-time), window:{ from:string|null, to:string|null }|null (null for all time), funnel:[{ stage:string, count:integer }], totals:{ total, hired, rejected, hiredThisMonth, hiredLastMonth } (integers), conversion:[{ from:string, to:string, reached:integer, advanced:integer, rate:integer (a whole percentage) }], efficiency:{ timeToHireDays, timeToHireSamples, bottleneck, avgInStageDays, stalled, agingDays }, sources, offers, openRequisitions:integer } — efficiency, sources and offers are not typed here"
    }
   ],
   "errors": [],
   "guard": "figures cover only the candidates the caller may see"
  },
  {
   "id": "GET /recruitment/insights",
   "group": "recruitment-insights",
   "method": "GET",
   "path": "/recruitment/insights",
   "purpose": "LLM-generated pipeline insights.",
   "auth": {
    "text": "Session + manage_recruitment | view_recruitment (licensed)",
    "kind": "session",
    "permissions": [
     "manage_recruitment",
     "view_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { insights:array, generatedAt, aiAvailable:boolean } — aiAvailable is false and insights is empty when the local model returned nothing",
   "responses": [
    {
     "status": 200,
     "text": "{ insights:array, generatedAt, aiAvailable:boolean } — aiAvailable is false and insights is empty when the local model returned nothing"
    }
   ],
   "errors": [],
   "guard": "one generated result is cached and shared by every caller; it is regenerated only when the cache has expired and the pipeline snapshot has changed"
  },
  {
   "id": "GET /recruitment/careers-settings",
   "group": "recruitment-insights",
   "method": "GET",
   "path": "/recruitment/careers-settings",
   "purpose": "Read the careers-page content and feature switches.",
   "auth": {
    "text": "Session + manage_recruitment (licensed)",
    "kind": "session",
    "permissions": [
     "manage_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { intro, whereWeWork, teamSize, replyTime, topBenefit, linkedinUrl, privacyContact: each string; talentPoolEnabled:boolean, alertsEnabled:boolean } — text fields default to an empty string, both switches to true",
   "responses": [
    {
     "status": 200,
     "text": "{ intro, whereWeWork, teamSize, replyTime, topBenefit, linkedinUrl, privacyContact: each string; talentPoolEnabled:boolean, alertsEnabled:boolean } — text fields default to an empty string, both switches to true"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "PUT /recruitment/careers-settings",
   "group": "recruitment-insights",
   "method": "PUT",
   "path": "/recruitment/careers-settings",
   "purpose": "Update the careers-page content and feature switches.",
   "auth": {
    "text": "Session + manage_recruitment (licensed)",
    "kind": "session",
    "permissions": [
     "manage_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Partial of the same fields; only keys present are applied. Text is trimmed and cut to its maximum: intro 600, whereWeWork 120, teamSize 60, replyTime 60, topBenefit 120, linkedinUrl 300, privacyContact 200. talentPoolEnabled and alertsEnabled are booleans.",
   "response": "200 the saved settings (all fields) | 400 code BAD_URL when linkedinUrl is not an https:// address | 400 code BAD_PRIVACY_CONTACT when privacyContact is neither an email address nor an https:// link",
   "responses": [
    {
     "status": 200,
     "text": "the saved settings (all fields)"
    },
    {
     "status": 400,
     "text": "code BAD_URL when linkedinUrl is not an https:// address"
    },
    {
     "status": 400,
     "text": "code BAD_PRIVACY_CONTACT when privacyContact is neither an email address nor an https:// link"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "code BAD_URL when linkedinUrl is not an https:// address"
    },
    {
     "status": 400,
     "text": "code BAD_PRIVACY_CONTACT when privacyContact is neither an email address nor an https:// link"
    }
   ],
   "guard": "changed fields are audited; safe to repeat — a call that changes nothing saves nothing and writes no audit entry"
  },
  {
   "id": "GET /recruitment/public/jobs",
   "group": "recruitment-public",
   "method": "GET",
   "path": "/recruitment/public/jobs",
   "purpose": "Public job board listing.",
   "auth": {
    "text": "Public (rate-limited, licensed)",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters; not paged.",
   "response": "200 bare array of Published roles (the talent-pool requisition excluded), newest first: { id:integer, title:string, department:string|null, location:string|null, description:string|null, createdAt:string (date-time), employmentType:string|null, workMode:string|null, salaryMin:integer|null, salaryMax:integer|null, currency:string|null, payPeriod:string|null, seniority:string|null, competencies:array, applicationQuestions:[{ id, label, type, required, options }] (a key is left out when the question does not have it) }",
   "responses": [
    {
     "status": 200,
     "text": "bare array of Published roles (the talent-pool requisition excluded), newest first: { id:integer, title:string, department:string|null, location:string|null, description:string|null, createdAt:string (date-time), employmentType:string|null, workMode:string|null, salaryMin:integer|null, salaryMax:integer|null, currency:string|null, payPeriod:string|null, seniority:string|null, competencies:array, applicationQuestions:[{ id, label, type, required, options }] (a key is left out when the question does not have it) }"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /recruitment/public/jobs/:id",
   "group": "recruitment-public",
   "method": "GET",
   "path": "/recruitment/public/jobs/:id",
   "purpose": "One public job posting (numeric id).",
   "auth": {
    "text": "Public (rate-limited, licensed)",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 the posting | 404 { error:string } when the position is no longer open — same fields as one row of the list",
   "responses": [
    {
     "status": 200,
     "text": "the posting"
    },
    {
     "status": 404,
     "text": "{ error:string } when the position is no longer open — same fields as one row of the list"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "{ error:string } when the position is no longer open — same fields as one row of the list"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /recruitment/public/jobs/jsonld",
   "group": "recruitment-public",
   "method": "GET",
   "path": "/recruitment/public/jobs/jsonld",
   "purpose": "Open roles as schema.org JobPosting JSON-LD.",
   "auth": {
    "text": "Public (rate-limited, licensed)",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 bare array of schema.org JobPosting objects, one per role in the public list; a role without a pay band has no baseSalary; not paged",
   "responses": [
    {
     "status": 200,
     "text": "bare array of schema.org JobPosting objects, one per role in the public list; a role without a pay band has no baseSalary; not paged"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /recruitment/public/jobs/:id/jsonld",
   "group": "recruitment-public",
   "method": "GET",
   "path": "/recruitment/public/jobs/:id/jsonld",
   "purpose": "One open role as schema.org JobPosting JSON-LD.",
   "auth": {
    "text": "Public (rate-limited, licensed)",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 JSON-LD | 404 { error:string } when the position is no longer open",
   "responses": [
    {
     "status": 200,
     "text": "JSON-LD"
    },
    {
     "status": 404,
     "text": "{ error:string } when the position is no longer open"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "{ error:string } when the position is no longer open"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /recruitment/public/jobs/indeed.xml",
   "group": "recruitment-public",
   "method": "GET",
   "path": "/recruitment/public/jobs/indeed.xml",
   "purpose": "Job feed in the Indeed XML format.",
   "auth": {
    "text": "Public (rate-limited, licensed)",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 application/xml: one feed holding every Published role (the same roles as GET /public/jobs); not paged",
   "responses": [
    {
     "status": 200,
     "text": "application/xml: one feed holding every Published role (the same roles as GET /public/jobs); not paged"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /recruitment/public/jobs/linkedin.xml",
   "group": "recruitment-public",
   "method": "GET",
   "path": "/recruitment/public/jobs/linkedin.xml",
   "purpose": "Job feed in the LinkedIn XML format.",
   "auth": {
    "text": "Public (rate-limited, licensed)",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 application/xml: one feed holding every Published role (the same roles as GET /public/jobs); not paged",
   "responses": [
    {
     "status": 200,
     "text": "application/xml: one feed holding every Published role (the same roles as GET /public/jobs); not paged"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /recruitment/public/careers",
   "group": "recruitment-public",
   "method": "GET",
   "path": "/recruitment/public/careers",
   "purpose": "Careers-page content: company name and logo, intro and facts, which features are on, and the department list for filters. Open roles come from GET /recruitment/public/jobs.",
   "auth": {
    "text": "Public (rate-limited, licensed)",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { companyName:string, logoUrl:string|null, intro:string|null, facts:{ whereWeWork, teamSize, replyTime, topBenefit: each string|null }, linkedinUrl:string|null, privacyContact:string|null, talentPoolEnabled:boolean, alertsEnabled:boolean, departments:string[] } — unset text is null; departments lists those with a Published role, sorted",
   "responses": [
    {
     "status": 200,
     "text": "{ companyName:string, logoUrl:string|null, intro:string|null, facts:{ whereWeWork, teamSize, replyTime, topBenefit: each string|null }, linkedinUrl:string|null, privacyContact:string|null, talentPoolEnabled:boolean, alertsEnabled:boolean, departments:string[] } — unset text is null; departments lists those with a Published role, sorted"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /recruitment/public/apply/:jobId",
   "group": "recruitment-public",
   "method": "POST",
   "path": "/recruitment/public/apply/:jobId",
   "purpose": "Submit an application from the careers site.",
   "auth": {
    "text": "Public (rate-limited, also per email, licensed)",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "jobId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "multipart/form-data: firstName (required, cut to 100 characters), email (required, must look like an address, cut to 200), lastName? (100), phoneNumber? (40), consent (must be the string true), resume? (one file, at most 5 MB, .pdf, .docx or .txt), applicationAnswers? (a JSON string keyed by question id), humanReviewRequested? (true skips automated résumé scoring), aiNoticeAck?, source? (indeed | linkedin | careers; anything else is recorded as Career Site)",
   "response": "201 { success:true } | 400 { error:string } for a missing first name or invalid email, a file over 5 MB or of another type, consent not given, or an unanswered required question | 404 { error:string } 'This position is no longer open.' — any role that is not Published",
   "responses": [
    {
     "status": 201,
     "text": "{ success:true }"
    },
    {
     "status": 400,
     "text": "{ error:string } for a missing first name or invalid email, a file over 5 MB or of another type, consent not given, or an unanswered required question"
    },
    {
     "status": 404,
     "text": "{ error:string } 'This position is no longer open.' — any role that is not Published"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ error:string } for a missing first name or invalid email, a file over 5 MB or of another type, consent not given, or an unanswered required question"
    },
    {
     "status": 404,
     "text": "{ error:string } 'This position is no longer open.' — any role that is not Published"
    }
   ],
   "guard": "a second application with the same email for the same role also answers 201 and creates nothing, so a repeat is safe and the response does not reveal whether the email is known"
  },
  {
   "id": "POST /recruitment/public/apply-general",
   "group": "recruitment-public",
   "method": "POST",
   "path": "/recruitment/public/apply-general",
   "purpose": "Submit a general application into the talent pool, with no specific role.",
   "auth": {
    "text": "Public (rate-limited, also per email, licensed)",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Same multipart fields as POST /recruitment/public/apply/:jobId.",
   "response": "201 { success:true } | 400 { error:string } as for a role application | 404 { error:string } when the talent pool is switched off in the careers settings",
   "responses": [
    {
     "status": 201,
     "text": "{ success:true }"
    },
    {
     "status": 400,
     "text": "{ error:string } as for a role application"
    },
    {
     "status": 404,
     "text": "{ error:string } when the talent pool is switched off in the careers settings"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ error:string } as for a role application"
    },
    {
     "status": 404,
     "text": "{ error:string } when the talent pool is switched off in the careers settings"
    }
   ],
   "guard": "lands in the single General applications requisition; a repeat with the same email answers 201 and creates nothing"
  },
  {
   "id": "POST /recruitment/public/job-alerts",
   "group": "recruitment-public",
   "method": "POST",
   "path": "/recruitment/public/job-alerts",
   "purpose": "Subscribe an email address to job alerts (double opt-in).",
   "auth": {
    "text": "Public (rate-limited, also per email, licensed)",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The JobAlertSubscribe object: { email:string, consent:true } required; department?:string, locale?:string. email is cut to 200 characters, department to 120, locale to 8.",
   "response": "202 { success:true } for every valid request | 400 code BAD_EMAIL | 400 code CONSENT_REQUIRED | 404 code ALERTS_OFF when job alerts are switched off. Safe to repeat: the confirmation email is sent at most once per 10 minutes per address, and an already confirmed subscription is left as it is",
   "responses": [
    {
     "status": 202,
     "text": "{ success:true } for every valid request"
    },
    {
     "status": 400,
     "text": "code BAD_EMAIL"
    },
    {
     "status": 400,
     "text": "code CONSENT_REQUIRED"
    },
    {
     "status": 404,
     "text": "code ALERTS_OFF when job alerts are switched off. Safe to repeat: the confirmation email is sent at most once per 10 minutes per address, and an already confirmed subscription is left as it is"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "code BAD_EMAIL"
    },
    {
     "status": 400,
     "text": "code CONSENT_REQUIRED"
    },
    {
     "status": 404,
     "text": "code ALERTS_OFF when job alerts are switched off. Safe to repeat: the confirmation email is sent at most once per 10 minutes per address, and an already confirmed subscription is left as it is"
    }
   ],
   "guard": "Repeat: safe; an address that is already confirmed for the same department is left as it is and gets no email, and a confirmation email is sent at most once per 10 minutes per address"
  },
  {
   "id": "POST /recruitment/public/job-alerts/confirm",
   "group": "recruitment-public",
   "method": "POST",
   "path": "/recruitment/public/job-alerts/confirm",
   "purpose": "Confirm a job-alert subscription from the emailed link.",
   "auth": {
    "text": "Public (rate-limited, licensed)",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The JobAlertToken object: { token:string }.",
   "response": "200 { confirmed:true } | 404 { error:string } when the link is invalid or expired. Confirming twice does not change the stored confirmation time",
   "responses": [
    {
     "status": 200,
     "text": "{ confirmed:true }"
    },
    {
     "status": 404,
     "text": "{ error:string } when the link is invalid or expired. Confirming twice does not change the stored confirmation time"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "{ error:string } when the link is invalid or expired. Confirming twice does not change the stored confirmation time"
    }
   ],
   "guard": "Repeat: safe; a second call answers 200 and keeps the first confirmation time, and writes another JOB_ALERT_CONFIRMED audit entry"
  },
  {
   "id": "POST /recruitment/public/job-alerts/unsubscribe",
   "group": "recruitment-public",
   "method": "POST",
   "path": "/recruitment/public/job-alerts/unsubscribe",
   "purpose": "Withdraw a job-alert subscription.",
   "auth": {
    "text": "Public (rate-limited, licensed)",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The JobAlertToken object: { token:string }.",
   "response": "200 { unsubscribed:true, found:boolean } — the subscription row is deleted; a repeat answers 200 with found:false",
   "responses": [
    {
     "status": 200,
     "text": "{ unsubscribed:true, found:boolean } — the subscription row is deleted; a repeat answers 200 with found:false"
    }
   ],
   "errors": [],
   "guard": "Repeat: safe; the subscription is deleted by the first call and a second answers 200 with found:false"
  },
  {
   "id": "GET /recruitment/public/branding",
   "group": "recruitment-public",
   "method": "GET",
   "path": "/recruitment/public/branding",
   "purpose": "Company name, logo and brand colour for the candidate portal.",
   "auth": {
    "text": "Public (rate-limited)",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { companyName:string, logoUrl:string|null, brandColor:string }",
   "responses": [
    {
     "status": 200,
     "text": "{ companyName:string, logoUrl:string|null, brandColor:string }"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /recruitment/public/self-schedule/:token",
   "group": "recruitment-public",
   "method": "GET",
   "path": "/recruitment/public/self-schedule/:token",
   "purpose": "Candidate self-scheduling page data, addressed by a one-time token.",
   "auth": {
    "text": "Public (rate-limited)",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "token",
     "type": "string",
     "basis": "unknown"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 the offered times | the service error status (400 by default) with { error:string, code:string, alreadyScheduled:boolean } — 404 code INVALID_TOKEN, 410 code CANCELLED, CLOSED, ALREADY_SCHEDULED or SLOTS_EXPIRED",
   "responses": [
    {
     "status": 200,
     "text": "the offered times | the service error status (400 by default) with { error:string, code:string, alreadyScheduled:boolean } — 404 code INVALID_TOKEN, 410 code CANCELLED, CLOSED, ALREADY_SCHEDULED or SLOTS_EXPIRED"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /recruitment/public/self-schedule/:token",
   "group": "recruitment-public",
   "method": "POST",
   "path": "/recruitment/public/self-schedule/:token",
   "purpose": "Candidate books one of the offered times.",
   "auth": {
    "text": "Public (rate-limited)",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "token",
     "type": "string",
     "basis": "unknown"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The SelfScheduleChoice object: { chosen:string (date-time) }: one of the times offered by GET on the same path.",
   "response": "200 { ok:true, scheduledAt:string (date-time) } | the service error status (400 by default) with { error:string, code:string } — 404 code INVALID_TOKEN, 410 code CANCELLED or CLOSED, 400 code SLOT_NOT_OFFERED, 409 code SLOT_PAST, SLOT_TAKEN or ALREADY_SCHEDULED (what a repeat gets once a time is booked)",
   "responses": [
    {
     "status": 200,
     "text": "{ ok:true, scheduledAt:string (date-time) } | the service error status (400 by default) with { error:string, code:string } — 404 code INVALID_TOKEN, 410 code CANCELLED or CLOSED, 400 code SLOT_NOT_OFFERED, 409 code SLOT_PAST, SLOT_TAKEN or ALREADY_SCHEDULED (what a repeat gets once a time is booked)"
    }
   ],
   "errors": [],
   "guard": "Repeat: a second call answers 409 with code ALREADY_SCHEDULED once a time is booked"
  },
  {
   "id": "GET /recruitment/public/portal/:token",
   "group": "recruitment-public",
   "method": "GET",
   "path": "/recruitment/public/portal/:token",
   "purpose": "Candidate portal, addressed by a one-time token.",
   "auth": {
    "text": "Public (rate-limited)",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "token",
     "type": "string",
     "basis": "unknown"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 portal data | 404 { error:string } for an unknown or malformed token | 403 { error:string } for an expired one",
   "responses": [
    {
     "status": 200,
     "text": "portal data"
    },
    {
     "status": 404,
     "text": "{ error:string } for an unknown or malformed token"
    },
    {
     "status": 403,
     "text": "{ error:string } for an expired one"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "{ error:string } for an unknown or malformed token"
    },
    {
     "status": 403,
     "text": "{ error:string } for an expired one"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /recruitment/public/portal/:token/document/:docId",
   "group": "recruitment-public",
   "method": "GET",
   "path": "/recruitment/public/portal/:token/document/:docId",
   "purpose": "Candidate downloads a supporting file attached to their offer.",
   "auth": {
    "text": "Public (rate-limited)",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "token",
     "type": "string",
     "basis": "unknown"
    },
    {
     "name": "docId",
     "type": "string",
     "basis": "unknown"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 the file | 404 unknown token or document | 410 'This offer is no longer available.'",
   "responses": [
    {
     "status": 200,
     "text": "the file"
    },
    {
     "status": 404,
     "text": "unknown token or document"
    },
    {
     "status": 410,
     "text": "'This offer is no longer available.'"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "unknown token or document"
    },
    {
     "status": 410,
     "text": "'This offer is no longer available.'"
    }
   ],
   "guard": "the file must be one of that offer's supporting documents"
  },
  {
   "id": "POST /recruitment/public/portal/:token/hardware",
   "group": "recruitment-public",
   "method": "POST",
   "path": "/recruitment/public/portal/:token/hardware",
   "purpose": "Candidate submits their hardware selection.",
   "auth": {
    "text": "Public (rate-limited)",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "token",
     "type": "string",
     "basis": "unknown"
    }
   ],
   "paging": [],
   "query": [],
   "request": "Hardware selection object (stored verbatim as candidate.onboardingData.hardwareSelection).",
   "response": "200 { success:true } | 400 'Selection is only available for active or accepted offers.' | 404 { error:string } for a malformed token",
   "responses": [
    {
     "status": 200,
     "text": "{ success:true }"
    },
    {
     "status": 400,
     "text": "'Selection is only available for active or accepted offers.'"
    },
    {
     "status": 404,
     "text": "{ error:string } for a malformed token"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "'Selection is only available for active or accepted offers.'"
    },
    {
     "status": 404,
     "text": "{ error:string } for a malformed token"
    }
   ],
   "guard": "public + rate-limited; token-authed (onboardingToken); offer must be Sent/Accepted; may trigger onboarding"
  },
  {
   "id": "POST /recruitment/webhooks/docuseal",
   "group": "recruitment-public",
   "method": "POST",
   "path": "/recruitment/webhooks/docuseal",
   "purpose": "Zonitix Sign completion callback (path name retained for compatibility).",
   "auth": {
    "text": "Signed request (HMAC, see Inbound Webhooks)",
    "kind": "other",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ event_type, data:{ id, ... } } — Zonitix Sign completion callback.",
   "response": "200 'OK' | 400 text 'Invalid signature' for a missing, stale or mismatched signature (the service answers 401; the gateway relays it as 400, with the body unchanged) | 400 'Malformed payload' | 503 text 'Webhook not configured' if the signing secret is unset in production",
   "responses": [
    {
     "status": 200,
     "text": "'OK'"
    },
    {
     "status": 400,
     "text": "text 'Invalid signature' for a missing, stale or mismatched signature (the service answers 401; the gateway relays it as 400, with the body unchanged)"
    },
    {
     "status": 400,
     "text": "'Malformed payload'"
    },
    {
     "status": 503,
     "text": "text 'Webhook not configured' if the signing secret is unset in production"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "text 'Invalid signature' for a missing, stale or mismatched signature (the service answers 401; the gateway relays it as 400, with the body unchanged)"
    },
    {
     "status": 400,
     "text": "'Malformed payload'"
    },
    {
     "status": 503,
     "text": "text 'Webhook not configured' if the signing secret is unset in production"
    }
   ],
   "guard": "X-Zonitix-Signature: t=<ms timestamp>,v1=<hex HMAC-SHA256(secret, t + \".\" + raw body)> — bound to the exact payload, with a ±5-minute replay window; the secret itself is never sent on the wire and no static-secret header is accepted; the sender retries a failed delivery up to 4 attempts"
  },
  {
   "id": "POST /recruitment/webhooks/inbound-email",
   "group": "recruitment-public",
   "method": "POST",
   "path": "/recruitment/webhooks/inbound-email",
   "purpose": "Inbound-email provider callback that records a candidate reply on their thread.",
   "auth": {
    "text": "Shared secret (rate-limited)",
    "kind": "other",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ recipient | to, from | sender, subject, text | body-plain | body, messageId | Message-Id | id, token? }; the secret goes in the X-Inbound-Secret header or the body field secret",
   "response": "200 { ok:true, recorded:true, candidateId:integer, messageId:integer } | 200 { ok:true, ignored:'not_configured'|'bad_secret'|'no_token'|'no_match' } | 200 { ok:true, deduped:true, candidateId:integer }",
   "responses": [
    {
     "status": 200,
     "text": "{ ok:true, recorded:true, candidateId:integer, messageId:integer }"
    },
    {
     "status": 200,
     "text": "{ ok:true, ignored:'not_configured'|'bad_secret'|'no_token'|'no_match' }"
    },
    {
     "status": 200,
     "text": "{ ok:true, deduped:true, candidateId:integer }"
    }
   ],
   "errors": [],
   "guard": "always answers 200; nothing is recorded until an inbound secret is configured; a bad or missing secret is audited; the reply is matched to a candidate by the reply token in the recipient address; safe to re-deliver when the provider sends a message id — an inbound message id already recorded answers deduped:true and stores nothing"
  },
  {
   "id": "POST /parser/parse",
   "group": "parser",
   "method": "POST",
   "path": "/parser/parse",
   "purpose": "Parse an uploaded invoice PDF into structured fields.",
   "auth": {
    "text": "Session + manage_finance | submit_invoice (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance",
     "submit_invoice"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "multipart form: file (UploadFile) required — pdf/docx/png/jpg/jpeg/xlsx/txt, at most 10 MB. No JSON body.",
   "response": "200 {success, document_id, displayId, approvalStatus, holdReasons, currency, filename, text, extracted_fields{total,date,renewal_date,vendor,category,status,user_id}, method} | 400 unsupported extension, content that does not match the extension, or over 10 MB | 409 the same file was already uploaded, or the same vendor and invoice number already exist | 500",
   "responses": [
    {
     "status": 200,
     "text": "{success, document_id, displayId, approvalStatus, holdReasons, currency, filename, text, extracted_fields{total,date,renewal_date,vendor,category,status,user_id}, method}"
    },
    {
     "status": 400,
     "text": "unsupported extension, content that does not match the extension, or over 10 MB"
    },
    {
     "status": 409,
     "text": "the same file was already uploaded, or the same vendor and invoice number already exist"
    },
    {
     "status": 500,
     "text": ""
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "unsupported extension, content that does not match the extension, or over 10 MB"
    },
    {
     "status": 409,
     "text": "the same file was already uploaded, or the same vendor and invoice number already exist"
    },
    {
     "status": 500,
     "text": ""
    }
   ],
   "guard": "file_safety validation must pass. Repeat: the same file sent again answers 409 and stores nothing"
  },
  {
   "id": "GET /parser/my-documents",
   "group": "parser",
   "method": "GET",
   "path": "/parser/my-documents",
   "purpose": "Documents you uploaded yourself.",
   "auth": {
    "text": "Session + view_finance | manage_finance | submit_invoice (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance",
     "submit_invoice",
     "view_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [
    "limit"
   ],
   "query": [
    {
     "name": "limit",
     "type": "integer",
     "default": 50,
     "minimum": 1,
     "maximum": 200
    }
   ],
   "request": "Query: limit (default 50, clamped to 1–200).",
   "response": "200 { items:object[] } — newest first. Each item: { id:integer, db_id:integer, displayId:string, filename:string|null, vendor:string|null, amount:number, currency:string, currencyDefaulted:boolean, date:string (a display date, or 'N/A'), category:string|null, status:string, approvalStatus:string, createdAt:string (date-time), isoDate:string (date)|null, invoiceNumber:string|null, poNumber:string|null, note:string|null, rejectionReason:string|null (set only on a rejected invoice), holdReasons:array, canDelete:boolean, canEdit:boolean, hasFile:boolean }. limit is the only paging control: there is no offset or page",
   "responses": [
    {
     "status": 200,
     "text": "{ items:object[] } — newest first. Each item: { id:integer, db_id:integer, displayId:string, filename:string|null, vendor:string|null, amount:number, currency:string, currencyDefaulted:boolean, date:string (a display date, or 'N/A'), category:string|null, status:string, approvalStatus:string, createdAt:string (date-time), isoDate:string (date)|null, invoiceNumber:string|null, poNumber:string|null, note:string|null, rejectionReason:string|null (set only on a rejected invoice), holdReasons:array, canDelete:boolean, canEdit:boolean, hasFile:boolean }. limit is the only paging control: there is no offset or page"
    }
   ],
   "errors": [],
   "guard": "only documents the caller uploaded"
  },
  {
   "id": "GET /parser/my-documents/:doc_id/timeline",
   "group": "parser",
   "method": "GET",
   "path": "/parser/my-documents/:doc_id/timeline",
   "purpose": "History of one invoice the caller may see.",
   "auth": {
    "text": "Session + view_finance | manage_finance | submit_invoice (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance",
     "submit_invoice",
     "view_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "doc_id",
     "type": "integer",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { docId:integer, displayId:string, approvalStatus:string, steps:[{ step:string, label:string, at:string (date-time), by?:string, reason?:string (on a rejection) }] }, oldest step first, built from the newest 200 audit entries of the invoice; not paged | 404 'Document not found'",
   "responses": [
    {
     "status": 200,
     "text": "{ docId:integer, displayId:string, approvalStatus:string, steps:[{ step:string, label:string, at:string (date-time), by?:string, reason?:string (on a rejection) }] }, oldest step first, built from the newest 200 audit entries of the invoice; not paged"
    },
    {
     "status": 404,
     "text": "'Document not found'"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'Document not found'"
    }
   ],
   "guard": "a self-service submitter can read only their own invoice; the answer is the same 404 either way"
  },
  {
   "id": "GET /parser/invoices",
   "group": "parser",
   "method": "GET",
   "path": "/parser/invoices",
   "purpose": "Paginated, filterable invoice ledger.",
   "auth": {
    "text": "Session + view_finance | manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance",
     "view_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [
    "limit",
    "page"
   ],
   "query": [
    {
     "name": "page",
     "type": "integer",
     "default": 1,
     "minimum": 1
    },
    {
     "name": "limit",
     "type": "integer",
     "default": 25,
     "minimum": 1,
     "maximum": 200
    },
    {
     "name": "sort_by",
     "type": "string",
     "default": "date"
    },
    {
     "name": "sort_dir",
     "type": "string",
     "default": "desc"
    },
    {
     "name": "date_from",
     "type": "string"
    },
    {
     "name": "date_to",
     "type": "string"
    },
    {
     "name": "vendor",
     "type": "string"
    },
    {
     "name": "category",
     "type": "string"
    },
    {
     "name": "status",
     "type": "string"
    },
    {
     "name": "cost_center",
     "type": "string"
    },
    {
     "name": "approval_status",
     "type": "string"
    },
    {
     "name": "search",
     "type": "string"
    }
   ],
   "request": "Query: page (default 1), limit (default 25, clamped to 1–200), sort_by (date | amount | amountBase | vendor | category | costCenter | status | approvalStatus | createdAt; default date), sort_dir (asc | desc; default desc), date_from, date_to, vendor, category, status, cost_center, approval_status, search. An unknown sort_by falls back to date; rows with the same sort value are ordered by id.",
   "response": "200 the InvoicePage object { items:[ Invoice ], total:integer, page:integer, limit:integer, pages:integer, filteredTotalAmount:number } — total and filteredTotalAmount cover every invoice matching the filters, not the page. Page-numbered: page and limit, no offset",
   "responses": [
    {
     "status": 200,
     "text": "the InvoicePage object { items:[ Invoice ], total:integer, page:integer, limit:integer, pages:integer, filteredTotalAmount:number } — total and filteredTotalAmount cover every invoice matching the filters, not the page. Page-numbered: page and limit, no offset"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /parser/invoices/export",
   "group": "parser",
   "method": "GET",
   "path": "/parser/invoices/export",
   "purpose": "Export the ledger as CSV.",
   "auth": {
    "text": "Session + view_finance | manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance",
     "view_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query: date_from, date_to, vendor, category, status, cost_center, approval_status, search.",
   "response": "200 text/csv attachment zonitix-invoices.csv — every invoice matching the filters, newest date first, not paged. Columns: Invoice ID, Vendor, Canonical Vendor, Amount, Currency, Amount (Base), Date, Renewal Date, Category, Cost Center, Department, Project, Status, Filename | 500 { detail:string }",
   "responses": [
    {
     "status": 200,
     "text": "text/csv attachment zonitix-invoices.csv — every invoice matching the filters, newest date first, not paged. Columns: Invoice ID, Vendor, Canonical Vendor, Amount, Currency, Amount (Base), Date, Renewal Date, Category, Cost Center, Department, Project, Status, Filename"
    },
    {
     "status": 500,
     "text": "{ detail:string }"
    }
   ],
   "errors": [
    {
     "status": 500,
     "text": "{ detail:string }"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /parser/invoices/export.xlsx",
   "group": "parser",
   "method": "GET",
   "path": "/parser/invoices/export.xlsx",
   "purpose": "Export the ledger as Excel.",
   "auth": {
    "text": "Session + view_finance | manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance",
     "view_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query: date_from, date_to, vendor, category, status, cost_center, approval_status, search.",
   "response": "200 .xlsx attachment zonitix-invoices.xlsx, same filters as the CSV export; not paged | 500 { detail:string }",
   "responses": [
    {
     "status": 200,
     "text": ".xlsx attachment zonitix-invoices.xlsx, same filters as the CSV export; not paged"
    },
    {
     "status": 500,
     "text": "{ detail:string }"
    }
   ],
   "errors": [
    {
     "status": 500,
     "text": "{ detail:string }"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /parser/invoices/export.pdf",
   "group": "parser",
   "method": "GET",
   "path": "/parser/invoices/export.pdf",
   "purpose": "Export the ledger as PDF.",
   "auth": {
    "text": "Session + view_finance | manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance",
     "view_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query: date_from, date_to, vendor, category, status, cost_center, approval_status, search.",
   "response": "200 application/pdf attachment zonitix-invoice-ledger.pdf, same filters as the CSV export; not paged | 500 { detail:string }",
   "responses": [
    {
     "status": 200,
     "text": "application/pdf attachment zonitix-invoice-ledger.pdf, same filters as the CSV export; not paged"
    },
    {
     "status": 500,
     "text": "{ detail:string }"
    }
   ],
   "errors": [
    {
     "status": 500,
     "text": "{ detail:string }"
    }
   ],
   "guard": null
  },
  {
   "id": "POST /parser/invoices/import",
   "group": "parser",
   "method": "POST",
   "path": "/parser/invoices/import",
   "purpose": "Bulk-import invoices.",
   "auth": {
    "text": "Session + manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "multipart form: file (UploadFile) required — CSV. No JSON body.",
   "response": "200 an InvoiceImportSummary { imported:integer, skipped:integer, errors:string[] (the first 50), currencyDefaulted:integer, amountAmbiguous:integer, dateHeld:integer, duplicatesSkipped:integer, duplicatesHeld:integer } | 400 unsafe file | 500",
   "responses": [
    {
     "status": 200,
     "text": "an InvoiceImportSummary { imported:integer, skipped:integer, errors:string[] (the first 50), currencyDefaulted:integer, amountAmbiguous:integer, dateHeld:integer, duplicatesSkipped:integer, duplicatesHeld:integer }"
    },
    {
     "status": 400,
     "text": "unsafe file"
    },
    {
     "status": 500,
     "text": ""
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "unsafe file"
    },
    {
     "status": 500,
     "text": ""
    }
   ],
   "guard": "file_safety validation must pass. Repeat: a row whose vendor and invoice number are already on file is skipped and counted in duplicatesSkipped; a row with no invoice number is stored again, and held for review when an invoice with the same vendor, amount and date exists"
  },
  {
   "id": "GET /parser/stats",
   "group": "parser",
   "method": "GET",
   "path": "/parser/stats",
   "purpose": "Parsing and spend statistics.",
   "auth": {
    "text": "Session + view_finance | manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance",
     "view_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { baseCurrency, totalSpend, monthlyBudget, vendorCount, pendingCount, monthlyTrend, forecast, categoryBreakdown, topVendors, upcomingRenewals, awsBilling, azureBilling, awsInvoiceTotal, azureInvoiceTotal, awsReconcileSpend, azureReconcileSpend, cloudBillingByProvider, cloudPrimaryProvider, recentInvoices:[{ id, db_id, vendor, amount, currency, amountBase, costCenter, date, renewalDate, category, status }] } | 500 { detail:string }",
   "responses": [
    {
     "status": 200,
     "text": "{ baseCurrency, totalSpend, monthlyBudget, vendorCount, pendingCount, monthlyTrend, forecast, categoryBreakdown, topVendors, upcomingRenewals, awsBilling, azureBilling, awsInvoiceTotal, azureInvoiceTotal, awsReconcileSpend, azureReconcileSpend, cloudBillingByProvider, cloudPrimaryProvider, recentInvoices:[{ id, db_id, vendor, amount, currency, amountBase, costCenter, date, renewalDate, category, status }] }"
    },
    {
     "status": 500,
     "text": "{ detail:string }"
    }
   ],
   "errors": [
    {
     "status": 500,
     "text": "{ detail:string }"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /parser/health",
   "group": "parser",
   "method": "GET",
   "path": "/parser/health",
   "purpose": "Is the document parser answering. Add ?ready=1 to test its database as well.",
   "auth": {
    "text": "Session",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query: ready? (1 or true).",
   "response": "200 { status:'ok', service:'document-parser', results:[{ name:string, status: up | down | not-licensed }], rate:number }. With ready=1: 200 { status:'ready', service:'document-parser', db:'up' } | 503 { status:'degraded', service:'document-parser', db:'down', reason:string }",
   "responses": [
    {
     "status": 200,
     "text": "{ status:'ok', service:'document-parser', results:[{ name:string, status: up | down | not-licensed }], rate:number }. With ready=1: 200 { status:'ready', service:'document-parser', db:'up' }"
    },
    {
     "status": 503,
     "text": "{ status:'degraded', service:'document-parser', db:'down', reason:string }"
    }
   ],
   "errors": [
    {
     "status": 503,
     "text": "{ status:'degraded', service:'document-parser', db:'down', reason:string }"
    }
   ],
   "guard": "Any signed-in person; the gateway requires a session."
  },
  {
   "id": "GET /parser/finance/documents/:doc_id",
   "group": "parser",
   "method": "GET",
   "path": "/parser/finance/documents/:doc_id",
   "purpose": "One invoice in full.",
   "auth": {
    "text": "Session + view_finance | manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance",
     "view_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "doc_id",
     "type": "integer",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 the InvoiceDetail object | 404 'Document not found'",
   "responses": [
    {
     "status": 200,
     "text": "the InvoiceDetail object"
    },
    {
     "status": 404,
     "text": "'Document not found'"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'Document not found'"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /parser/finance/documents/:doc_id/file",
   "group": "parser",
   "method": "GET",
   "path": "/parser/finance/documents/:doc_id/file",
   "purpose": "The original uploaded file of an invoice.",
   "auth": {
    "text": "Session + view_finance | manage_finance | submit_invoice (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance",
     "submit_invoice",
     "view_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "doc_id",
     "type": "integer",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 the file | 404 'No original file stored for this document'",
   "responses": [
    {
     "status": 200,
     "text": "the file"
    },
    {
     "status": 404,
     "text": "'No original file stored for this document'"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'No original file stored for this document'"
    }
   ],
   "guard": null
  },
  {
   "id": "PATCH /parser/finance/documents/:doc_id/review",
   "group": "parser",
   "method": "PATCH",
   "path": "/parser/finance/documents/:doc_id/review",
   "purpose": "Correct the extracted fields of an invoice held for review, and optionally release it.",
   "auth": {
    "text": "Session + manage_finance | submit_invoice (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance",
     "submit_invoice"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "doc_id",
     "type": "integer",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The InvoiceReview object: { vendor?:string, category?:string, currency?:string, date?:string, amount?:number, po_number?:string (trimmed, upper-cased and cut to 64 characters), note?:string (trimmed and cut to 1000 characters), commit?:boolean }. commit releases an invoice that is held for review; it is read only from a caller who holds manage_finance.",
   "response": "200 { success:true, approvalStatus, changes } | 404 not found | 403 not your upload | 409 the invoice can no longer be changed, or it cannot be released yet | 400 an amount that cannot be read, or, for a caller who holds manage_finance, a currency with no exchange rate set",
   "responses": [
    {
     "status": 200,
     "text": "{ success:true, approvalStatus, changes }"
    },
    {
     "status": 404,
     "text": "not found"
    },
    {
     "status": 403,
     "text": "not your upload"
    },
    {
     "status": 409,
     "text": "the invoice can no longer be changed, or it cannot be released yet"
    },
    {
     "status": 400,
     "text": "an amount that cannot be read, or, for a caller who holds manage_finance, a currency with no exchange rate set"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "not found"
    },
    {
     "status": 403,
     "text": "not your upload"
    },
    {
     "status": 409,
     "text": "the invoice can no longer be changed, or it cannot be released yet"
    },
    {
     "status": 400,
     "text": "an amount that cannot be read, or, for a caller who holds manage_finance, a currency with no exchange rate set"
    }
   ],
   "guard": "a caller without manage_finance can correct only an invoice they uploaded"
  },
  {
   "id": "PATCH /parser/finance/documents/:doc_id/allocation",
   "group": "parser",
   "method": "PATCH",
   "path": "/parser/finance/documents/:doc_id/allocation",
   "purpose": "Allocate a document to cost centres.",
   "auth": {
    "text": "Session + manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "doc_id",
     "type": "integer",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The InvoiceAllocation object: { costCenter?:string, department?:string, project?:string, currency?:string, glCode?:string, customFields?:object }, all optional. currency re-derives the base amount; glCode must exist in the chart of accounts; customFields keeps only the keys defined in the allocation custom-field schema. doc_id is an integer in the path.",
   "response": "200 { success:true, id:integer } | 404 not found | 409 if Submitted/Approved/Paid (frozen) | 400 a GL code that is not in the chart of accounts | 500",
   "responses": [
    {
     "status": 200,
     "text": "{ success:true, id:integer }"
    },
    {
     "status": 404,
     "text": "not found"
    },
    {
     "status": 409,
     "text": "if Submitted/Approved/Paid (frozen)"
    },
    {
     "status": 400,
     "text": "a GL code that is not in the chart of accounts"
    },
    {
     "status": 500,
     "text": ""
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "not found"
    },
    {
     "status": 409,
     "text": "if Submitted/Approved/Paid (frozen)"
    },
    {
     "status": 400,
     "text": "a GL code that is not in the chart of accounts"
    },
    {
     "status": 500,
     "text": ""
    }
   ],
   "guard": "409 ledger-freeze — cost-center/currency frozen once in approval workflow. Repeat: the same values are stored again and the answer is 200, until the invoice is submitted"
  },
  {
   "id": "POST /parser/finance/documents/:doc_id/:action",
   "group": "parser",
   "method": "POST",
   "path": "/parser/finance/documents/:doc_id/:action",
   "purpose": "Approve or reject a document (approval flow with separation of duties).",
   "auth": {
    "text": "Session + per-action permission, see guard (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "doc_id",
     "type": "integer",
     "basis": "parser"
    },
    {
     "name": "action",
     "type": "string",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The InvoiceAction object. action is in the path: submit | approve | reject | pay | clear_review. reject needs { reason:string }; approve reads { budget_override?:boolean, override_reason?:string, comment?:string }; pay reads no body; an approve with budget_override also needs override_reason and the approve_invoice permission; submit and clear_review read none either.",
   "response": "200 { success:true, approvalStatus:string } | 404 unknown action or doc | 403 SoD violation | 409 budget-exceeded or illegal transition | 400 illegal transition, a reject without a reason, or a budget override without a written reason",
   "responses": [
    {
     "status": 200,
     "text": "{ success:true, approvalStatus:string }"
    },
    {
     "status": 404,
     "text": "unknown action or doc"
    },
    {
     "status": 403,
     "text": "SoD violation"
    },
    {
     "status": 409,
     "text": "budget-exceeded or illegal transition"
    },
    {
     "status": 400,
     "text": "illegal transition, a reject without a reason, or a budget override without a written reason"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "unknown action or doc"
    },
    {
     "status": 403,
     "text": "SoD violation"
    },
    {
     "status": 409,
     "text": "budget-exceeded or illegal transition"
    },
    {
     "status": 400,
     "text": "illegal transition, a reject without a reason, or a budget override without a written reason"
    }
   ],
   "guard": "per-action, amount-aware permissions: submit = manage_finance | approve_invoice | pay_invoice; reject = manage_finance | approve_invoice; approve = approve_invoice at or above the high-value floor (default 25000 in base currency), otherwise manage_finance | approve_invoice; pay = pay_invoice at or above the floor, otherwise manage_finance | pay_invoice; clear_review = manage_finance. SoD: approver != uploader & submitter, payer != approver. Budget-over approval needs budget_override. Repeat: each step checks the invoice's state first, so a second identical call is refused and changes nothing: submit, reject and clear_review answer 400 once the invoice has moved on, a second approve by the same person answers 409 while the invoice still waits for another approver and 400 once it is Approved, and pay answers 400 once the invoice is Paid"
  },
  {
   "id": "POST /parser/finance/documents/bulk",
   "group": "parser",
   "method": "POST",
   "path": "/parser/finance/documents/bulk",
   "purpose": "Apply one action to many invoices: any single-invoice action, allocate or delete.",
   "auth": {
    "text": "Session + per-action permission, see guard (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The InvoiceBulkAction object: { action:string, ids:integer[] (1 to 500), allocation?:InvoiceAllocation, reason?:string, budget_override?:boolean, override_reason?:string, comment?:string }. action is submit, approve, reject, pay, clear_review, allocate or delete; allocation is read for allocate and carries the same fields as the allocation route; reason is shared by every invoice of a reject, and comment by every invoice of an approve.",
   "response": "200 a BulkOutcome { action:string, ok:integer, failed:integer, results:[ BulkOutcomeItem ] } with each item { id, ok:boolean, error?:string, approvalStatus?:string } | 400 unknown action, empty list, or more than 500 ids",
   "responses": [
    {
     "status": 200,
     "text": "a BulkOutcome { action:string, ok:integer, failed:integer, results:[ BulkOutcomeItem ] } with each item { id, ok:boolean, error?:string, approvalStatus?:string }"
    },
    {
     "status": 400,
     "text": "unknown action, empty list, or more than 500 ids"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "unknown action, empty list, or more than 500 ids"
    }
   ],
   "guard": "to reach the route: allocate needs manage_finance; delete needs manage_finance | submit_invoice; every other action needs manage_finance | approve_invoice | pay_invoice. Each invoice then passes the same amount-aware checks as the single route, and one the caller may not action is reported in results. Repeat: each invoice gets what a repeat of its single route gets, reported in results; a delete of an invoice that is already gone is reported as failed"
  },
  {
   "id": "POST /parser/finance/rematch/:doc_id",
   "group": "parser",
   "method": "POST",
   "path": "/parser/finance/rematch/:doc_id",
   "purpose": "Re-run purchase-order matching for one invoice.",
   "auth": {
    "text": "Session + manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "doc_id",
     "type": "integer",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { success:true, matchStatus:string } | 404 'Document not found'",
   "responses": [
    {
     "status": 200,
     "text": "{ success:true, matchStatus:string }"
    },
    {
     "status": 404,
     "text": "'Document not found'"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'Document not found'"
    }
   ],
   "guard": null
  },
  {
   "id": "DELETE /parser/documents/:doc_id",
   "group": "parser",
   "method": "DELETE",
   "path": "/parser/documents/:doc_id",
   "purpose": "Delete a document.",
   "auth": {
    "text": "Session + manage_finance | submit_invoice",
    "kind": "session",
    "permissions": [
     "manage_finance",
     "submit_invoice"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "doc_id",
     "type": "integer",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body. doc_id path param (int).",
   "response": "200 { success:true, message:string } | 404 not found | 403 not your document | 409 if Submitted/Approved/Paid, if it has approval or ledger history, or if it is still referenced elsewhere",
   "responses": [
    {
     "status": 200,
     "text": "{ success:true, message:string }"
    },
    {
     "status": 404,
     "text": "not found"
    },
    {
     "status": 403,
     "text": "not your document"
    },
    {
     "status": 409,
     "text": "if Submitted/Approved/Paid, if it has approval or ledger history, or if it is still referenced elsewhere"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "not found"
    },
    {
     "status": 403,
     "text": "not your document"
    },
    {
     "status": 409,
     "text": "if Submitted/Approved/Paid, if it has approval or ledger history, or if it is still referenced elsewhere"
    }
   ],
   "guard": "a caller without manage_finance may delete only a document they uploaded; a document dated in a locked accounting period cannot be deleted; only Draft, Rejected and held-for-review documents can be. Repeat: a second call answers 404 'Document not found'"
  },
  {
   "id": "PATCH /parser/documents/:doc_id/status",
   "group": "parser",
   "method": "PATCH",
   "path": "/parser/documents/:doc_id/status",
   "purpose": "Update a document status.",
   "auth": {
    "text": "Session + manage_finance",
    "kind": "session",
    "permissions": [
     "manage_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "doc_id",
     "type": "integer",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The DocumentStatusWrite object: { status:string } required. doc_id is an integer in the path.",
   "response": "200 { success:true, message:string } | 400 missing status | 404 not found | 403 IDOR or controlled target",
   "responses": [
    {
     "status": 200,
     "text": "{ success:true, message:string }"
    },
    {
     "status": 400,
     "text": "missing status"
    },
    {
     "status": 404,
     "text": "not found"
    },
    {
     "status": 403,
     "text": "IDOR or controlled target"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "missing status"
    },
    {
     "status": 404,
     "text": "not found"
    },
    {
     "status": 403,
     "text": "IDOR or controlled target"
    }
   ],
   "guard": "a caller who is not an administrator can change only a document they own; setting 'paid' or 'approved' is refused with 403 (those go through the approval flow). Repeat: the same status is stored again, the answer is 200 and another status_changed audit entry is written"
  },
  {
   "id": "POST /parser/screen-resume",
   "group": "parser",
   "method": "POST",
   "path": "/parser/screen-resume",
   "purpose": "Extract structured data from a resume.",
   "auth": {
    "text": "Session + view_recruitment | manage_recruitment",
    "kind": "session",
    "permissions": [
     "manage_recruitment",
     "view_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "multipart form: file required (pdf, docx or txt). Query parameters, all optional: job_description (string, default empty), competencies (a JSON array as text, default '[]'), requirements (a JSON array as text, default '[]').",
   "response": "200 {success:true, match_score, score_source, ai_summary, raw_text_preview} | 400 unsupported/unsafe file | 500",
   "responses": [
    {
     "status": 200,
     "text": "{success:true, match_score, score_source, ai_summary, raw_text_preview}"
    },
    {
     "status": 400,
     "text": "unsupported/unsafe file"
    },
    {
     "status": 500,
     "text": ""
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "unsupported/unsafe file"
    },
    {
     "status": 500,
     "text": ""
    }
   ],
   "guard": null
  },
  {
   "id": "POST /parser/extract-text",
   "group": "parser",
   "method": "POST",
   "path": "/parser/extract-text",
   "purpose": "Extract raw text from a document. Internal: used between Zonitix services, not callable with a session or API token.",
   "auth": {
    "text": "Internal only",
    "kind": "other",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "multipart form: file (UploadFile) required — pdf/docx/txt only. No JSON body.",
   "response": "404 through the gateway — the gateway refuses this path for every session and API token",
   "responses": [
    {
     "status": 404,
     "text": "through the gateway — the gateway refuses this path for every session and API token"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "through the gateway — the gateway refuses this path for every session and API token"
    }
   ],
   "guard": "service-to-service only: the gateway blocks it by name, and the service itself rejects a call that arrived through the gateway"
  },
  {
   "id": "GET /parser/finance/insights",
   "group": "finance-vendors-settings",
   "method": "GET",
   "path": "/parser/finance/insights",
   "purpose": "Local-AI spend insights.",
   "auth": {
    "text": "Session + view_finance | manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance",
     "view_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { duplicates:[{ vendor, amount, daysApart, invoiceIds, dbIds, severity, message }], priceIncreases:[{ vendor, basis, previousAmount, latestAmount, increasePct, message }] } — priceIncreases is ordered by increasePct, largest first. Not paged: both lists come back whole; the handler takes no query parameters. | 500 { detail:string }",
   "responses": [
    {
     "status": 200,
     "text": "{ duplicates:[{ vendor, amount, daysApart, invoiceIds, dbIds, severity, message }], priceIncreases:[{ vendor, basis, previousAmount, latestAmount, increasePct, message }] } — priceIncreases is ordered by increasePct, largest first. Not paged: both lists come back whole; the handler takes no query parameters."
    },
    {
     "status": 500,
     "text": "{ detail:string }"
    }
   ],
   "errors": [
    {
     "status": 500,
     "text": "{ detail:string }"
    }
   ],
   "guard": null
  },
  {
   "id": "POST /parser/finance/ask",
   "group": "finance-vendors-settings",
   "method": "POST",
   "path": "/parser/finance/ask",
   "purpose": "Natural-language query over your finance data.",
   "auth": {
    "text": "Session + view_finance | manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance",
     "view_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The FinanceQuestion object: { question:string } required; blank after trimming is refused.",
   "response": "200 { answer:string, aiUsed:boolean, grounded?:true (the answer was worked out from your figures without the model), aiDisabled?:true (AI features are switched off) } | 400 missing question | 500",
   "responses": [
    {
     "status": 200,
     "text": "{ answer:string, aiUsed:boolean, grounded?:true (the answer was worked out from your figures without the model), aiDisabled?:true (AI features are switched off) }"
    },
    {
     "status": 400,
     "text": "missing question"
    },
    {
     "status": 500,
     "text": ""
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "missing question"
    },
    {
     "status": 500,
     "text": ""
    }
   ],
   "guard": "answered by local LLM only, no data leaves box"
  },
  {
   "id": "GET /parser/finance/variance",
   "group": "finance-vendors-settings",
   "method": "GET",
   "path": "/parser/finance/variance",
   "purpose": "Spend variance figures.",
   "auth": {
    "text": "Session + view_finance | manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance",
     "view_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { currentMonth:{ label, amount }, vsLastMonth:{ label, amount, delta, deltaPct }, vsLastYear:{ label, amount, delta, deltaPct }, movers:[] } — movers holds at most 8 categories whose spend changed | 500 { detail:string }",
   "responses": [
    {
     "status": 200,
     "text": "{ currentMonth:{ label, amount }, vsLastMonth:{ label, amount, delta, deltaPct }, vsLastYear:{ label, amount, delta, deltaPct }, movers:[] } — movers holds at most 8 categories whose spend changed"
    },
    {
     "status": 500,
     "text": "{ detail:string }"
    }
   ],
   "errors": [
    {
     "status": 500,
     "text": "{ detail:string }"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /parser/finance/commitments",
   "group": "finance-vendors-settings",
   "method": "GET",
   "path": "/parser/finance/commitments",
   "purpose": "Open commitments.",
   "auth": {
    "text": "Session + view_finance | manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance",
     "view_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { openPoCount, openPoValue, invoicedAgainst, uninvoicedCommitment } — over purchase orders that are neither closed nor cancelled, in base currency | 500 { detail:string }",
   "responses": [
    {
     "status": 200,
     "text": "{ openPoCount, openPoValue, invoicedAgainst, uninvoicedCommitment } — over purchase orders that are neither closed nor cancelled, in base currency"
    },
    {
     "status": 500,
     "text": "{ detail:string }"
    }
   ],
   "errors": [
    {
     "status": 500,
     "text": "{ detail:string }"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /parser/finance/category-trend",
   "group": "finance-vendors-settings",
   "method": "GET",
   "path": "/parser/finance/category-trend",
   "purpose": "Spend by category over time.",
   "auth": {
    "text": "Session + view_finance | manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance",
     "view_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query: months (default 12). Clamped to 3–24.",
   "response": "200 { months:[label], series:[{ category, points:[] }] } — the 6 largest categories, the rest summed into one Other series | 500 { detail:string }",
   "responses": [
    {
     "status": 200,
     "text": "{ months:[label], series:[{ category, points:[] }] } — the 6 largest categories, the rest summed into one Other series"
    },
    {
     "status": 500,
     "text": "{ detail:string }"
    }
   ],
   "errors": [
    {
     "status": 500,
     "text": "{ detail:string }"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /parser/finance/saas-spend",
   "group": "finance-vendors-settings",
   "method": "GET",
   "path": "/parser/finance/saas-spend",
   "purpose": "Spend per SaaS application.",
   "auth": {
    "text": "Session + view_finance | manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance",
     "view_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { apps:[], totalSpend, appCount, renewals:[{ window: 30 | 60 | 90, count, amount }], vendorsWithoutOwner, unusedCount, overBudgetCount, reclaimableMonthly, withUsageCount, reclaimableByDepartment:[{ department, reclaimableMonthly }] }. Not paged: apps holds every app in one reply. | 500 { detail:string }",
   "responses": [
    {
     "status": 200,
     "text": "{ apps:[], totalSpend, appCount, renewals:[{ window: 30 | 60 | 90, count, amount }], vendorsWithoutOwner, unusedCount, overBudgetCount, reclaimableMonthly, withUsageCount, reclaimableByDepartment:[{ department, reclaimableMonthly }] }. Not paged: apps holds every app in one reply."
    },
    {
     "status": 500,
     "text": "{ detail:string }"
    }
   ],
   "errors": [
    {
     "status": 500,
     "text": "{ detail:string }"
    }
   ],
   "guard": null
  },
  {
   "id": "PUT /parser/finance/saas/:vendor_id/budget",
   "group": "finance-vendors-settings",
   "method": "PUT",
   "path": "/parser/finance/saas/:vendor_id/budget",
   "purpose": "Set or clear the annual budget of one application.",
   "auth": {
    "text": "Session + manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "vendor_id",
     "type": "integer",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The AppBudgetWrite object: { amount?:number }. Zero, a negative number, a missing key, or a blank or non-numeric value clears the budget.",
   "response": "200 { vendor, period, budget } — period is the current year | 404 'App/vendor not found' | 409 when that budget is submitted, approved or locked (it then changes only through the budget routes; the refusal is audited)",
   "responses": [
    {
     "status": 200,
     "text": "{ vendor, period, budget } — period is the current year"
    },
    {
     "status": 404,
     "text": "'App/vendor not found'"
    },
    {
     "status": 409,
     "text": "when that budget is submitted, approved or locked (it then changes only through the budget routes; the refusal is audited)"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'App/vendor not found'"
    },
    {
     "status": 409,
     "text": "when that budget is submitted, approved or locked (it then changes only through the budget routes; the refusal is audited)"
    }
   ],
   "guard": "Repeat: safe to repeat: the one budget for that vendor and year is set to the same amount, and clearing a budget that is already cleared answers 200 with budget null"
  },
  {
   "id": "GET /parser/finance/connectors",
   "group": "finance-vendors-settings",
   "method": "GET",
   "path": "/parser/finance/connectors",
   "purpose": "Finance connectors and their status.",
   "auth": {
    "text": "Session + view_finance | manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance",
     "view_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { connectors:[{ key, name, category, status: connected | identity | available, billing, note }], connectedCount, identityCount, availableCount }. Not paged: the whole connector list.",
   "responses": [
    {
     "status": 200,
     "text": "{ connectors:[{ key, name, category, status: connected | identity | available, billing, note }], connectedCount, identityCount, availableCount }. Not paged: the whole connector list."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /parser/finance/vendors",
   "group": "finance-vendors-settings",
   "method": "GET",
   "path": "/parser/finance/vendors",
   "purpose": "Vendor list.",
   "auth": {
    "text": "Session + view_finance | manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance",
     "view_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters; not paged.",
   "response": "200 { vendors:[ Vendor ] } — every vendor, ordered by canonicalName | 500 { detail:string }",
   "responses": [
    {
     "status": 200,
     "text": "{ vendors:[ Vendor ] } — every vendor, ordered by canonicalName"
    },
    {
     "status": 500,
     "text": "{ detail:string }"
    }
   ],
   "errors": [
    {
     "status": 500,
     "text": "{ detail:string }"
    }
   ],
   "guard": null
  },
  {
   "id": "PATCH /parser/finance/vendors/:vendor_id",
   "group": "finance-vendors-settings",
   "method": "PATCH",
   "path": "/parser/finance/vendors/:vendor_id",
   "purpose": "Update vendor details.",
   "auth": {
    "text": "Session + manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "vendor_id",
     "type": "integer",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The VendorUpdate object: { canonicalName?:string, category?:string, owner?:string, ownerUserId?:integer, contractRef?:string, contractRenewal?:string (date-time), notes?:string, aliases?:string[], customFields?:object }, all optional; only the keys sent are applied; customFields keeps only the keys defined in the vendor custom-field schema. vendor_id is an integer in the path.",
   "response": "200 { success:true, id:integer } | 404 vendor not found | 409 another vendor already has that name | 500",
   "responses": [
    {
     "status": 200,
     "text": "{ success:true, id:integer }"
    },
    {
     "status": 404,
     "text": "vendor not found"
    },
    {
     "status": 409,
     "text": "another vendor already has that name"
    },
    {
     "status": 500,
     "text": ""
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "vendor not found"
    },
    {
     "status": 409,
     "text": "another vendor already has that name"
    },
    {
     "status": 500,
     "text": ""
    }
   ],
   "guard": "logs vendor_updated audit entry. Repeat: the same values are stored again and the answer is 200"
  },
  {
   "id": "POST /parser/finance/vendors/bulk-update",
   "group": "finance-vendors-settings",
   "method": "POST",
   "path": "/parser/finance/vendors/bulk-update",
   "purpose": "Set the category and/or owner on many vendors.",
   "auth": {
    "text": "Session + manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The VendorBulkUpdate object: { ids:integer[] (1 to 500), patch:VendorBulkPatch } with patch { category?:string, owner?:string }. patch must set at least one of the two.",
   "response": "200 a BulkOutcome { ok:integer, failed:integer, results:[ BulkOutcomeItem ] } with each item { id, ok:boolean, error?:string } | 400 empty list, more than 500 ids, or a patch that sets neither field",
   "responses": [
    {
     "status": 200,
     "text": "a BulkOutcome { ok:integer, failed:integer, results:[ BulkOutcomeItem ] } with each item { id, ok:boolean, error?:string }"
    },
    {
     "status": 400,
     "text": "empty list, more than 500 ids, or a patch that sets neither field"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "empty list, more than 500 ids, or a patch that sets neither field"
    }
   ],
   "guard": "Repeat: the same values are stored again; every vendor is reported ok and another vendor_updated audit entry is written for each"
  },
  {
   "id": "POST /parser/finance/vendors/merge",
   "group": "finance-vendors-settings",
   "method": "POST",
   "path": "/parser/finance/vendors/merge",
   "purpose": "Merge duplicate vendors.",
   "auth": {
    "text": "Session + manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The VendorMerge object: { keepId:integer, mergeId:integer }, both required (vendor ids).",
   "response": "200 a VendorMergeResult { success:true, keptVendorId:integer, repointed:integer (invoices moved), skippedLocked:array (invoices left in place because their accounting period is locked), loserRetained:boolean } | 400 missing ids or invalid/equal ids | 500",
   "responses": [
    {
     "status": 200,
     "text": "a VendorMergeResult { success:true, keptVendorId:integer, repointed:integer (invoices moved), skippedLocked:array (invoices left in place because their accounting period is locked), loserRetained:boolean }"
    },
    {
     "status": 400,
     "text": "missing ids or invalid/equal ids"
    },
    {
     "status": 500,
     "text": ""
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "missing ids or invalid/equal ids"
    },
    {
     "status": 500,
     "text": ""
    }
   ],
   "guard": "repoints invoices, absorbs aliases, deletes loser; logs vendor_merged. Repeat: when the merged vendor was deleted, a second call answers 400 'Invalid vendor ids'"
  },
  {
   "id": "GET /parser/finance/vendor-custom-fields",
   "group": "finance-vendors-settings",
   "method": "GET",
   "path": "/parser/finance/vendor-custom-fields",
   "purpose": "Read the vendor custom-field schema.",
   "auth": {
    "text": "Session + view_finance | manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance",
     "view_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { fields } — every field definition; not paged",
   "responses": [
    {
     "status": 200,
     "text": "{ fields } — every field definition; not paged"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /parser/finance/vendor-custom-fields",
   "group": "finance-vendors-settings",
   "method": "POST",
   "path": "/parser/finance/vendor-custom-fields",
   "purpose": "Replace the vendor custom-field schema.",
   "auth": {
    "text": "Session + manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ fields:[ CustomFieldDefinition ] }: each { key:string, label?:string, type?: text | select | number | date, options?:string[] }. At most 20 fields. The call replaces the whole schema.",
   "response": "200 { fields } (as stored) | 400 when fields is not a list or has more than 20 entries",
   "responses": [
    {
     "status": 200,
     "text": "{ fields } (as stored)"
    },
    {
     "status": 400,
     "text": "when fields is not a list or has more than 20 entries"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "when fields is not a list or has more than 20 entries"
    }
   ],
   "guard": "Repeat: safe to repeat: the call replaces the whole schema, so the same list leaves it unchanged"
  },
  {
   "id": "GET /parser/finance/allocation-custom-fields",
   "group": "finance-vendors-settings",
   "method": "GET",
   "path": "/parser/finance/allocation-custom-fields",
   "purpose": "Read the allocation custom-field schema.",
   "auth": {
    "text": "Session + view_finance | manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance",
     "view_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { fields } — every field definition; not paged",
   "responses": [
    {
     "status": 200,
     "text": "{ fields } — every field definition; not paged"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /parser/finance/allocation-custom-fields",
   "group": "finance-vendors-settings",
   "method": "POST",
   "path": "/parser/finance/allocation-custom-fields",
   "purpose": "Replace the allocation custom-field schema.",
   "auth": {
    "text": "Session + manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ fields:[ CustomFieldDefinition ] }: each { key:string, label?:string, type?: text | select | number | date, options?:string[] }. At most 20 fields. The call replaces the whole schema.",
   "response": "200 { fields } (as stored) | 400 when fields is not a list or has more than 20 entries",
   "responses": [
    {
     "status": 200,
     "text": "{ fields } (as stored)"
    },
    {
     "status": 400,
     "text": "when fields is not a list or has more than 20 entries"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "when fields is not a list or has more than 20 entries"
    }
   ],
   "guard": "Repeat: safe to repeat: the call replaces the whole schema, so the same list leaves it unchanged"
  },
  {
   "id": "GET /parser/finance/allocation-values",
   "group": "finance-vendors-settings",
   "method": "GET",
   "path": "/parser/finance/allocation-values",
   "purpose": "Values already in use for the allocation fields, for pickers.",
   "auth": {
    "text": "Session + view_finance | manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance",
     "view_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { costCenters:string[], projects:string[], departments:string[] } — each sorted; departments also includes those in the synced organisation directory. Not paged.",
   "responses": [
    {
     "status": 200,
     "text": "{ costCenters:string[], projects:string[], departments:string[] } — each sorted; departments also includes those in the synced organisation directory. Not paged."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /parser/finance/categories",
   "group": "finance-vendors-settings",
   "method": "GET",
   "path": "/parser/finance/categories",
   "purpose": "Spend categories in use.",
   "auth": {
    "text": "Session + view_finance | manage_finance | submit_invoice (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance",
     "submit_invoice",
     "view_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { categories:string[] } (sorted); not paged",
   "responses": [
    {
     "status": 200,
     "text": "{ categories:string[] } (sorted); not paged"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /parser/finance/currency",
   "group": "finance-vendors-settings",
   "method": "GET",
   "path": "/parser/finance/currency",
   "purpose": "Read currency configuration.",
   "auth": {
    "text": "Session + view_finance | manage_finance | manage_finance_policy (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance",
     "manage_finance_policy",
     "view_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 a CurrencyTable { baseCurrency:string, missingRates:array, rates:[ CurrencyRate ] } with each rate { code:string, rateToBase:number, updatedAt:string (date-time) } — rates ordered by code | 500 { detail:string } Every stored rate comes back; not paged.",
   "responses": [
    {
     "status": 200,
     "text": "a CurrencyTable { baseCurrency:string, missingRates:array, rates:[ CurrencyRate ] } with each rate { code:string, rateToBase:number, updatedAt:string (date-time) } — rates ordered by code"
    },
    {
     "status": 500,
     "text": "{ detail:string } Every stored rate comes back; not paged."
    }
   ],
   "errors": [
    {
     "status": 500,
     "text": "{ detail:string } Every stored rate comes back; not paged."
    }
   ],
   "guard": null
  },
  {
   "id": "POST /parser/finance/currency",
   "group": "finance-vendors-settings",
   "method": "POST",
   "path": "/parser/finance/currency",
   "purpose": "Update currency configuration.",
   "auth": {
    "text": "Session + manage_finance_policy (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance_policy"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The CurrencyPolicyUpdate object: { baseCurrency?:string, code?:string, rateToBase?:number }. Sets the base currency, or one exchange rate (code and rateToBase together), or both.",
   "response": "200 the CurrencyTable as it now stands, with base totals worked out again | 400 invalid FX rate (non-numeric, <=0, or outside 1e-6..1e6) | 500",
   "responses": [
    {
     "status": 200,
     "text": "the CurrencyTable as it now stands, with base totals worked out again"
    },
    {
     "status": 400,
     "text": "invalid FX rate (non-numeric, <=0, or outside 1e-6..1e6)"
    },
    {
     "status": 500,
     "text": ""
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "invalid FX rate (non-numeric, <=0, or outside 1e-6..1e6)"
    },
    {
     "status": 500,
     "text": ""
    }
   ],
   "guard": "manage_finance alone is refused 403 and the attempt is audited as currency_change_denied; upsert_rate rejects insane rates; recompute_all_base re-values only Draft/Rejected (frozen otherwise); logs currency_changed. Repeat: safe to repeat: the same base currency and rate are stored again, the answer is 200 and another currency_changed audit entry is written, saying no field changed"
  },
  {
   "id": "DELETE /parser/finance/currency/:code",
   "group": "finance-vendors-settings",
   "method": "DELETE",
   "path": "/parser/finance/currency/:code",
   "purpose": "Delete an exchange rate.",
   "auth": {
    "text": "Session + manage_finance_policy (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance_policy"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "code",
     "type": "string",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body. code is the three-letter currency in the path; it is upper-cased.",
   "response": "200 the CurrencyTable without that rate | 404 No exchange rate for <code> | 409 when the rate is still in use | 400 with the reason",
   "responses": [
    {
     "status": 200,
     "text": "the CurrencyTable without that rate"
    },
    {
     "status": 404,
     "text": "No exchange rate for <code>"
    },
    {
     "status": 409,
     "text": "when the rate is still in use"
    },
    {
     "status": 400,
     "text": "with the reason"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "No exchange rate for <code>"
    },
    {
     "status": 409,
     "text": "when the rate is still in use"
    },
    {
     "status": 400,
     "text": "with the reason"
    }
   ],
   "guard": "manage_finance alone is refused 403 and the attempt is audited. Repeat: a second call answers 404 'No exchange rate for <code>'"
  },
  {
   "id": "GET /parser/finance/settings",
   "group": "finance-vendors-settings",
   "method": "GET",
   "path": "/parser/finance/settings",
   "purpose": "Read the editable finance settings with their defaults.",
   "auth": {
    "text": "Session + view_finance | manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance",
     "view_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { settings:[{ key, label, kind, value, default, critical }] } — every setting; not paged",
   "responses": [
    {
     "status": 200,
     "text": "{ settings:[{ key, label, kind, value, default, critical }] } — every setting; not paged"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "PUT /parser/finance/settings",
   "group": "finance-vendors-settings",
   "method": "PUT",
   "path": "/parser/finance/settings",
   "purpose": "Change one finance setting.",
   "auth": {
    "text": "Session + manage_finance, or manage_security for a control setting (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance",
     "manage_security"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The FinanceSettingWrite object: { key:string, value }. One setting per call.",
   "response": "200 { key, value } (as stored) | 400 an unknown key, or a non-numeric value for a number setting — a number outside the setting's range is clamped to it, not refused",
   "responses": [
    {
     "status": 200,
     "text": "{ key, value } (as stored)"
    },
    {
     "status": 400,
     "text": "an unknown key, or a non-numeric value for a number setting — a number outside the setting's range is clamped to it, not refused"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "an unknown key, or a non-numeric value for a number setting — a number outside the setting's range is clamped to it, not refused"
    }
   ],
   "guard": "the control settings — enforce_sod, finance.high_value_floor, match.tolerance_pct and match.require_grn — need manage_security; every other key needs manage_finance; only keys on the fixed list can be written. Repeat: safe to repeat: the one stored value is written again, and another setting_changed audit entry is written"
  },
  {
   "id": "GET /parser/finance/digest",
   "group": "finance-vendors-settings",
   "method": "GET",
   "path": "/parser/finance/digest",
   "purpose": "The finance digest summary (the scheduled digest email is built from the same summary).",
   "auth": {
    "text": "Session + view_finance | manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance",
     "view_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { baseCurrency, totalSpend, invoiceCount, committedCount, pendingApproval, awaitingFinance:{ draft, draftAmount, held, heldAmount }, topVendors:[{ name, amount }], exceptions, momVariance, fxWarnings, generatedAt } | 500 { detail:string }",
   "responses": [
    {
     "status": 200,
     "text": "{ baseCurrency, totalSpend, invoiceCount, committedCount, pendingApproval, awaitingFinance:{ draft, draftAmount, held, heldAmount }, topVendors:[{ name, amount }], exceptions, momVariance, fxWarnings, generatedAt }"
    },
    {
     "status": 500,
     "text": "{ detail:string }"
    }
   ],
   "errors": [
    {
     "status": 500,
     "text": "{ detail:string }"
    }
   ],
   "guard": null
  },
  {
   "id": "POST /parser/finance/digest/send",
   "group": "finance-vendors-settings",
   "method": "POST",
   "path": "/parser/finance/digest/send",
   "purpose": "Send the finance digest now.",
   "auth": {
    "text": "Session + manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The DigestSend object: { recipients?:string[] }, optional (email addresses); without it the configured recipients are used. Addresses are trimmed, lower-cased and de-duplicated.",
   "response": "200 { success:true, message:string } | 400 if send fails (message from send_digest)",
   "responses": [
    {
     "status": 200,
     "text": "{ success:true, message:string }"
    },
    {
     "status": 400,
     "text": "if send fails (message from send_digest)"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "if send fails (message from send_digest)"
    }
   ],
   "guard": "best-effort SMTP send via customer's configured SMTP — failure returns 400 not 500. Repeat: not deduplicated: every call queues the digest again for each recipient"
  },
  {
   "id": "GET /parser/finance/audit",
   "group": "finance-vendors-settings",
   "method": "GET",
   "path": "/parser/finance/audit",
   "purpose": "Finance audit trail.",
   "auth": {
    "text": "Session + view_finance | manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance",
     "view_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [
    "limit"
   ],
   "query": [
    {
     "name": "doc_id",
     "type": "integer"
    },
    {
     "name": "limit",
     "type": "integer",
     "default": 200,
     "minimum": 1,
     "maximum": 500
    }
   ],
   "request": "Query: doc_id?, limit (default 200, clamped to 1–500).",
   "response": "200 { entries } — newest first: { id, docId, action, actorId, actorName, detail, at }; limit is the only paging control, there is no offset",
   "responses": [
    {
     "status": 200,
     "text": "{ entries } — newest first: { id, docId, action, actorId, actorName, detail, at }; limit is the only paging control, there is no offset"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /parser/finance/audit.csv",
   "group": "finance-vendors-settings",
   "method": "GET",
   "path": "/parser/finance/audit.csv",
   "purpose": "Controls-evidence export of the finance audit trail (auditor-ready CSV; who submitted/approved/paid/deleted what).",
   "auth": {
    "text": "Session + view_finance | manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance",
     "view_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query: doc_id? (scope to one document), action? (filter one action). No JSON body.",
   "response": "200 text/csv attachment 'finance_audit_trail.csv', oldest entry first. Not paged, and capped at 10,000 entries: on a longer trail the newest entries are the ones left out, so narrow the file with doc_id or action, or read the newest with GET /finance/audit | 500",
   "responses": [
    {
     "status": 200,
     "text": "text/csv attachment 'finance_audit_trail.csv', oldest entry first. Not paged, and capped at 10,000 entries: on a longer trail the newest entries are the ones left out, so narrow the file with doc_id or action, or read the newest with GET /finance/audit"
    },
    {
     "status": 500,
     "text": ""
    }
   ],
   "errors": [
    {
     "status": 500,
     "text": ""
    }
   ],
   "guard": "logs an 'exported' finance-audit entry recording the export itself"
  },
  {
   "id": "GET /parser/finance/purchase-orders",
   "group": "finance-procurement",
   "method": "GET",
   "path": "/parser/finance/purchase-orders",
   "purpose": "List purchase orders.",
   "auth": {
    "text": "Session + view_finance | manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance",
     "view_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query: status? No paging parameters.",
   "response": "200 { items:[ PurchaseOrder ], baseCurrency:string } — newest first, at most 500",
   "responses": [
    {
     "status": 200,
     "text": "{ items:[ PurchaseOrder ], baseCurrency:string } — newest first, at most 500"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /parser/finance/purchase-orders",
   "group": "finance-procurement",
   "method": "POST",
   "path": "/parser/finance/purchase-orders",
   "purpose": "Create a purchase order.",
   "auth": {
    "text": "Session + manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The PurchaseOrderCreate object: { po_number:string } required; vendor?:string, currency?:string (default USD), total_amount?:number, cost_center?:string, department?:string, project?:string, lines?:[ PurchaseOrderLineInput ] (each { description?:string, quantity?:number, unit_price?:number }), budget_override?:boolean. With lines, the total is the sum of quantity times unit_price and total_amount is not read.",
   "response": "200 { success:true, id:integer, po_number:string } | 400 po_number is required | 409 the PO number already exists, or the PO would exceed its budget and budget_override is not set. A repeat with the same po_number answers 409 and creates nothing",
   "responses": [
    {
     "status": 200,
     "text": "{ success:true, id:integer, po_number:string }"
    },
    {
     "status": 400,
     "text": "po_number is required"
    },
    {
     "status": 409,
     "text": "the PO number already exists, or the PO would exceed its budget and budget_override is not set. A repeat with the same po_number answers 409 and creates nothing"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "po_number is required"
    },
    {
     "status": 409,
     "text": "the PO number already exists, or the PO would exceed its budget and budget_override is not set. A repeat with the same po_number answers 409 and creates nothing"
    }
   ],
   "guard": "Repeat: the PO number is unique, so a second call with the same po_number answers 409 and creates nothing"
  },
  {
   "id": "GET /parser/finance/purchase-orders/:po_id",
   "group": "finance-procurement",
   "method": "GET",
   "path": "/parser/finance/purchase-orders/:po_id",
   "purpose": "One purchase order with its lines and receipts.",
   "auth": {
    "text": "Session + view_finance | manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance",
     "view_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "po_id",
     "type": "integer",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 the PurchaseOrderDetail object | 404 'Purchase order not found'",
   "responses": [
    {
     "status": 200,
     "text": "the PurchaseOrderDetail object"
    },
    {
     "status": 404,
     "text": "'Purchase order not found'"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'Purchase order not found'"
    }
   ],
   "guard": null
  },
  {
   "id": "POST /parser/finance/purchase-orders/:po_id/close",
   "group": "finance-procurement",
   "method": "POST",
   "path": "/parser/finance/purchase-orders/:po_id/close",
   "purpose": "Close, cancel or reopen a purchase order.",
   "auth": {
    "text": "Session + manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "po_id",
     "type": "integer",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The PurchaseOrderStatusWrite object: { status?:string }: closed (default), cancelled or open.",
   "response": "200 { success:true, id:integer, status:string } | 404 not found | 400 an unknown status",
   "responses": [
    {
     "status": 200,
     "text": "{ success:true, id:integer, status:string }"
    },
    {
     "status": 404,
     "text": "not found"
    },
    {
     "status": 400,
     "text": "an unknown status"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "not found"
    },
    {
     "status": 400,
     "text": "an unknown status"
    }
   ],
   "guard": "Repeat: the same status is stored again, the answer is 200 and another po_status_changed audit entry is written"
  },
  {
   "id": "POST /parser/finance/purchase-orders/bulk-close",
   "group": "finance-procurement",
   "method": "POST",
   "path": "/parser/finance/purchase-orders/bulk-close",
   "purpose": "Close, cancel or reopen many purchase orders.",
   "auth": {
    "text": "Session + manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The PurchaseOrderBulkStatus object: { ids:integer[] (1 to 500), status?:string }: closed (default), cancelled or open.",
   "response": "200 a BulkOutcome { ok:integer, failed:integer, results:[ BulkOutcomeItem ] } with each item { id, ok:boolean, error?:string } | 400 empty list, more than 500 ids, or an unknown status",
   "responses": [
    {
     "status": 200,
     "text": "a BulkOutcome { ok:integer, failed:integer, results:[ BulkOutcomeItem ] } with each item { id, ok:boolean, error?:string }"
    },
    {
     "status": 400,
     "text": "empty list, more than 500 ids, or an unknown status"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "empty list, more than 500 ids, or an unknown status"
    }
   ],
   "guard": "Repeat: as the single route: every order is reported ok again"
  },
  {
   "id": "POST /parser/finance/goods-receipts",
   "group": "finance-procurement",
   "method": "POST",
   "path": "/parser/finance/goods-receipts",
   "purpose": "Record a goods receipt against a purchase order.",
   "auth": {
    "text": "Session + manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The GoodsReceiptCreate object: { po_id:integer } required; grn_number?:string, lines?:[ GoodsReceiptLineInput ] (each { po_line_id?:integer, quantity_received?:number }).",
   "response": "200 { success:true, id:integer } | 404 'Purchase order not found'. Not deduplicated: every call records another receipt against the purchase order, so read the purchase order back before re-sending",
   "responses": [
    {
     "status": 200,
     "text": "{ success:true, id:integer }"
    },
    {
     "status": 404,
     "text": "'Purchase order not found'. Not deduplicated: every call records another receipt against the purchase order, so read the purchase order back before re-sending"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'Purchase order not found'. Not deduplicated: every call records another receipt against the purchase order, so read the purchase order back before re-sending"
    }
   ],
   "guard": "Repeat: not deduplicated: every call records another receipt against the purchase order"
  },
  {
   "id": "GET /parser/finance/match-exceptions",
   "group": "finance-procurement",
   "method": "GET",
   "path": "/parser/finance/match-exceptions",
   "purpose": "Invoice-to-order match exceptions.",
   "auth": {
    "text": "Session + view_finance | manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance",
     "view_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query: status (default open). An empty status returns every status. No paging parameters.",
   "response": "200 { exceptions:[ MatchException ] } — newest first, at most 200",
   "responses": [
    {
     "status": 200,
     "text": "{ exceptions:[ MatchException ] } — newest first, at most 200"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /parser/finance/match-exceptions/:exc_id/waive",
   "group": "finance-procurement",
   "method": "POST",
   "path": "/parser/finance/match-exceptions/:exc_id/waive",
   "purpose": "Waive one match exception.",
   "auth": {
    "text": "Session + manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "exc_id",
     "type": "integer",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The MatchExceptionWaive object: { reason:string } required.",
   "response": "200 { success:true } | 404 not found | 400 no reason | 403",
   "responses": [
    {
     "status": 200,
     "text": "{ success:true }"
    },
    {
     "status": 404,
     "text": "not found"
    },
    {
     "status": 400,
     "text": "no reason"
    },
    {
     "status": 403,
     "text": ""
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "not found"
    },
    {
     "status": 400,
     "text": "no reason"
    },
    {
     "status": 403,
     "text": ""
    }
   ],
   "guard": "where segregation of duties applies to the invoice amount, the person who uploaded or submitted the invoice cannot waive its exception. Repeat: a second call answers 200 and stores the newer reason and the person who gave it"
  },
  {
   "id": "POST /parser/finance/match-exceptions/bulk-waive",
   "group": "finance-procurement",
   "method": "POST",
   "path": "/parser/finance/match-exceptions/bulk-waive",
   "purpose": "Waive many match exceptions with one reason.",
   "auth": {
    "text": "Session + manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The MatchExceptionBulkWaive object: { ids:integer[] (1 to 500), reason:string }, both required.",
   "response": "200 a BulkOutcome { ok:integer, failed:integer, results:[ BulkOutcomeItem ] } with each item { id, ok:boolean, error?:string } | 400 empty list, more than 500 ids, or no reason",
   "responses": [
    {
     "status": 200,
     "text": "a BulkOutcome { ok:integer, failed:integer, results:[ BulkOutcomeItem ] } with each item { id, ok:boolean, error?:string }"
    },
    {
     "status": 400,
     "text": "empty list, more than 500 ids, or no reason"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "empty list, more than 500 ids, or no reason"
    }
   ],
   "guard": "each exception passes the same check as the single route. Repeat: an exception that is no longer open is not waived again: it is reported in results as failed with \"Already waived\""
  },
  {
   "id": "GET /parser/finance/approval-rules",
   "group": "finance-procurement",
   "method": "GET",
   "path": "/parser/finance/approval-rules",
   "purpose": "List the amount-tiered approval rules.",
   "auth": {
    "text": "Session + view_finance | manage_finance | manage_finance_policy (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance",
     "manage_finance_policy",
     "view_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { rules:[ ApprovalRule ] }, lowest minAmount first; every rule, not paged",
   "responses": [
    {
     "status": 200,
     "text": "{ rules:[ ApprovalRule ] }, lowest minAmount first; every rule, not paged"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /parser/finance/approval-rules/preview",
   "group": "finance-procurement",
   "method": "GET",
   "path": "/parser/finance/approval-rules/preview",
   "purpose": "Show which approval tier an amount would fall in.",
   "auth": {
    "text": "Session + view_finance | manage_finance | manage_finance_policy (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance",
     "manage_finance_policy",
     "view_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query: amount (default 0).",
   "response": "200 { amount:number, requiredApprovals:integer, rule:ApprovalRule|null } | 400 an amount that cannot be read",
   "responses": [
    {
     "status": 200,
     "text": "{ amount:number, requiredApprovals:integer, rule:ApprovalRule|null }"
    },
    {
     "status": 400,
     "text": "an amount that cannot be read"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "an amount that cannot be read"
    }
   ],
   "guard": null
  },
  {
   "id": "POST /parser/finance/approval-rules",
   "group": "finance-procurement",
   "method": "POST",
   "path": "/parser/finance/approval-rules",
   "purpose": "Create an approval rule.",
   "auth": {
    "text": "Session + manage_finance_policy (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance_policy"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The ApprovalRuleWrite object: { name?:string (at most 80 characters, default \"Rule\"), minAmount?:number (default 0), maxAmount?:number|null (null for no ceiling), requiredApprovals?:integer (1 to 10, default 1), isActive?:boolean (default true) }.",
   "response": "200 { success:true, id:integer } | 400 with the validation message",
   "responses": [
    {
     "status": 200,
     "text": "{ success:true, id:integer }"
    },
    {
     "status": 400,
     "text": "with the validation message"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "with the validation message"
    }
   ],
   "guard": "manage_finance alone is refused 403 and the attempt is audited. Repeat: not deduplicated: every call creates another rule, also with the same name and amounts"
  },
  {
   "id": "PATCH /parser/finance/approval-rules/:rule_id",
   "group": "finance-procurement",
   "method": "PATCH",
   "path": "/parser/finance/approval-rules/:rule_id",
   "purpose": "Change an approval rule.",
   "auth": {
    "text": "Session + manage_finance_policy (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance_policy"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "rule_id",
     "type": "integer",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The ApprovalRuleWrite object; only the keys sent are changed, and the result is validated as a whole (maxAmount must stay above minAmount).",
   "response": "200 { success:true } | 400 with the validation message | 404 'Rule not found'",
   "responses": [
    {
     "status": 200,
     "text": "{ success:true }"
    },
    {
     "status": 400,
     "text": "with the validation message"
    },
    {
     "status": 404,
     "text": "'Rule not found'"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "with the validation message"
    },
    {
     "status": 404,
     "text": "'Rule not found'"
    }
   ],
   "guard": "manage_finance alone is refused 403 and the attempt is audited. Repeat: the same values are stored again, the answer is 200 and another approval_rule_updated audit entry is written, saying no field changed"
  },
  {
   "id": "DELETE /parser/finance/approval-rules/:rule_id",
   "group": "finance-procurement",
   "method": "DELETE",
   "path": "/parser/finance/approval-rules/:rule_id",
   "purpose": "Delete an approval rule.",
   "auth": {
    "text": "Session + manage_finance_policy (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance_policy"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "rule_id",
     "type": "integer",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body. rule_id is an integer in the path.",
   "response": "200 { success:true }",
   "responses": [
    {
     "status": 200,
     "text": "{ success:true }"
    }
   ],
   "errors": [],
   "guard": "manage_finance alone is refused 403 and the attempt is audited. Repeat: a second call, like a call for a rule that never existed, answers 200 { success:true } and changes nothing"
  },
  {
   "id": "GET /parser/finance/approvals-queue",
   "group": "finance-procurement",
   "method": "GET",
   "path": "/parser/finance/approvals-queue",
   "purpose": "Submitted invoices awaiting approval, oldest first, with approval progress.",
   "auth": {
    "text": "Session + view_finance | manage_finance | approve_invoice (licensed)",
    "kind": "session",
    "permissions": [
     "approve_invoice",
     "manage_finance",
     "view_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { queue:[ ApprovalQueueItem ] } — at most 200 rows",
   "responses": [
    {
     "status": 200,
     "text": "{ queue:[ ApprovalQueueItem ] } — at most 200 rows"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /parser/finance/documents/:doc_id/approvals",
   "group": "finance-procurement",
   "method": "GET",
   "path": "/parser/finance/documents/:doc_id/approvals",
   "purpose": "Approval history and progress of one invoice.",
   "auth": {
    "text": "Session + view_finance | manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance",
     "view_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "doc_id",
     "type": "integer",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { required:integer, count:integer (distinct approvers so far), actions:[{ actorId:integer|null, actorName:string|null, decision:string, comment:string|null, at:string (date-time) }] }, oldest action first; every action, not paged | 404 'Document not found'",
   "responses": [
    {
     "status": 200,
     "text": "{ required:integer, count:integer (distinct approvers so far), actions:[{ actorId:integer|null, actorName:string|null, decision:string, comment:string|null, at:string (date-time) }] }, oldest action first; every action, not paged"
    },
    {
     "status": 404,
     "text": "'Document not found'"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'Document not found'"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /parser/finance/gl-accounts",
   "group": "finance-procurement",
   "method": "GET",
   "path": "/parser/finance/gl-accounts",
   "purpose": "List the chart of accounts (GL accounts).",
   "auth": {
    "text": "Session + view_finance | manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance",
     "view_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { accounts:[ GLAccount ] } (ordered by code); every account, not paged",
   "responses": [
    {
     "status": 200,
     "text": "{ accounts:[ GLAccount ] } (ordered by code); every account, not paged"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /parser/finance/gl-accounts",
   "group": "finance-procurement",
   "method": "POST",
   "path": "/parser/finance/gl-accounts",
   "purpose": "Create a GL account.",
   "auth": {
    "text": "Session + manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The GLAccountCreate object: { code:string } required; name?:string (defaults to code), type?:string.",
   "response": "200 { success:true, id:integer } | 400 code is required | 409 GL account <code> already exists",
   "responses": [
    {
     "status": 200,
     "text": "{ success:true, id:integer }"
    },
    {
     "status": 400,
     "text": "code is required"
    },
    {
     "status": 409,
     "text": "GL account <code> already exists"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "code is required"
    },
    {
     "status": 409,
     "text": "GL account <code> already exists"
    }
   ],
   "guard": "code unique; logs gl_account_created. Repeat: the code is unique, so a second call with the same code answers 409 and creates nothing"
  },
  {
   "id": "GET /parser/finance/close/:period",
   "group": "finance-procurement",
   "method": "GET",
   "path": "/parser/finance/close/:period",
   "purpose": "Month-end close readiness for a period (open blockers: documents in review/awaiting approval, unresolved match exceptions).",
   "auth": {
    "text": "Session + view_finance | manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance",
     "view_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "period",
     "type": "string",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": "period path param (e.g. '2026-08'). No body.",
   "response": "200 { period:string, status:string (open until the period is locked), needsReview:integer, awaitingApproval:integer, openExceptions:integer, committed:integer, total:integer, uncoded:integer, readyPct:integer, canLock:boolean }; canLock is true when the first three counts are all zero",
   "responses": [
    {
     "status": 200,
     "text": "{ period:string, status:string (open until the period is locked), needsReview:integer, awaitingApproval:integer, openExceptions:integer, committed:integer, total:integer, uncoded:integer, readyPct:integer, canLock:boolean }; canLock is true when the first three counts are all zero"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /parser/finance/close/:period",
   "group": "finance-procurement",
   "method": "POST",
   "path": "/parser/finance/close/:period",
   "purpose": "Lock a period (month-end close). Posts balanced double-entry GL journals (net expense / input tax / AP) for the period; refuses to lock a dirty period.",
   "auth": {
    "text": "Session + close_finance (licensed)",
    "kind": "session",
    "permissions": [
     "close_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "period",
     "type": "string",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The PeriodClose object: { force?:boolean, reason?:string }. period is in the path (such as 2026-08). Forcing a close over open blockers requires a written reason.",
   "response": "200 a PeriodCloseResult { period:string, status:'locked', journalsPosted:integer, batch:string, forced:boolean } | 400 'Forcing a close over open items requires a written reason.' | 409 'Period <period> is already locked.' — what a repeat gets | 409 when the period still has open blockers and force is not set",
   "responses": [
    {
     "status": 200,
     "text": "a PeriodCloseResult { period:string, status:'locked', journalsPosted:integer, batch:string, forced:boolean }"
    },
    {
     "status": 400,
     "text": "'Forcing a close over open items requires a written reason.'"
    },
    {
     "status": 409,
     "text": "'Period <period> is already locked.' — what a repeat gets"
    },
    {
     "status": 409,
     "text": "when the period still has open blockers and force is not set"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "'Forcing a close over open items requires a written reason.'"
    },
    {
     "status": 409,
     "text": "'Period <period> is already locked.' — what a repeat gets"
    },
    {
     "status": 409,
     "text": "when the period still has open blockers and force is not set"
    }
   ],
   "guard": "a dedicated close permission separated from approve/pay (SoD); a forced override is captured on the audit trail with its reason. Repeat: a second call answers 409 'Period <period> is already locked.' and posts nothing"
  },
  {
   "id": "GET /parser/finance/close/:period/journals.csv",
   "group": "finance-procurement",
   "method": "GET",
   "path": "/parser/finance/close/:period/journals.csv",
   "purpose": "Export the period's double-entry GL journals as CSV in a QuickBooks, Xero, or generic template.",
   "auth": {
    "text": "Session + view_finance | manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance",
     "view_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "period",
     "type": "string",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": "period path param; query format=generic|quickbooks|xero (default generic; unknown falls back to generic). No JSON body.",
   "response": "200 text/csv attachment 'journals_<period>_<fmt>.csv'",
   "responses": [
    {
     "status": 200,
     "text": "text/csv attachment 'journals_<period>_<fmt>.csv'"
    }
   ],
   "errors": [],
   "guard": "logs an 'exported' entry recording the period + format"
  },
  {
   "id": "POST /parser/finance/adjustments",
   "group": "finance-procurement",
   "method": "POST",
   "path": "/parser/finance/adjustments",
   "purpose": "Post a manual adjustment journal.",
   "auth": {
    "text": "Session + manage_finance | close_finance (licensed)",
    "kind": "session",
    "permissions": [
     "close_finance",
     "manage_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The AdjustmentCreate object: { reason:string, debitGl:string, creditGl:string, amount:number }, all required; amount greater than zero; the two accounts must differ and, once a chart of accounts exists, both must be in it.",
   "response": "200 { period, batch, amount, debitGl, creditGl } — booked to the current period | 400 { detail:string } for a missing reason, a missing or identical account, an account not in the chart, or an amount that is not positive | 409 { detail:string } when the current period is locked",
   "responses": [
    {
     "status": 200,
     "text": "{ period, batch, amount, debitGl, creditGl } — booked to the current period"
    },
    {
     "status": 400,
     "text": "{ detail:string } for a missing reason, a missing or identical account, an account not in the chart, or an amount that is not positive"
    },
    {
     "status": 409,
     "text": "{ detail:string } when the current period is locked"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ detail:string } for a missing reason, a missing or identical account, an account not in the chart, or an amount that is not positive"
    },
    {
     "status": 409,
     "text": "{ detail:string } when the current period is locked"
    }
   ],
   "guard": "not deduplicated: every call posts another balanced debit and credit pair; the batch reference is ADJ-<UTC timestamp to the second>, so two calls in the same second share it; the entry and its reason are written to the finance audit trail. Repeat: not deduplicated: every call posts another debit and credit pair"
  },
  {
   "id": "GET /parser/finance/reconciliation",
   "group": "finance-procurement",
   "method": "GET",
   "path": "/parser/finance/reconciliation",
   "purpose": "Reconciliation figures for one period.",
   "auth": {
    "text": "Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | close_finance (licensed)",
    "kind": "session",
    "permissions": [
     "close_finance",
     "manage_all_finance_budgets",
     "manage_finance",
     "view_finance",
     "view_finance_consolidated"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query: period (required).",
   "response": "200 { period:string, invoiceActual:number, glPosted:number, difference:number, accounts:[{ glCode:string, invoiceActual:number, glPosted:number, difference:number }], reconcilingItems:[ the accounts whose difference is at least 0.01 ] }, accounts ordered by glCode; every account, not paged | 422 when period is absent | 400 when it is empty",
   "responses": [
    {
     "status": 200,
     "text": "{ period:string, invoiceActual:number, glPosted:number, difference:number, accounts:[{ glCode:string, invoiceActual:number, glPosted:number, difference:number }], reconcilingItems:[ the accounts whose difference is at least 0.01 ] }, accounts ordered by glCode; every account, not paged"
    },
    {
     "status": 422,
     "text": "when period is absent"
    },
    {
     "status": 400,
     "text": "when it is empty"
    }
   ],
   "errors": [
    {
     "status": 422,
     "text": "when period is absent"
    },
    {
     "status": 400,
     "text": "when it is empty"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /parser/finance/gl-sync",
   "group": "finance-procurement",
   "method": "GET",
   "path": "/parser/finance/gl-sync",
   "purpose": "Outbound GL-sync status: active connector, and synced vs pending journal counts.",
   "auth": {
    "text": "Session + view_finance | manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance",
     "view_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query: period? optional (scope counts to one period). No JSON body.",
   "response": "200 { connector, period, total, synced, pending, live, configured } — 'manual' is always configured; a live connector reports configured:false until its credentials are supplied",
   "responses": [
    {
     "status": 200,
     "text": "{ connector, period, total, synced, pending, live, configured } — 'manual' is always configured; a live connector reports configured:false until its credentials are supplied"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "PUT /parser/finance/gl-sync/connector",
   "group": "finance-procurement",
   "method": "PUT",
   "path": "/parser/finance/gl-sync/connector",
   "purpose": "Choose the outbound GL connector.",
   "auth": {
    "text": "Session + manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The GLConnectorWrite object: { connector:string }: one of manual | quickbooks | netsuite | xero.",
   "response": "200 { connector } | 400 Unknown GL connector '<x>'",
   "responses": [
    {
     "status": 200,
     "text": "{ connector }"
    },
    {
     "status": 400,
     "text": "Unknown GL connector '<x>'"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "Unknown GL connector '<x>'"
    }
   ],
   "guard": "logs gl_sync_connector; only 'manual' (file/CSV transport) is active on an instance without provider credentials. Repeat: safe to repeat: the one stored setting is written again with the same value"
  },
  {
   "id": "POST /parser/finance/gl-sync/:period",
   "group": "finance-procurement",
   "method": "POST",
   "path": "/parser/finance/gl-sync/:period",
   "purpose": "Push the period's unsynced journals through the active connector. Idempotent — a journal that already carries an external_ref is never pushed again, so a re-run or retry cannot double-book.",
   "auth": {
    "text": "Session + manage_finance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "period",
     "type": "string",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body. period is in the path.",
   "response": "200 a GLSyncRun { connector:string, pushed:integer, live:boolean, note:string } | 400 (fail-closed) if the selected live connector's OAuth credentials are not configured — never a silent no-op | 500",
   "responses": [
    {
     "status": 200,
     "text": "a GLSyncRun { connector:string, pushed:integer, live:boolean, note:string }"
    },
    {
     "status": 400,
     "text": "(fail-closed) if the selected live connector's OAuth credentials are not configured — never a silent no-op"
    },
    {
     "status": 500,
     "text": ""
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "(fail-closed) if the selected live connector's OAuth credentials are not configured — never a silent no-op"
    },
    {
     "status": 500,
     "text": ""
    }
   ],
   "guard": "'manual' stamps a deterministic external_ref and marks rows exported; live connectors fail closed until credentialled; logs gl_synced. Repeat: with the manual connector a second call finds nothing left to mark and answers 200 with pushed 0"
  },
  {
   "id": "GET /parser/finance/chargeback",
   "group": "finance-procurement",
   "method": "GET",
   "path": "/parser/finance/chargeback",
   "purpose": "Chargeback breakdown by cost centre.",
   "auth": {
    "text": "Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets (licensed)",
    "kind": "session",
    "permissions": [
     "manage_all_finance_budgets",
     "manage_finance",
     "view_finance",
     "view_finance_consolidated"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query: dimension (default cost_center), period? dimension is cost_center | department | project | category; any other value is treated as cost_center.",
   "response": "200 { dimension, period, allocations:[{ label, amount, invoiceCount, sharePct }] } — largest amount first; spend with no value on the dimension is labelled Unallocated. Not paged: every value of the dimension in one reply. | 500 { detail:string }",
   "responses": [
    {
     "status": 200,
     "text": "{ dimension, period, allocations:[{ label, amount, invoiceCount, sharePct }] } — largest amount first; spend with no value on the dimension is labelled Unallocated. Not paged: every value of the dimension in one reply."
    },
    {
     "status": 500,
     "text": "{ detail:string }"
    }
   ],
   "errors": [
    {
     "status": 500,
     "text": "{ detail:string }"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /parser/finance/budgets",
   "group": "finance-budgets",
   "method": "GET",
   "path": "/parser/finance/budgets",
   "purpose": "List budgets.",
   "auth": {
    "text": "Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "kind": "session",
    "permissions": [
     "approve_finance_budget",
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget",
     "view_finance",
     "view_finance_budgets",
     "view_finance_consolidated"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query: budgetType?, status?, period?, scopeType?, department?, over?:boolean, atRisk?:boolean, sort? sort is period | name | budget | actual | available | consumed, with a leading - for descending; without it rows come newest period first. No paging parameters.",
   "response": "200 { budgets:[ Budget ] } — every budget in the caller's scope that matches; not paged | 500 { detail:string }",
   "responses": [
    {
     "status": 200,
     "text": "{ budgets:[ Budget ] } — every budget in the caller's scope that matches; not paged"
    },
    {
     "status": 500,
     "text": "{ detail:string }"
    }
   ],
   "errors": [
    {
     "status": 500,
     "text": "{ detail:string }"
    }
   ],
   "guard": "scoped: a caller who holds only the scoped budget permissions reaches only budgets in their own scope"
  },
  {
   "id": "POST /parser/finance/budgets",
   "group": "finance-budgets",
   "method": "POST",
   "path": "/parser/finance/budgets",
   "purpose": "Create a budget.",
   "auth": {
    "text": "Session + manage_finance | manage_all_finance_budgets | manage_finance_budget (licensed)",
    "kind": "session",
    "permissions": [
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The BudgetWrite object. { period:string } required ('YYYY-MM' or 'YYYY'); scopeType?:string (default 'global'), scopeValue?:string, amount?:number (default 0), currency?:string, name?:string, description?:string, budgetType?:string (default 'opex'), method?:string (default 'top_down'), startDate?, endDate?, periodKind?:string, authoritativeSource?: invoice | cloud | both (default invoice), tags?:array, glAccountId?:integer, recurrence?:string, recurrenceInterval?:string, warnPct?:number, overPct?:number (fractions, 0 to 1).",
   "response": "200 the Budget object | 400 missing period or an invalid value | 403 a scope outside the caller's authority (audited) | 500",
   "responses": [
    {
     "status": 200,
     "text": "the Budget object"
    },
    {
     "status": 400,
     "text": "missing period or an invalid value"
    },
    {
     "status": 403,
     "text": "a scope outside the caller's authority (audited)"
    },
    {
     "status": 500,
     "text": ""
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "missing period or an invalid value"
    },
    {
     "status": 403,
     "text": "a scope outside the caller's authority (audited)"
    },
    {
     "status": 500,
     "text": ""
    }
   ],
   "guard": "the owner is stamped by the server; a scoped maker owns what they create; logs budget_created. Repeat: not deduplicated: every call creates another budget, also for the same scope and period"
  },
  {
   "id": "PATCH /parser/finance/budgets/:budget_id",
   "group": "finance-budgets",
   "method": "PATCH",
   "path": "/parser/finance/budgets/:budget_id",
   "purpose": "Update a budget.",
   "auth": {
    "text": "Session + manage_finance | manage_all_finance_budgets | manage_finance_budget (licensed)",
    "kind": "session",
    "permissions": [
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "budget_id",
     "type": "integer",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The BudgetWrite object, all keys optional; only the keys sent are applied: scopeType, scopeValue, period, amount, currency, name, description, budgetType, method, startDate, endDate, periodKind, authoritativeSource, tags, glAccountId, recurrence, recurrenceInterval, warnPct, overPct. budget_id is an integer in the path.",
   "response": "200 the Budget object | 404 budget not found | 403 not your budget (audited) | 409 the budget is locked (approved) — reforecast it to get an editable version | 400 | 500",
   "responses": [
    {
     "status": 200,
     "text": "the Budget object"
    },
    {
     "status": 404,
     "text": "budget not found"
    },
    {
     "status": 403,
     "text": "not your budget (audited)"
    },
    {
     "status": 409,
     "text": "the budget is locked (approved) — reforecast it to get an editable version"
    },
    {
     "status": 400,
     "text": ""
    },
    {
     "status": 500,
     "text": ""
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "budget not found"
    },
    {
     "status": 403,
     "text": "not your budget (audited)"
    },
    {
     "status": 409,
     "text": "the budget is locked (approved) — reforecast it to get an editable version"
    },
    {
     "status": 400,
     "text": ""
    },
    {
     "status": 500,
     "text": ""
    }
   ],
   "guard": "a scoped maker can edit only their own budget; editing an approved budget that was unlocked sends it back for approval; logs budget_updated"
  },
  {
   "id": "DELETE /parser/finance/budgets/:budget_id",
   "group": "finance-budgets",
   "method": "DELETE",
   "path": "/parser/finance/budgets/:budget_id",
   "purpose": "Delete a budget.",
   "auth": {
    "text": "Session + manage_finance | manage_all_finance_budgets | manage_finance_budget (licensed)",
    "kind": "session",
    "permissions": [
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "budget_id",
     "type": "integer",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body. budget_id path param (int).",
   "response": "200 {success:true} | 403 not your budget | 404 budget not found | 409 an approved budget cannot be deleted",
   "responses": [
    {
     "status": 200,
     "text": "{success:true}"
    },
    {
     "status": 403,
     "text": "not your budget"
    },
    {
     "status": 404,
     "text": "budget not found"
    },
    {
     "status": 409,
     "text": "an approved budget cannot be deleted"
    }
   ],
   "errors": [
    {
     "status": 403,
     "text": "not your budget"
    },
    {
     "status": 404,
     "text": "budget not found"
    },
    {
     "status": 409,
     "text": "an approved budget cannot be deleted"
    }
   ],
   "guard": "logs budget_deleted. Repeat: a second call answers 404 'Budget not found'"
  },
  {
   "id": "GET /parser/finance/budgets/summary",
   "group": "finance-budgets",
   "method": "GET",
   "path": "/parser/finance/budgets/summary",
   "purpose": "Organisation-wide budget summary.",
   "auth": {
    "text": "Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets (licensed)",
    "kind": "session",
    "permissions": [
     "manage_all_finance_budgets",
     "manage_finance",
     "view_finance",
     "view_finance_consolidated"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query: period?",
   "response": "200 { departments:[], totals } — departments ordered by budget, largest first. Not paged. | 500 { detail:string }",
   "responses": [
    {
     "status": 200,
     "text": "{ departments:[], totals } — departments ordered by budget, largest first. Not paged."
    },
    {
     "status": 500,
     "text": "{ detail:string }"
    }
   ],
   "errors": [
    {
     "status": 500,
     "text": "{ detail:string }"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /parser/finance/budgets/export.csv",
   "group": "finance-budgets",
   "method": "GET",
   "path": "/parser/finance/budgets/export.csv",
   "purpose": "Budget, actual and committed figures as CSV.",
   "auth": {
    "text": "Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "kind": "session",
    "permissions": [
     "approve_finance_budget",
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget",
     "view_finance",
     "view_finance_budgets",
     "view_finance_consolidated"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 text/csv: every budget the caller may see, in one file; no query parameters are read",
   "responses": [
    {
     "status": 200,
     "text": "text/csv: every budget the caller may see, in one file; no query parameters are read"
    }
   ],
   "errors": [],
   "guard": "scoped: a caller who holds only the scoped budget permissions reaches only budgets in their own scope"
  },
  {
   "id": "GET /parser/finance/budgets/approvals-inbox",
   "group": "finance-budgets",
   "method": "GET",
   "path": "/parser/finance/budgets/approvals-inbox",
   "purpose": "Budget approvals inbox for the caller.",
   "auth": {
    "text": "Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "kind": "session",
    "permissions": [
     "approve_finance_budget",
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget",
     "view_finance",
     "view_finance_budgets",
     "view_finance_consolidated"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { budgets:[ Budget ], transfers:[ BudgetTransfer ], count:integer } — submitted budgets the caller may approve and pending transfers the caller may act on; count is their sum. Not paged: budgets holds every one; transfers holds at most 200. | 500 { detail:string }",
   "responses": [
    {
     "status": 200,
     "text": "{ budgets:[ Budget ], transfers:[ BudgetTransfer ], count:integer } — submitted budgets the caller may approve and pending transfers the caller may act on; count is their sum. Not paged: budgets holds every one; transfers holds at most 200."
    },
    {
     "status": 500,
     "text": "{ detail:string }"
    }
   ],
   "errors": [
    {
     "status": 500,
     "text": "{ detail:string }"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /parser/finance/budgets/alerts",
   "group": "finance-budgets",
   "method": "GET",
   "path": "/parser/finance/budgets/alerts",
   "purpose": "Budget alerts.",
   "auth": {
    "text": "Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "kind": "session",
    "permissions": [
     "approve_finance_budget",
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget",
     "view_finance",
     "view_finance_budgets",
     "view_finance_consolidated"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query: includeAck?:boolean (default false).",
   "response": "200 { alerts:[ BudgetAlert ] } — newest first, at most 100; no paging parameters",
   "responses": [
    {
     "status": 200,
     "text": "{ alerts:[ BudgetAlert ] } — newest first, at most 100; no paging parameters"
    }
   ],
   "errors": [],
   "guard": "scoped: a caller who holds only the scoped budget permissions reaches only budgets in their own scope"
  },
  {
   "id": "POST /parser/finance/budgets/alerts/sweep",
   "group": "finance-budgets",
   "method": "POST",
   "path": "/parser/finance/budgets/alerts/sweep",
   "purpose": "Run the budget alert check now.",
   "auth": {
    "text": "Session + manage_finance | manage_all_finance_budgets | view_finance_consolidated (licensed)",
    "kind": "session",
    "permissions": [
     "manage_all_finance_budgets",
     "manage_finance",
     "view_finance_consolidated"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 an AlertSweepResult { evaluated:integer, fired:integer } | 500 { detail:string }",
   "responses": [
    {
     "status": 200,
     "text": "an AlertSweepResult { evaluated:integer, fired:integer }"
    },
    {
     "status": 500,
     "text": "{ detail:string }"
    }
   ],
   "errors": [
    {
     "status": 500,
     "text": "{ detail:string }"
    }
   ],
   "guard": "Repeat: safe to repeat: an alert is raised only when a budget has moved to a higher tier since its last alert, so a second sweep with unchanged figures answers fired 0"
  },
  {
   "id": "POST /parser/finance/budgets/alerts/:alert_id/ack",
   "group": "finance-budgets",
   "method": "POST",
   "path": "/parser/finance/budgets/alerts/:alert_id/ack",
   "purpose": "Acknowledge a budget alert.",
   "auth": {
    "text": "Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "kind": "session",
    "permissions": [
     "approve_finance_budget",
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget",
     "view_finance",
     "view_finance_budgets",
     "view_finance_consolidated"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "alert_id",
     "type": "integer",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { success:true } | 404 'Alert not found'",
   "responses": [
    {
     "status": 200,
     "text": "{ success:true }"
    },
    {
     "status": 404,
     "text": "'Alert not found'"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'Alert not found'"
    }
   ],
   "guard": "Repeat: a second call answers 200 and records the newer acknowledgement"
  },
  {
   "id": "GET /parser/finance/budgets/audit",
   "group": "finance-budgets",
   "method": "GET",
   "path": "/parser/finance/budgets/audit",
   "purpose": "Audit trail of budget actions.",
   "auth": {
    "text": "Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | close_finance (licensed)",
    "kind": "session",
    "permissions": [
     "close_finance",
     "manage_all_finance_budgets",
     "manage_finance",
     "view_finance",
     "view_finance_consolidated"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [
    "limit"
   ],
   "query": [
    {
     "name": "limit",
     "type": "integer",
     "default": 500,
     "minimum": 1,
     "maximum": 2000
    }
   ],
   "request": "Query: limit (default 500, clamped to 1–2000).",
   "response": "200 { entries:[{ id:integer, docId:integer|null, action:string, actorId:integer|null, actorName:string|null, detail:string|null, at:string (date-time) }] }; limit is the only paging control, there is no offset",
   "responses": [
    {
     "status": 200,
     "text": "{ entries:[{ id:integer, docId:integer|null, action:string, actorId:integer|null, actorName:string|null, detail:string|null, at:string (date-time) }] }; limit is the only paging control, there is no offset"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /parser/finance/budgets/audit.csv",
   "group": "finance-budgets",
   "method": "GET",
   "path": "/parser/finance/budgets/audit.csv",
   "purpose": "Audit trail of budget actions as CSV.",
   "auth": {
    "text": "Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | close_finance (licensed)",
    "kind": "session",
    "permissions": [
     "close_finance",
     "manage_all_finance_budgets",
     "manage_finance",
     "view_finance",
     "view_finance_consolidated"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 text/csv, attachment 'budget-audit-evidence.csv'. No query parameters are read. The file is capped at 10,000 entries, oldest first: on a longer trail the newest entries are the ones left out, so read those with GET /finance/budgets/audit",
   "responses": [
    {
     "status": 200,
     "text": "text/csv, attachment 'budget-audit-evidence.csv'. No query parameters are read. The file is capped at 10,000 entries, oldest first: on a longer trail the newest entries are the ones left out, so read those with GET /finance/budgets/audit"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /parser/finance/budgets/:budget_id/submit",
   "group": "finance-budgets",
   "method": "POST",
   "path": "/parser/finance/budgets/:budget_id/submit",
   "purpose": "Submit a budget for approval.",
   "auth": {
    "text": "Session + manage_finance | manage_all_finance_budgets | manage_finance_budget | submit_finance_budget (licensed)",
    "kind": "session",
    "permissions": [
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget",
     "submit_finance_budget"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "budget_id",
     "type": "integer",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { id:integer, status:string } | the lifecycle error status with its message — 409 unless the budget is Draft or Rejected (what a repeat gets), 409 for a historical version or a locked budget, 403 for a budget that is not the caller's",
   "responses": [
    {
     "status": 200,
     "text": "{ id:integer, status:string } | the lifecycle error status with its message — 409 unless the budget is Draft or Rejected (what a repeat gets), 409 for a historical version or a locked budget, 403 for a budget that is not the caller's"
    }
   ],
   "errors": [],
   "guard": "Repeat: a second call answers 409, because the budget is no longer Draft or Rejected"
  },
  {
   "id": "POST /parser/finance/budgets/:budget_id/approve",
   "group": "finance-budgets",
   "method": "POST",
   "path": "/parser/finance/budgets/:budget_id/approve",
   "purpose": "Approve a submitted budget.",
   "auth": {
    "text": "Session + manage_finance | manage_all_finance_budgets | approve_finance_budget (licensed)",
    "kind": "session",
    "permissions": [
     "approve_finance_budget",
     "manage_all_finance_budgets",
     "manage_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "budget_id",
     "type": "integer",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { id:integer, status:string, locked:boolean } | the lifecycle error status with its message — 409 unless the budget is Submitted (what a repeat gets)",
   "responses": [
    {
     "status": 200,
     "text": "{ id:integer, status:string, locked:boolean } | the lifecycle error status with its message — 409 unless the budget is Submitted (what a repeat gets)"
    }
   ],
   "errors": [],
   "guard": "Repeat: a second call answers 409, because the budget is no longer Submitted"
  },
  {
   "id": "POST /parser/finance/budgets/:budget_id/reject",
   "group": "finance-budgets",
   "method": "POST",
   "path": "/parser/finance/budgets/:budget_id/reject",
   "purpose": "Reject a submitted budget.",
   "auth": {
    "text": "Session + manage_finance | manage_all_finance_budgets | approve_finance_budget (licensed)",
    "kind": "session",
    "permissions": [
     "approve_finance_budget",
     "manage_all_finance_budgets",
     "manage_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "budget_id",
     "type": "integer",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The BudgetReject object: { reason:string } required.",
   "response": "200 { id:integer, status:string } | the lifecycle error status with its message — 409 unless the budget is Submitted, 400 when no reason is given, 403 when the caller is not an eligible approver",
   "responses": [
    {
     "status": 200,
     "text": "{ id:integer, status:string } | the lifecycle error status with its message — 409 unless the budget is Submitted, 400 when no reason is given, 403 when the caller is not an eligible approver"
    }
   ],
   "errors": [],
   "guard": "Repeat: a second call answers 409, because the budget is no longer Submitted"
  },
  {
   "id": "POST /parser/finance/budgets/:budget_id/reforecast",
   "group": "finance-budgets",
   "method": "POST",
   "path": "/parser/finance/budgets/:budget_id/reforecast",
   "purpose": "Create a new editable version of an approved budget.",
   "auth": {
    "text": "Session + manage_finance | manage_all_finance_budgets | manage_finance_budget | submit_finance_budget (licensed)",
    "kind": "session",
    "permissions": [
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget",
     "submit_finance_budget"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "budget_id",
     "type": "integer",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { id:integer, versionNo:integer, status:string } — the new version | 409 for a historical version | 403 for a budget that is not the caller's",
   "responses": [
    {
     "status": 200,
     "text": "{ id:integer, versionNo:integer, status:string } — the new version"
    },
    {
     "status": 409,
     "text": "for a historical version"
    },
    {
     "status": 403,
     "text": "for a budget that is not the caller's"
    }
   ],
   "errors": [
    {
     "status": 409,
     "text": "for a historical version"
    },
    {
     "status": 403,
     "text": "for a budget that is not the caller's"
    }
   ],
   "guard": "Repeat: every call creates a further version and makes it the current one"
  },
  {
   "id": "POST /parser/finance/budgets/:budget_id/lock",
   "group": "finance-budgets",
   "method": "POST",
   "path": "/parser/finance/budgets/:budget_id/lock",
   "purpose": "Lock or unlock a budget.",
   "auth": {
    "text": "Session + manage_finance | manage_all_finance_budgets | approve_finance_budget | manage_finance_policy (licensed)",
    "kind": "session",
    "permissions": [
     "approve_finance_budget",
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_policy"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "budget_id",
     "type": "integer",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The BudgetLock object: { locked?:boolean }, default true. A call with no body locks.",
   "response": "200 { id:integer, locked:boolean } | the lifecycle error status with its message",
   "responses": [
    {
     "status": 200,
     "text": "{ id:integer, locked:boolean } | the lifecycle error status with its message"
    }
   ],
   "errors": [],
   "guard": "Repeat: the same lock state is stored again, the answer is 200 and another budget_locked or budget_unlocked audit entry is written"
  },
  {
   "id": "POST /parser/finance/budgets/:budget_id/freeze",
   "group": "finance-budgets",
   "method": "POST",
   "path": "/parser/finance/budgets/:budget_id/freeze",
   "purpose": "Freeze or release a budget. An invoice approval against a frozen budget is refused with 409, and a budget override does not lift it.",
   "auth": {
    "text": "Session + manage_finance | manage_all_finance_budgets | close_finance (licensed)",
    "kind": "session",
    "permissions": [
     "close_finance",
     "manage_all_finance_budgets",
     "manage_finance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "budget_id",
     "type": "integer",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The BudgetFreeze object: { frozen?:boolean (default true), reason?:string }. A call with no body freezes.",
   "response": "200 { id:integer, frozen:boolean }",
   "responses": [
    {
     "status": 200,
     "text": "{ id:integer, frozen:boolean }"
    }
   ],
   "errors": [],
   "guard": "the budget must be inside the caller's scope. Repeat: a second freeze answers 200 and stores the newer reason, time and person; lifting a freeze that is already lifted answers 200"
  },
  {
   "id": "POST /parser/finance/budgets/:budget_id/copy",
   "group": "finance-budgets",
   "method": "POST",
   "path": "/parser/finance/budgets/:budget_id/copy",
   "purpose": "Copy a budget forward into another period.",
   "auth": {
    "text": "Session + manage_finance | manage_all_finance_budgets | manage_finance_budget (licensed)",
    "kind": "session",
    "permissions": [
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "budget_id",
     "type": "integer",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The BudgetCopy object: { period:string } required ('YYYY-MM' or 'YYYY').",
   "response": "200 the new Budget object, a Draft at version 1 with the lines of the source | 400 period is required",
   "responses": [
    {
     "status": 200,
     "text": "the new Budget object, a Draft at version 1 with the lines of the source"
    },
    {
     "status": 400,
     "text": "period is required"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "period is required"
    }
   ],
   "guard": "a scoped maker owns the copy. Repeat: not deduplicated: every call creates another copy for the period"
  },
  {
   "id": "GET /parser/finance/budgets/:budget_id/actuals",
   "group": "finance-budgets",
   "method": "GET",
   "path": "/parser/finance/budgets/:budget_id/actuals",
   "purpose": "Actual spend behind a budget.",
   "auth": {
    "text": "Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "kind": "session",
    "permissions": [
     "approve_finance_budget",
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget",
     "view_finance",
     "view_finance_budgets",
     "view_finance_consolidated"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "budget_id",
     "type": "integer",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { invoices:[], commitments:[], cloud, invoiceCount, invoiceTruncated, invoiceSum, commitmentsTracked } — invoices are newest first and capped; invoiceTruncated is true when invoiceCount is larger than the list returned",
   "responses": [
    {
     "status": 200,
     "text": "{ invoices:[], commitments:[], cloud, invoiceCount, invoiceTruncated, invoiceSum, commitmentsTracked } — invoices are newest first and capped; invoiceTruncated is true when invoiceCount is larger than the list returned"
    }
   ],
   "errors": [],
   "guard": "scoped: a caller who holds only the scoped budget permissions reaches only budgets in their own scope"
  },
  {
   "id": "GET /parser/finance/budgets/:budget_id/versions",
   "group": "finance-budgets",
   "method": "GET",
   "path": "/parser/finance/budgets/:budget_id/versions",
   "purpose": "Version history of a budget.",
   "auth": {
    "text": "Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "kind": "session",
    "permissions": [
     "approve_finance_budget",
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget",
     "view_finance",
     "view_finance_budgets",
     "view_finance_consolidated"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "budget_id",
     "type": "integer",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { versions:[{ id:integer, versionNo:integer, versionLabel:string|null, amount:number, status:string, isCurrent:boolean, locked:boolean, approvedBy:integer|null, approvedByEmail:string|null, approvedAt:string (date-time)|null }] } — newest version first. Every version, not paged",
   "responses": [
    {
     "status": 200,
     "text": "{ versions:[{ id:integer, versionNo:integer, versionLabel:string|null, amount:number, status:string, isCurrent:boolean, locked:boolean, approvedBy:integer|null, approvedByEmail:string|null, approvedAt:string (date-time)|null }] } — newest version first. Every version, not paged"
    }
   ],
   "errors": [],
   "guard": "scoped: a caller who holds only the scoped budget permissions reaches only budgets in their own scope"
  },
  {
   "id": "GET /parser/finance/budgets/:budget_id/forecast-accuracy",
   "group": "finance-budgets",
   "method": "GET",
   "path": "/parser/finance/budgets/:budget_id/forecast-accuracy",
   "purpose": "Forecast against actual, per version.",
   "auth": {
    "text": "Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "kind": "session",
    "permissions": [
     "approve_finance_budget",
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget",
     "view_finance",
     "view_finance_budgets",
     "view_finance_consolidated"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "budget_id",
     "type": "integer",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { history } — [{ period, forecast, actual, … }], oldest period first; not paged",
   "responses": [
    {
     "status": 200,
     "text": "{ history } — [{ period, forecast, actual, … }], oldest period first; not paged"
    }
   ],
   "errors": [],
   "guard": "scoped: a caller who holds only the scoped budget permissions reaches only budgets in their own scope"
  },
  {
   "id": "GET /parser/finance/budgets/:budget_id/approval-records",
   "group": "finance-budgets",
   "method": "GET",
   "path": "/parser/finance/budgets/:budget_id/approval-records",
   "purpose": "Approval records of a budget.",
   "auth": {
    "text": "Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget | close_finance (licensed)",
    "kind": "session",
    "permissions": [
     "approve_finance_budget",
     "close_finance",
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget",
     "view_finance",
     "view_finance_budgets",
     "view_finance_consolidated"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "budget_id",
     "type": "integer",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { records } | 404 'Budget not found' — newest decision first: { id, decision, approver, owner, authorityBasis, managerOfOwnerAtTime, versionNo, decidedAt }. Every record, not paged",
   "responses": [
    {
     "status": 200,
     "text": "{ records }"
    },
    {
     "status": 404,
     "text": "'Budget not found' — newest decision first: { id, decision, approver, owner, authorityBasis, managerOfOwnerAtTime, versionNo, decidedAt }. Every record, not paged"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'Budget not found' — newest decision first: { id, decision, approver, owner, authorityBasis, managerOfOwnerAtTime, versionNo, decidedAt }. Every record, not paged"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /parser/finance/budgets/:budget_id/lines",
   "group": "finance-budgets",
   "method": "GET",
   "path": "/parser/finance/budgets/:budget_id/lines",
   "purpose": "Line items of a budget.",
   "auth": {
    "text": "Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "kind": "session",
    "permissions": [
     "approve_finance_budget",
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget",
     "view_finance",
     "view_finance_budgets",
     "view_finance_consolidated"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "budget_id",
     "type": "integer",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { lines:[ BudgetLine ], method:string }; every line, not paged",
   "responses": [
    {
     "status": 200,
     "text": "{ lines:[ BudgetLine ], method:string }; every line, not paged"
    }
   ],
   "errors": [],
   "guard": "scoped: a caller who holds only the scoped budget permissions reaches only budgets in their own scope"
  },
  {
   "id": "POST /parser/finance/budgets/:budget_id/lines",
   "group": "finance-budgets",
   "method": "POST",
   "path": "/parser/finance/budgets/:budget_id/lines",
   "purpose": "Add a line to a budget.",
   "auth": {
    "text": "Session + manage_finance | manage_all_finance_budgets | manage_finance_budget (licensed)",
    "kind": "session",
    "permissions": [
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "budget_id",
     "type": "integer",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The BudgetLineWrite object: { label?:string, amount?:number } or driver-based { driverKey?:string, driverQty?:number, unitCost?:number }; glAccountId?:integer, category?:string, costCenter?:string, justification?:string, sortOrder?:integer.",
   "response": "200 the BudgetLine | 404 budget not found | 403 not your budget | 409 the budget is locked",
   "responses": [
    {
     "status": 200,
     "text": "the BudgetLine"
    },
    {
     "status": 404,
     "text": "budget not found"
    },
    {
     "status": 403,
     "text": "not your budget"
    },
    {
     "status": 409,
     "text": "the budget is locked"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "budget not found"
    },
    {
     "status": 403,
     "text": "not your budget"
    },
    {
     "status": 409,
     "text": "the budget is locked"
    }
   ],
   "guard": "editing the lines of an approved budget sends it back for approval. Repeat: not deduplicated: every call adds another line"
  },
  {
   "id": "PATCH /parser/finance/budgets/:budget_id/lines/:line_id",
   "group": "finance-budgets",
   "method": "PATCH",
   "path": "/parser/finance/budgets/:budget_id/lines/:line_id",
   "purpose": "Change a budget line.",
   "auth": {
    "text": "Session + manage_finance | manage_all_finance_budgets | manage_finance_budget (licensed)",
    "kind": "session",
    "permissions": [
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "budget_id",
     "type": "integer",
     "basis": "parser"
    },
    {
     "name": "line_id",
     "type": "integer",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The BudgetLineWrite object; only keys present are applied and the line amount is recalculated.",
   "response": "200 the BudgetLine | 404 'Line not found' — also when the line belongs to another budget | 403 not your budget | 409 the budget is locked",
   "responses": [
    {
     "status": 200,
     "text": "the BudgetLine"
    },
    {
     "status": 404,
     "text": "'Line not found' — also when the line belongs to another budget"
    },
    {
     "status": 403,
     "text": "not your budget"
    },
    {
     "status": 409,
     "text": "the budget is locked"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'Line not found' — also when the line belongs to another budget"
    },
    {
     "status": 403,
     "text": "not your budget"
    },
    {
     "status": 409,
     "text": "the budget is locked"
    }
   ],
   "guard": null
  },
  {
   "id": "DELETE /parser/finance/budgets/:budget_id/lines/:line_id",
   "group": "finance-budgets",
   "method": "DELETE",
   "path": "/parser/finance/budgets/:budget_id/lines/:line_id",
   "purpose": "Delete a budget line.",
   "auth": {
    "text": "Session + manage_finance | manage_all_finance_budgets | manage_finance_budget (licensed)",
    "kind": "session",
    "permissions": [
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "budget_id",
     "type": "integer",
     "basis": "parser"
    },
    {
     "name": "line_id",
     "type": "integer",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body. budget_id and line_id are integers in the path.",
   "response": "200 { success:true } | 404 'Line not found' | 403 not your budget | 409 the budget is locked",
   "responses": [
    {
     "status": 200,
     "text": "{ success:true }"
    },
    {
     "status": 404,
     "text": "'Line not found'"
    },
    {
     "status": 403,
     "text": "not your budget"
    },
    {
     "status": 409,
     "text": "the budget is locked"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'Line not found'"
    },
    {
     "status": 403,
     "text": "not your budget"
    },
    {
     "status": 409,
     "text": "the budget is locked"
    }
   ],
   "guard": "Repeat: a second call answers 404 'Line not found'"
  },
  {
   "id": "GET /parser/finance/budgets/:budget_id/comments",
   "group": "finance-budgets",
   "method": "GET",
   "path": "/parser/finance/budgets/:budget_id/comments",
   "purpose": "Comments on a budget.",
   "auth": {
    "text": "Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "kind": "session",
    "permissions": [
     "approve_finance_budget",
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget",
     "view_finance",
     "view_finance_budgets",
     "view_finance_consolidated"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "budget_id",
     "type": "integer",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { comments:[ BudgetComment ] }; every comment, not paged",
   "responses": [
    {
     "status": 200,
     "text": "{ comments:[ BudgetComment ] }; every comment, not paged"
    }
   ],
   "errors": [],
   "guard": "scoped: a caller who holds only the scoped budget permissions reaches only budgets in their own scope"
  },
  {
   "id": "POST /parser/finance/budgets/:budget_id/comments",
   "group": "finance-budgets",
   "method": "POST",
   "path": "/parser/finance/budgets/:budget_id/comments",
   "purpose": "Comment on a budget.",
   "auth": {
    "text": "Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "kind": "session",
    "permissions": [
     "approve_finance_budget",
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget",
     "view_finance",
     "view_finance_budgets",
     "view_finance_consolidated"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "budget_id",
     "type": "integer",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The BudgetCommentCreate object: { comment:string } required.",
   "response": "200 the BudgetComment | 400 comment is required",
   "responses": [
    {
     "status": 200,
     "text": "the BudgetComment"
    },
    {
     "status": 400,
     "text": "comment is required"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "comment is required"
    }
   ],
   "guard": "scoped: a caller who holds only the scoped budget permissions reaches only budgets in their own scope. Repeat: not deduplicated: every call adds another comment"
  },
  {
   "id": "GET /parser/finance/transfers",
   "group": "finance-budgets",
   "method": "GET",
   "path": "/parser/finance/transfers",
   "purpose": "Budget transfers the caller may see.",
   "auth": {
    "text": "Session + transfer_finance_budget | manage_all_finance_budgets (licensed)",
    "kind": "session",
    "permissions": [
     "manage_all_finance_budgets",
     "transfer_finance_budget"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { transfers:[ BudgetTransfer ] } — newest first, at most 200",
   "responses": [
    {
     "status": 200,
     "text": "{ transfers:[ BudgetTransfer ] } — newest first, at most 200"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /parser/finance/transfers",
   "group": "finance-budgets",
   "method": "POST",
   "path": "/parser/finance/transfers",
   "purpose": "Request a transfer between two budgets.",
   "auth": {
    "text": "Session + transfer_finance_budget | manage_all_finance_budgets (licensed)",
    "kind": "session",
    "permissions": [
     "manage_all_finance_budgets",
     "transfer_finance_budget"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The BudgetTransferCreate object: { fromBudgetId:integer, toBudgetId:integer, amount:number } required; currency?:string, reason?:string.",
   "response": "200 the BudgetTransfer | the transfer error status with its message — 400 when the two budgets are the same or the amount is not positive, 404 when a budget is not found",
   "responses": [
    {
     "status": 200,
     "text": "the BudgetTransfer | the transfer error status with its message — 400 when the two budgets are the same or the amount is not positive, 404 when a budget is not found"
    }
   ],
   "errors": [],
   "guard": "Repeat: not deduplicated: every call records another pending transfer"
  },
  {
   "id": "POST /parser/finance/transfers/:transfer_id/approve",
   "group": "finance-budgets",
   "method": "POST",
   "path": "/parser/finance/transfers/:transfer_id/approve",
   "purpose": "Approve a budget transfer.",
   "auth": {
    "text": "Session + transfer_finance_budget | manage_all_finance_budgets (licensed)",
    "kind": "session",
    "permissions": [
     "manage_all_finance_budgets",
     "transfer_finance_budget"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "transfer_id",
     "type": "integer",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 the BudgetTransfer (applied once its status is Approved) | the transfer error status with its message — 404 not found, 409 when the transfer is no longer Pending, 409 'You have already approved this transfer.' (what a repeat by the same approver gets), 403 for the person who requested it, 409 when the source budget has insufficient funds or either budget is locked",
   "responses": [
    {
     "status": 200,
     "text": "the BudgetTransfer (applied once its status is Approved) | the transfer error status with its message — 404 not found, 409 when the transfer is no longer Pending, 409 'You have already approved this transfer.' (what a repeat by the same approver gets), 403 for the person who requested it, 409 when the source budget has insufficient funds or either budget is locked"
    }
   ],
   "errors": [],
   "guard": "Repeat: a second approve by the same person answers 409 'You have already approved this transfer.', and any approve after the transfer has left Pending answers 409"
  },
  {
   "id": "POST /parser/finance/transfers/:transfer_id/reject",
   "group": "finance-budgets",
   "method": "POST",
   "path": "/parser/finance/transfers/:transfer_id/reject",
   "purpose": "Reject a budget transfer.",
   "auth": {
    "text": "Session + transfer_finance_budget | manage_all_finance_budgets (licensed)",
    "kind": "session",
    "permissions": [
     "manage_all_finance_budgets",
     "transfer_finance_budget"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "transfer_id",
     "type": "integer",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The TransferReject object: { reason?:string }.",
   "response": "200 the BudgetTransfer | the transfer error status with its message — 404 not found, 409 when the transfer is no longer Pending, 403 for the person who requested it",
   "responses": [
    {
     "status": 200,
     "text": "the BudgetTransfer | the transfer error status with its message — 404 not found, 409 when the transfer is no longer Pending, 403 for the person who requested it"
    }
   ],
   "errors": [],
   "guard": "Repeat: a second call answers 409, because the transfer is no longer Pending"
  },
  {
   "id": "POST /parser/finance/approvals/bulk",
   "group": "finance-budgets",
   "method": "POST",
   "path": "/parser/finance/approvals/bulk",
   "purpose": "Approve or reject many budgets and transfers in one call.",
   "auth": {
    "text": "Session + manage_finance | manage_all_finance_budgets | approve_finance_budget | transfer_finance_budget (licensed)",
    "kind": "session",
    "permissions": [
     "approve_finance_budget",
     "manage_all_finance_budgets",
     "manage_finance",
     "transfer_finance_budget"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The BudgetApprovalsBulk object: { action: approve | reject, budgetIds?:integer[], transferIds?:integer[], reason?:string }. At least 1 and at most 500 items in total. A reject needs reason for each budget: without it each budget is reported in results as failed; a transfer can be rejected without one.",
   "response": "200 a BulkOutcome { action:string, ok:integer, failed:integer, results:[ BulkOutcomeItem ] } with each item { kind: budget | transfer, id, ok:boolean, error?:string } | 400 unknown action, ids that are not lists, nothing to act on, or more than 500 items",
   "responses": [
    {
     "status": 200,
     "text": "a BulkOutcome { action:string, ok:integer, failed:integer, results:[ BulkOutcomeItem ] } with each item { kind: budget | transfer, id, ok:boolean, error?:string }"
    },
    {
     "status": 400,
     "text": "unknown action, ids that are not lists, nothing to act on, or more than 500 items"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "unknown action, ids that are not lists, nothing to act on, or more than 500 items"
    }
   ],
   "guard": "each budget needs the budget-approval permissions and each transfer the transfer permissions, checked per item; an item the caller may not decide is reported in results. Repeat: each item gets what a repeat of its single route gets (409 once decided), reported in results as failed"
  },
  {
   "id": "GET /parser/finance/spend-requests",
   "group": "finance-budgets",
   "method": "GET",
   "path": "/parser/finance/spend-requests",
   "purpose": "Spend requests the caller may see.",
   "auth": {
    "text": "Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "kind": "session",
    "permissions": [
     "approve_finance_budget",
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget",
     "view_finance",
     "view_finance_budgets",
     "view_finance_consolidated"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query: includeClosed?:boolean (default false).",
   "response": "200 { requests:[ SpendRequest ] } — newest first, at most 200; no paging parameters",
   "responses": [
    {
     "status": 200,
     "text": "{ requests:[ SpendRequest ] } — newest first, at most 200; no paging parameters"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /parser/finance/spend-requests",
   "group": "finance-budgets",
   "method": "POST",
   "path": "/parser/finance/spend-requests",
   "purpose": "Request spend against a budget.",
   "auth": {
    "text": "Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "kind": "session",
    "permissions": [
     "approve_finance_budget",
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget",
     "view_finance",
     "view_finance_budgets",
     "view_finance_consolidated"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The SpendRequestCreate object: { budgetId:integer, amount:number } required; currency?:string, description?:string, vendor?:string.",
   "response": "200 the SpendRequest | the request error status with its message — 400 when the amount is not positive, 404 or 403 for a budget outside the caller's scope",
   "responses": [
    {
     "status": 200,
     "text": "the SpendRequest | the request error status with its message — 400 when the amount is not positive, 404 or 403 for a budget outside the caller's scope"
    }
   ],
   "errors": [],
   "guard": "Repeat: not deduplicated: every call records another pending request"
  },
  {
   "id": "POST /parser/finance/spend-requests/:request_id/:action",
   "group": "finance-budgets",
   "method": "POST",
   "path": "/parser/finance/spend-requests/:request_id/:action",
   "purpose": "Approve, reject, fulfil or cancel a spend request.",
   "auth": {
    "text": "Session + per-action permission, see guard (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "request_id",
     "type": "integer",
     "basis": "parser"
    },
    {
     "name": "action",
     "type": "string",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The SpendRequestDecision object. action is in the path: approve | reject | fulfill | cancel. reject and cancel read { reason?:string }; approve and fulfill read no body.",
   "response": "200 the SpendRequest | 404 'Unknown action' | the request error status with its message — 409 when the request has already been decided (what a repeated approve, reject or cancel gets); fulfill answers 409 unless the request is approved; 403 for the requester approving their own request",
   "responses": [
    {
     "status": 200,
     "text": "the SpendRequest"
    },
    {
     "status": 404,
     "text": "'Unknown action' | the request error status with its message — 409 when the request has already been decided (what a repeated approve, reject or cancel gets); fulfill answers 409 unless the request is approved; 403 for the requester approving their own request"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'Unknown action' | the request error status with its message — 409 when the request has already been decided (what a repeated approve, reject or cancel gets); fulfill answers 409 unless the request is approved; 403 for the requester approving their own request"
    }
   ],
   "guard": "approve and reject need manage_finance | manage_all_finance_budgets | approve_finance_budget; fulfill needs manage_finance | manage_all_finance_budgets; cancel needs any budget-read permission, and the service then decides who may cancel which request. Repeat: a second approve, reject or cancel answers 409, because the request has already been decided"
  },
  {
   "id": "GET /parser/finance/systems",
   "group": "finance-budgets",
   "method": "GET",
   "path": "/parser/finance/systems",
   "purpose": "Systems and subscriptions linked to budgets.",
   "auth": {
    "text": "Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "kind": "session",
    "permissions": [
     "approve_finance_budget",
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget",
     "view_finance",
     "view_finance_budgets",
     "view_finance_consolidated"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { systems:[ OwnedSystem ] } — most recently linked first; not paged",
   "responses": [
    {
     "status": 200,
     "text": "{ systems:[ OwnedSystem ] } — most recently linked first; not paged"
    }
   ],
   "errors": [],
   "guard": "scoped: a caller who holds only the scoped budget permissions reaches only budgets in their own scope"
  },
  {
   "id": "GET /parser/finance/systems/providers",
   "group": "finance-budgets",
   "method": "GET",
   "path": "/parser/finance/systems/providers",
   "purpose": "Providers a system can be linked from.",
   "auth": {
    "text": "Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "kind": "session",
    "permissions": [
     "approve_finance_budget",
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget",
     "view_finance",
     "view_finance_budgets",
     "view_finance_consolidated"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { providers:[{ externalProviderId:integer, providerType:string, name:string, metering: live | derived | discovery, hasBilling:boolean }] }; not paged",
   "responses": [
    {
     "status": 200,
     "text": "{ providers:[{ externalProviderId:integer, providerType:string, name:string, metering: live | derived | discovery, hasBilling:boolean }] }; not paged"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /parser/finance/systems",
   "group": "finance-budgets",
   "method": "POST",
   "path": "/parser/finance/systems",
   "purpose": "Link a system to a budget.",
   "auth": {
    "text": "Session + manage_finance | manage_all_finance_budgets | manage_finance_budget (licensed)",
    "kind": "session",
    "permissions": [
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The OwnedSystemClaim object: { budgetId?:integer, providerType?:string, externalProviderId?:integer, systemRef?:string, label?:string, kind?:string, ownerEmail?:string }.",
   "response": "200 the OwnedSystem | 403 | 404 with the reason",
   "responses": [
    {
     "status": 200,
     "text": "the OwnedSystem"
    },
    {
     "status": 403,
     "text": ""
    },
    {
     "status": 404,
     "text": "with the reason"
    }
   ],
   "errors": [
    {
     "status": 403,
     "text": ""
    },
    {
     "status": 404,
     "text": "with the reason"
    }
   ],
   "guard": "for a scoped manager the owner is forced to the caller. Repeat: not deduplicated: every call links the system again as a new record"
  },
  {
   "id": "DELETE /parser/finance/systems/:system_id",
   "group": "finance-budgets",
   "method": "DELETE",
   "path": "/parser/finance/systems/:system_id",
   "purpose": "Unlink a system from its budget.",
   "auth": {
    "text": "Session + manage_finance | manage_all_finance_budgets | manage_finance_budget (licensed)",
    "kind": "session",
    "permissions": [
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "system_id",
     "type": "integer",
     "basis": "parser"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body. system_id is an integer in the path.",
   "response": "200 { success:true } | 403 | 404 'System not found'",
   "responses": [
    {
     "status": 200,
     "text": "{ success:true }"
    },
    {
     "status": 403,
     "text": ""
    },
    {
     "status": 404,
     "text": "'System not found'"
    }
   ],
   "errors": [
    {
     "status": 403,
     "text": ""
    },
    {
     "status": 404,
     "text": "'System not found'"
    }
   ],
   "guard": "Repeat: a second call answers 404 'System not found'"
  },
  {
   "id": "GET /parser/finance/cloud/anomalies",
   "group": "finance-budgets",
   "method": "GET",
   "path": "/parser/finance/cloud/anomalies",
   "purpose": "Month-over-month cloud cost spikes on linked systems.",
   "auth": {
    "text": "Session + view_finance | manage_finance | view_finance_consolidated | manage_all_finance_budgets | approve_finance_budget | view_finance_budgets | manage_finance_budget (licensed)",
    "kind": "session",
    "permissions": [
     "approve_finance_budget",
     "manage_all_finance_budgets",
     "manage_finance",
     "manage_finance_budget",
     "view_finance",
     "view_finance_budgets",
     "view_finance_consolidated"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { anomalies:[{ system:string, provider:string, budgetId:integer|null, currentMonth:number, previousMonth:number, changePct:number, delta:number, severity: high | medium }] }; not paged",
   "responses": [
    {
     "status": 200,
     "text": "{ anomalies:[{ system:string, provider:string, budgetId:integer|null, currentMonth:number, previousMonth:number, changePct:number, delta:number, severity: high | medium }] }; not paged"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /ai/score-candidate",
   "group": "ai",
   "method": "POST",
   "path": "/ai/score-candidate",
   "purpose": "Score a candidate against a job description.",
   "auth": {
    "text": "Session + view_recruitment | manage_recruitment",
    "kind": "session",
    "permissions": [
     "manage_recruitment",
     "view_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ resumeText } required; jobTitle, jobDescription, requirements[], competencies optional. resumeText truncated to 3000 chars, jobDescription to 1000.",
   "response": "200 { success:true, matchScore:int 0-100 (clamped), aiSummary } | 400 { success:false, error:'No resume text provided' } | 500 { success:false, error:'AI processing failed' }",
   "responses": [
    {
     "status": 200,
     "text": "{ success:true, matchScore:int 0-100 (clamped), aiSummary }"
    },
    {
     "status": 400,
     "text": "{ success:false, error:'No resume text provided' }"
    },
    {
     "status": 500,
     "text": "{ success:false, error:'AI processing failed' }"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ success:false, error:'No resume text provided' }"
    },
    {
     "status": 500,
     "text": "{ success:false, error:'AI processing failed' }"
    }
   ],
   "guard": "requirePermission(['view_recruitment','manage_recruitment']); M2M x-internal-service-token required or 401"
  },
  {
   "id": "POST /ai/score-candidate-stream",
   "group": "ai",
   "method": "POST",
   "path": "/ai/score-candidate-stream",
   "purpose": "Same, streamed incrementally into the UI.",
   "auth": {
    "text": "Session + view_recruitment | manage_recruitment",
    "kind": "session",
    "permissions": [
     "manage_recruitment",
     "view_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ resumeText } required; jobTitle, jobDescription, requirements[] optional.",
   "response": "200 text/plain chunked stream (first line 'SCORE: <n>', then SUMMARY/STRENGTHS/GAPS/VERDICT) | 400 { success:false, error:'No resume text provided' } | 500 { success:false, error:'AI processing failed' }",
   "responses": [
    {
     "status": 200,
     "text": "text/plain chunked stream (first line 'SCORE: <n>', then SUMMARY/STRENGTHS/GAPS/VERDICT)"
    },
    {
     "status": 400,
     "text": "{ success:false, error:'No resume text provided' }"
    },
    {
     "status": 500,
     "text": "{ success:false, error:'AI processing failed' }"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ success:false, error:'No resume text provided' }"
    },
    {
     "status": 500,
     "text": "{ success:false, error:'AI processing failed' }"
    }
   ],
   "guard": "requirePermission(['view_recruitment','manage_recruitment']); aborts Ollama generation on client disconnect; M2M token or 401"
  },
  {
   "id": "POST /ai/generate-competencies",
   "group": "ai",
   "method": "POST",
   "path": "/ai/generate-competencies",
   "purpose": "Generate scorecard competencies for a role.",
   "auth": {
    "text": "Session + view_recruitment | manage_recruitment",
    "kind": "session",
    "permissions": [
     "manage_recruitment",
     "view_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ jobTitle } required; jobDescription optional (truncated 800 chars).",
   "response": "200 { competencies: string[] } (deduped, sliced to 12) | 400 { error:'jobTitle is required' } | 500 { error:'AI processing failed' }",
   "responses": [
    {
     "status": 200,
     "text": "{ competencies: string[] } (deduped, sliced to 12)"
    },
    {
     "status": 400,
     "text": "{ error:'jobTitle is required' }"
    },
    {
     "status": 500,
     "text": "{ error:'AI processing failed' }"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ error:'jobTitle is required' }"
    },
    {
     "status": 500,
     "text": "{ error:'AI processing failed' }"
    }
   ],
   "guard": "requirePermission(['view_recruitment','manage_recruitment']); M2M token or 401"
  },
  {
   "id": "POST /ai/generate-interview-questions",
   "group": "ai",
   "method": "POST",
   "path": "/ai/generate-interview-questions",
   "purpose": "Generate structured interview questions.",
   "auth": {
    "text": "Session + view_recruitment | manage_recruitment",
    "kind": "session",
    "permissions": [
     "manage_recruitment",
     "view_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ jobTitle } required; department, jobDescription, competencies[], requirements[], seniority, minExperience, employmentType optional.",
   "response": "200 { questions:[{ questionText, category }] } | 400 { error:'jobTitle is required' } | 500 { error:'Interview question generation failed' }",
   "responses": [
    {
     "status": 200,
     "text": "{ questions:[{ questionText, category }] }"
    },
    {
     "status": 400,
     "text": "{ error:'jobTitle is required' }"
    },
    {
     "status": 500,
     "text": "{ error:'Interview question generation failed' }"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ error:'jobTitle is required' }"
    },
    {
     "status": 500,
     "text": "{ error:'Interview question generation failed' }"
    }
   ],
   "guard": "requirePermission(['view_recruitment','manage_recruitment']); M2M token or 401"
  },
  {
   "id": "POST /ai/generate-offer-template",
   "group": "ai",
   "method": "POST",
   "path": "/ai/generate-offer-template",
   "purpose": "Draft an offer template.",
   "auth": {
    "text": "Session + view_recruitment | manage_recruitment",
    "kind": "session",
    "permissions": [
     "manage_recruitment",
     "view_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No required field. jobTitle, department, location, salary, salaryRange, currency, managerName, jobDescription, competencies[], employmentType, seniority, workMode all optional.",
   "response": "200 { body } (square-bracket fill-ins stripped/normalized; only {{Curly}} merge fields kept) | 500 { error:'Offer template generation failed' }",
   "responses": [
    {
     "status": 200,
     "text": "{ body } (square-bracket fill-ins stripped/normalized; only {{Curly}} merge fields kept)"
    },
    {
     "status": 500,
     "text": "{ error:'Offer template generation failed' }"
    }
   ],
   "errors": [
    {
     "status": 500,
     "text": "{ error:'Offer template generation failed' }"
    }
   ],
   "guard": "requirePermission(['view_recruitment','manage_recruitment']); M2M token or 401"
  },
  {
   "id": "POST /ai/calibrate-scorecards",
   "group": "ai",
   "method": "POST",
   "path": "/ai/calibrate-scorecards",
   "purpose": "Calibrate scoring across interviewers.",
   "auth": {
    "text": "Session + view_recruitment | manage_recruitment",
    "kind": "session",
    "permissions": [
     "manage_recruitment",
     "view_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ scorecards: [] } required and non-empty; candidateName, jobTitle optional.",
   "response": "200 { executiveSummary, consensusLevel, conflicts:[{topic,description}], biasFlags:string[], recommendation } | 400 { error:'No scorecards provided' } | 500 { error:'Calibration analysis failed' }",
   "responses": [
    {
     "status": 200,
     "text": "{ executiveSummary, consensusLevel, conflicts:[{topic,description}], biasFlags:string[], recommendation }"
    },
    {
     "status": 400,
     "text": "{ error:'No scorecards provided' }"
    },
    {
     "status": 500,
     "text": "{ error:'Calibration analysis failed' }"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ error:'No scorecards provided' }"
    },
    {
     "status": 500,
     "text": "{ error:'Calibration analysis failed' }"
    }
   ],
   "guard": "requirePermission(['view_recruitment','manage_recruitment']); M2M token or 401"
  },
  {
   "id": "POST /ai/pipeline-insights",
   "group": "ai",
   "method": "POST",
   "path": "/ai/pipeline-insights",
   "purpose": "Summarise pipeline health.",
   "auth": {
    "text": "Session + view_recruitment | manage_recruitment",
    "kind": "session",
    "permissions": [
     "manage_recruitment",
     "view_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ summary } (numeric pipeline snapshot object). Missing summary short-circuits to empty.",
   "response": "200 { insights:[{ title, desc, severity }] } — FAILS OPEN: empty summary or any error returns 200 { insights:[] }, no 4xx/5xx",
   "responses": [
    {
     "status": 200,
     "text": "{ insights:[{ title, desc, severity }] } — FAILS OPEN: empty summary or any error returns 200 { insights:[] }, no 4xx/5xx"
    }
   ],
   "errors": [],
   "guard": "requirePermission(['view_recruitment','manage_recruitment']); M2M token or 401"
  },
  {
   "id": "POST /ai/chat",
   "group": "ai",
   "method": "POST",
   "path": "/ai/chat",
   "purpose": "General recruitment assistant chat.",
   "auth": {
    "text": "Session + view_recruitment | manage_recruitment",
    "kind": "session",
    "permissions": [
     "manage_recruitment",
     "view_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ query } required (branches to template-mode on keywords like 'template'/'offer letter'); context optional (PII-redacted before prompt).",
   "response": "200 { response } (or safe refusal string if output looks like an injection-echo) | 500 { error:'AI engine unreachable' }",
   "responses": [
    {
     "status": 200,
     "text": "{ response } (or safe refusal string if output looks like an injection-echo)"
    },
    {
     "status": 500,
     "text": "{ error:'AI engine unreachable' }"
    }
   ],
   "errors": [
    {
     "status": 500,
     "text": "{ error:'AI engine unreachable' }"
    }
   ],
   "guard": "requirePermission(['view_recruitment','manage_recruitment']); context PII-redacted (SSN/card/IBAN/email/phone); injection-echo output firewall; M2M token or 401"
  },
  {
   "id": "POST /ai/refine-text",
   "group": "ai",
   "method": "POST",
   "path": "/ai/refine-text",
   "purpose": "Rewrite or refine drafted text.",
   "auth": {
    "text": "Session + view_recruitment | manage_recruitment",
    "kind": "session",
    "permissions": [
     "manage_recruitment",
     "view_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ text } required.",
   "response": "200 { refinedText } | 400 { error:'No text provided' } | 500 { error:'Refinement failed' }",
   "responses": [
    {
     "status": 200,
     "text": "{ refinedText }"
    },
    {
     "status": 400,
     "text": "{ error:'No text provided' }"
    },
    {
     "status": 500,
     "text": "{ error:'Refinement failed' }"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ error:'No text provided' }"
    },
    {
     "status": 500,
     "text": "{ error:'Refinement failed' }"
    }
   ],
   "guard": "requirePermission(['view_recruitment','manage_recruitment']); M2M token or 401"
  },
  {
   "id": "POST /ai/analyze-report",
   "group": "ai",
   "method": "POST",
   "path": "/ai/analyze-report",
   "purpose": "Narrative analysis of a compliance/reporting dataset.",
   "auth": {
    "text": "Session + view_reporting",
    "kind": "session",
    "permissions": [
     "view_reporting"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ reportData } required (object: metadata, users[], groups[], workflows[], tasks[], logs[], frameworkDetails). Minimized server-side before prompt.",
   "response": "200 text/plain chunked stream (<think>...</think> then JSON: executiveSummary, riskLevel, metrics, anomalies[], recommendedActions[]) | 400 { success:false, error:'No report data provided' }. On AI error FAILS OPEN: streams a fallback JSON with 200",
   "responses": [
    {
     "status": 200,
     "text": "text/plain chunked stream (<think>...</think> then JSON: executiveSummary, riskLevel, metrics, anomalies[], recommendedActions[])"
    },
    {
     "status": 400,
     "text": "{ success:false, error:'No report data provided' }. On AI error FAILS OPEN: streams a fallback JSON with 200"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ success:false, error:'No report data provided' }. On AI error FAILS OPEN: streams a fallback JSON with 200"
    }
   ],
   "guard": "requirePermission('view_reporting'); aborts Ollama generation on client disconnect; M2M token or 401"
  },
  {
   "id": "POST /ai/draft-justification",
   "group": "ai",
   "method": "POST",
   "path": "/ai/draft-justification",
   "purpose": "Draft a compliance justification.",
   "auth": {
    "text": "Session + manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ controlId, controlTitle, rawData, notes } — all optional (no server-side required-field validation).",
   "response": "200 { justification } | 500 { error:'Failed to draft justification note' }",
   "responses": [
    {
     "status": 200,
     "text": "{ justification }"
    },
    {
     "status": 500,
     "text": "{ error:'Failed to draft justification note' }"
    }
   ],
   "errors": [
    {
     "status": 500,
     "text": "{ error:'Failed to draft justification note' }"
    }
   ],
   "guard": "requirePermission('manage_compliance'); M2M token or 401"
  },
  {
   "id": "POST /ai/draft-workflow",
   "group": "ai",
   "method": "POST",
   "path": "/ai/draft-workflow",
   "purpose": "Draft workflow steps from a plain-English description (the Workflow Builder's \"Generate from description\"). A suggestion only: it pre-fills the canvas as an unsaved draft that still goes through save and approval.",
   "auth": {
    "text": "Session + manage_workflows",
    "kind": "session",
    "permissions": [
     "manage_workflows"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ description } required (non-empty string); workflowType, palette[] optional",
   "response": "200 a list of steps drawn only from the offered palette | 400 missing description / 'No licensed step types to draft from.'",
   "responses": [
    {
     "status": 200,
     "text": "a list of steps drawn only from the offered palette"
    },
    {
     "status": 400,
     "text": "missing description / 'No licensed step types to draft from.'"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "missing description / 'No licensed step types to draft from.'"
    }
   ],
   "guard": "step types are constrained to the palette, and types whose module is switched off on the licence are removed server-side; the builder does not offer account, hardware or app provisioning steps to the model"
  },
  {
   "id": "POST /ai/extract-resume-fields",
   "group": "ai",
   "method": "POST",
   "path": "/ai/extract-resume-fields",
   "purpose": "Extract structured, filterable fields from resume text for recruiter-editable tags. Not a decision input.",
   "auth": {
    "text": "Session + view_recruitment | manage_recruitment",
    "kind": "session",
    "permissions": [
     "manage_recruitment",
     "view_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ resumeText } required",
   "response": "200 { skills (up to 40), titles (up to 20), employers (up to 20), education (up to 15), totalYears (number or null), seniority (string or null) } | 400 resumeText is required | 500 AI processing failed",
   "responses": [
    {
     "status": 200,
     "text": "{ skills (up to 40), titles (up to 20), employers (up to 20), education (up to 15), totalYears (number or null), seniority (string or null) }"
    },
    {
     "status": 400,
     "text": "resumeText is required"
    },
    {
     "status": 500,
     "text": "AI processing failed"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "resumeText is required"
    },
    {
     "status": 500,
     "text": "AI processing failed"
    }
   ],
   "guard": null
  },
  {
   "id": "POST /ai/generate-job-description",
   "group": "ai",
   "method": "POST",
   "path": "/ai/generate-job-description",
   "purpose": "Draft a job description.",
   "auth": {
    "text": "Session + view_recruitment | manage_recruitment",
    "kind": "session",
    "permissions": [
     "manage_recruitment",
     "view_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ jobTitle } required; department?, seniority?, employmentType?, workMode?, location?, competencies?",
   "response": "200 { description } | 400 jobTitle is required | 422 the model produced no usable description | 500 AI processing failed",
   "responses": [
    {
     "status": 200,
     "text": "{ description }"
    },
    {
     "status": 400,
     "text": "jobTitle is required"
    },
    {
     "status": 422,
     "text": "the model produced no usable description"
    },
    {
     "status": 500,
     "text": "AI processing failed"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "jobTitle is required"
    },
    {
     "status": 422,
     "text": "the model produced no usable description"
    },
    {
     "status": 500,
     "text": "AI processing failed"
    }
   ],
   "guard": null
  },
  {
   "id": "POST /ai/draft-candidate-message",
   "group": "ai",
   "method": "POST",
   "path": "/ai/draft-candidate-message",
   "purpose": "Draft an email to a candidate.",
   "auth": {
    "text": "Session + manage_recruitment",
    "kind": "session",
    "permissions": [
     "manage_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ candidateName, jobTitle, stage, purpose, companyName } — all optional; a missing field is replaced by a neutral default in the prompt",
   "response": "200 { subject (at most 200 characters), body } — either may be an empty string when the model returns nothing usable | 500 { error:'AI processing failed' }",
   "responses": [
    {
     "status": 200,
     "text": "{ subject (at most 200 characters), body } — either may be an empty string when the model returns nothing usable"
    },
    {
     "status": 500,
     "text": "{ error:'AI processing failed' }"
    }
   ],
   "errors": [
    {
     "status": 500,
     "text": "{ error:'AI processing failed' }"
    }
   ],
   "guard": "writes nothing, so it is safe to repeat (each call drafts again and may word it differently)"
  },
  {
   "id": "POST /ai/chat-stream",
   "group": "ai",
   "method": "POST",
   "path": "/ai/chat-stream",
   "purpose": "The recruitment assistant chat, streamed.",
   "auth": {
    "text": "Session + view_recruitment | manage_recruitment",
    "kind": "session",
    "permissions": [
     "manage_recruitment",
     "view_recruitment"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ query } required (non-empty string); context?",
   "response": "200 application/x-ndjson, one JSON object per line: { phase:'thinking' }, then { token } pieces, ending with { done:true }; { refusal:true } or { error } can replace the ending | 400 when query is missing or empty | 500 when the model cannot be reached before the stream starts",
   "responses": [
    {
     "status": 200,
     "text": "application/x-ndjson, one JSON object per line: { phase:'thinking' }, then { token } pieces, ending with { done:true }; { refusal:true } or { error } can replace the ending"
    },
    {
     "status": 400,
     "text": "when query is missing or empty"
    },
    {
     "status": 500,
     "text": "when the model cannot be reached before the stream starts"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "when query is missing or empty"
    },
    {
     "status": 500,
     "text": "when the model cannot be reached before the stream starts"
    }
   ],
   "guard": null
  },
  {
   "id": "POST /ai/parse-log-query",
   "group": "ai",
   "method": "POST",
   "path": "/ai/parse-log-query",
   "purpose": "Turn a plain-English question into audit-log filters.",
   "auth": {
    "text": "Session + view_logs",
    "kind": "session",
    "permissions": [
     "view_logs"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ query } required (non-empty string; the first 500 characters are used); actions? (the action names the filter may pick from)",
   "response": "200 { level, userEmail, daysAgo, includeActions, excludeActions, q } — unset filters are omitted | 400 when query is missing or empty | 500 AI processing failed",
   "responses": [
    {
     "status": 200,
     "text": "{ level, userEmail, daysAgo, includeActions, excludeActions, q } — unset filters are omitted"
    },
    {
     "status": 400,
     "text": "when query is missing or empty"
    },
    {
     "status": 500,
     "text": "AI processing failed"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "when query is missing or empty"
    },
    {
     "status": 500,
     "text": "AI processing failed"
    }
   ],
   "guard": null
  },
  {
   "id": "POST /ai/parse-cloud-query",
   "group": "ai",
   "method": "POST",
   "path": "/ai/parse-cloud-query",
   "purpose": "Turn a plain-English question into a cloud-map search.",
   "auth": {
    "text": "Session + view_cloud_infrastructure",
    "kind": "session",
    "permissions": [
     "view_cloud_infrastructure"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ query } required (non-empty string; the first 500 characters are used); types? (the resource types the filter may pick from)",
   "response": "200 { types, minSeverity (critical | high | medium | low), toxic, hasAttackPath, text } — unset filters are omitted | 400 when query is missing or empty | 500 AI processing failed",
   "responses": [
    {
     "status": 200,
     "text": "{ types, minSeverity (critical | high | medium | low), toxic, hasAttackPath, text } — unset filters are omitted"
    },
    {
     "status": 400,
     "text": "when query is missing or empty"
    },
    {
     "status": 500,
     "text": "AI processing failed"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "when query is missing or empty"
    },
    {
     "status": 500,
     "text": "AI processing failed"
    }
   ],
   "guard": null
  },
  {
   "id": "POST /ai/detect-toxic-combos",
   "group": "ai",
   "method": "POST",
   "path": "/ai/detect-toxic-combos",
   "purpose": "Flag risky permission combinations in cloud findings.",
   "auth": {
    "text": "Session + view_cloud_infrastructure",
    "kind": "session",
    "permissions": [
     "view_cloud_infrastructure"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ items:[] (the first 40 are used), existingPaths?:[] (the first 25 are used) }",
   "response": "200 { combos:[{ title, severity (critical | high | medium | low), refs, why, remediation }] } — at most 8, and only combinations whose refs point at items that were sent; an empty items list answers { combos:[] } without calling the model | 500 AI processing failed",
   "responses": [
    {
     "status": 200,
     "text": "{ combos:[{ title, severity (critical | high | medium | low), refs, why, remediation }] } — at most 8, and only combinations whose refs point at items that were sent; an empty items list answers { combos:[] } without calling the model"
    },
    {
     "status": 500,
     "text": "AI processing failed"
    }
   ],
   "errors": [
    {
     "status": 500,
     "text": "AI processing failed"
    }
   ],
   "guard": null
  },
  {
   "id": "POST /ai/prioritize-findings",
   "group": "ai",
   "method": "POST",
   "path": "/ai/prioritize-findings",
   "purpose": "Suggest an order to work cloud findings in.",
   "auth": {
    "text": "Session + view_cloud_infrastructure",
    "kind": "session",
    "permissions": [
     "view_cloud_infrastructure"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ items:[] (the first 40 are used), counts? }",
   "response": "200 { summary, topFocus:[{ ref, why }] } — at most 3 entries in topFocus | 500 AI processing failed",
   "responses": [
    {
     "status": 200,
     "text": "{ summary, topFocus:[{ ref, why }] } — at most 3 entries in topFocus"
    },
    {
     "status": 500,
     "text": "AI processing failed"
    }
   ],
   "errors": [
    {
     "status": 500,
     "text": "AI processing failed"
    }
   ],
   "guard": null
  },
  {
   "id": "POST /ai/explain-finding",
   "group": "ai",
   "method": "POST",
   "path": "/ai/explain-finding",
   "purpose": "Explain a Security Lab finding in plain language.",
   "auth": {
    "text": "Session + view_security_findings | view_cloud_infrastructure",
    "kind": "session",
    "permissions": [
     "view_cloud_infrastructure",
     "view_security_findings"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ title, severity, target, evidence, mitigation, cvssScore } — a title or evidence is required",
   "response": "200 { explanation, businessImpact, steps:[] (up to 8) } | 400 when both title and evidence are missing | 500 AI processing failed",
   "responses": [
    {
     "status": 200,
     "text": "{ explanation, businessImpact, steps:[] (up to 8) }"
    },
    {
     "status": 400,
     "text": "when both title and evidence are missing"
    },
    {
     "status": 500,
     "text": "AI processing failed"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "when both title and evidence are missing"
    },
    {
     "status": 500,
     "text": "AI processing failed"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /ai/model-status",
   "group": "ai",
   "method": "GET",
   "path": "/ai/model-status",
   "purpose": "Whether the selected model is pulled and warm (used by the Settings model switch).",
   "auth": {
    "text": "Session + manage_security",
    "kind": "session",
    "permissions": [
     "manage_security"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query: ?model= required (must be on the allow-list); ?previous= optional (the model being switched away from)",
   "response": "200 an AiModelStatus (typed under Response Objects) { model, pulled, ready, state:'ready'|'loading'|'pulling'|'engine_unreachable', reachable, previousResident (boolean or null), consumers } | 400 'model query param is required' | 400 'model is not on the allowlist'",
   "responses": [
    {
     "status": 200,
     "text": "an AiModelStatus (typed under Response Objects) { model, pulled, ready, state:'ready'|'loading'|'pulling'|'engine_unreachable', reachable, previousResident (boolean or null), consumers }"
    },
    {
     "status": 400,
     "text": "'model query param is required'"
    },
    {
     "status": 400,
     "text": "'model is not on the allowlist'"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "'model query param is required'"
    },
    {
     "status": 400,
     "text": "'model is not on the allowlist'"
    }
   ],
   "guard": "read-only, except that it loads an already-downloaded model into memory so the next request is fast"
  },
  {
   "id": "GET /ai/model-capability",
   "group": "ai",
   "method": "GET",
   "path": "/ai/model-capability",
   "purpose": "The instance's cores and memory, and which allow-listed models it can run.",
   "auth": {
    "text": "Session + manage_security",
    "kind": "session",
    "permissions": [
     "manage_security"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 an AiModelCapability (typed under Response Objects) { box:{ cores, ramGB, gpu, cpuParamCap }, models:[{ id, label, sizeB, ramGB, fits:boolean, reason }], defaultModel } | 500 { error:'capability check failed' }",
   "responses": [
    {
     "status": 200,
     "text": "an AiModelCapability (typed under Response Objects) { box:{ cores, ramGB, gpu, cpuParamCap }, models:[{ id, label, sizeB, ramGB, fits:boolean, reason }], defaultModel }"
    },
    {
     "status": 500,
     "text": "{ error:'capability check failed' }"
    }
   ],
   "errors": [
    {
     "status": 500,
     "text": "{ error:'capability check failed' }"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /ai/health",
   "group": "ai",
   "method": "GET",
   "path": "/ai/health",
   "purpose": "AI engine status and loaded model.",
   "auth": {
    "text": "Session",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 always: { status:'healthy'|'degraded', ollama:'up'|'down'|'unknown', model:string|null, modelPresent:boolean, installedModels:integer (absent when the engine is down), reason?:string (present when degraded), engine:'Ollama', lastModel:string, auditWrite:{ failures:integer, lastAt:string|null, lastAction:string|null, recentFailures:boolean }|null }",
   "responses": [
    {
     "status": 200,
     "text": "always: { status:'healthy'|'degraded', ollama:'up'|'down'|'unknown', model:string|null, modelPresent:boolean, installedModels:integer (absent when the engine is down), reason?:string (present when degraded), engine:'Ollama', lastModel:string, auditWrite:{ failures:integer, lastAt:string|null, lastAction:string|null, recentFailures:boolean }|null }"
    }
   ],
   "errors": [],
   "guard": "read status, not the HTTP code: an unreachable engine still answers 200 with status 'degraded'"
  },
  {
   "id": "GET /user-management/logs",
   "group": "logs-devices",
   "method": "GET",
   "path": "/user-management/logs",
   "purpose": "Query the central audit log (filter by action, level, actor, date).",
   "auth": {
    "text": "Session + view_logs",
    "kind": "session",
    "permissions": [
     "view_logs"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [
    "limit",
    "page"
   ],
   "query": [
    {
     "name": "page",
     "type": "integer",
     "default": 0,
     "minimum": 0,
     "note": "Zero-based."
    },
    {
     "name": "limit",
     "type": "integer",
     "default": 25,
     "minimum": 1,
     "maximum": 500
    },
    {
     "name": "level",
     "type": "string"
    },
    {
     "name": "userEmail",
     "type": "string"
    },
    {
     "name": "includeActions",
     "type": "string"
    },
    {
     "name": "excludeActions",
     "type": "string"
    },
    {
     "name": "daysAgo",
     "type": "string"
    },
    {
     "name": "correlationId",
     "type": "string"
    },
    {
     "name": "q",
     "type": "string"
    },
    {
     "name": "deep",
     "type": "string",
     "enum": [
      "1"
     ],
     "note": "Search the payload too. Read only together with q."
    }
   ],
   "request": "Query: ?page (zero-based, default 0), ?limit (default 25, max 500), ?level (info | warning | error | debug; warn and critical are accepted as synonyms), ?userEmail (substring), ?includeActions and ?excludeActions (an action name or family, repeatable), ?daysAgo (the look-back window in days; default 30), ?correlationId (exact), ?q (substring of details; add ?deep=1 to search the payload too). No sort parameter: newest first",
   "response": "200 an AuditLogPage: { total:integer (rows matching, including ?level), logs:[ AuditLog ] (typed under Response Objects), counts:{ total, info, warning, error } (integers, for the same filters without ?level), windowDays:integer, retentionDays:integer|null, limit:integer, page:integer } — typed under Response Objects as AuditLogPage",
   "responses": [
    {
     "status": 200,
     "text": "an AuditLogPage: { total:integer (rows matching, including ?level), logs:[ AuditLog ] (typed under Response Objects), counts:{ total, info, warning, error } (integers, for the same filters without ?level), windowDays:integer, retentionDays:integer|null, limit:integer, page:integer } — typed under Response Objects as AuditLogPage"
    }
   ],
   "errors": [],
   "guard": "paged by page and limit, not offset: row n starts at page × limit"
  },
  {
   "id": "GET /user-management/logs/verify",
   "group": "logs-devices",
   "method": "GET",
   "path": "/user-management/logs/verify",
   "purpose": "Verify the integrity of the hash-chained audit log — a one-click green/red result an auditor can run. Detects tampering; it does not prevent it.",
   "auth": {
    "text": "Session + view_logs",
    "kind": "session",
    "permissions": [
     "view_logs"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 an AuditChainVerification: { ok:boolean, chained:integer, unchained:integer, firstBrokenId:integer|null, brokenType:'content'|'linkage'|'anchor'|null, anchor:{ present, headId?, at?, ok? } } — typed under Response Objects as AuditChainVerification",
   "responses": [
    {
     "status": 200,
     "text": "an AuditChainVerification: { ok:boolean, chained:integer, unchained:integer, firstBrokenId:integer|null, brokenType:'content'|'linkage'|'anchor'|null, anchor:{ present, headId?, at?, ok? } } — typed under Response Objects as AuditChainVerification"
    }
   ],
   "errors": [],
   "guard": "recomputes the chain IN SQL with the SAME audit_row_hash() the insert trigger uses (verify and compute cannot drift); three checks — content (each row's stored hash matches its recomputed content), linkage (each row's prevHash matches the prior row's hash — catches a deleted/reordered row), and the off-box external anchor (catches an internally-consistent full rewrite); pre-chain NULL-hash history is reported, not treated as a break"
  },
  {
   "id": "GET /user-management/logs/export",
   "group": "logs-devices",
   "method": "GET",
   "path": "/user-management/logs/export",
   "purpose": "Export the audit log as CSV or JSON for an auditor.",
   "auth": {
    "text": "Session + view_logs",
    "kind": "session",
    "permissions": [
     "view_logs"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query: level?, userEmail?, includeActions?, excludeActions?, daysAgo?, format? ('csv'|'json'; default follows Accept header). No JSON body.",
   "response": "200 CSV attachment (id,createdAt,level,action,userEmail,details,payload; RFC-4180 quoted) or JSON { exportedAt, count, truncated, windowDays, logs[] }; capped at a max row count with a 'truncated' flag",
   "responses": [
    {
     "status": 200,
     "text": "CSV attachment (id,createdAt,level,action,userEmail,details,payload; RFC-4180 quoted) or JSON { exportedAt, count, truncated, windowDays, logs[] }; capped at a max row count with a 'truncated' flag"
    }
   ],
   "errors": [],
   "guard": "view_logs; the export itself is recorded as an AUDIT_LOG_EXPORTED (warning) entry with the filters used — best-effort, never blocks the download"
  },
  {
   "id": "DELETE /user-management/logs",
   "group": "logs-devices",
   "method": "DELETE",
   "path": "/user-management/logs",
   "purpose": "Always refused: audit logs are append-only.",
   "auth": {
    "text": "Session + manage_logs",
    "kind": "session",
    "permissions": [
     "manage_logs"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No body (any body ignored). Audit logs are append-only; deletion by id or purgeAll is not a capability.",
   "response": "Always 403 { error } — refused as append-only; logs an AUDIT_LOG_DELETE_BLOCKED warning. The retention job is the only pruner; its period is Security Settings → Audit Log Retention (auditLogRetentionDays, 90 to 3650 days, default 90).",
   "responses": [
    {
     "status": null,
     "text": "Always 403 { error } — refused as append-only; logs an AUDIT_LOG_DELETE_BLOCKED warning. The retention job is the only pruner; its period is Security Settings → Audit Log Retention (auditLogRetentionDays, 90 to 3650 days, default 90)."
    }
   ],
   "errors": [],
   "guard": "requires manage_logs, but the endpoint hard-refuses all ad-hoc deletion (single-actor evidence-destruction block); DB trigger also rejects UPDATE/DELETE except the retention job"
  },
  {
   "id": "GET /user-management/devices",
   "group": "logs-devices",
   "method": "GET",
   "path": "/user-management/devices",
   "purpose": "List hardware inventory.",
   "auth": {
    "text": "Session + view_devices",
    "kind": "session",
    "permissions": [
     "view_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [
    "limit",
    "offset",
    "page",
    "pageSize"
   ],
   "query": [
    {
     "name": "limit",
     "type": "integer",
     "default": 200,
     "minimum": 1,
     "maximum": 1000,
     "note": "Default view only."
    },
    {
     "name": "offset",
     "type": "integer",
     "default": 0,
     "minimum": 0,
     "note": "Default view only."
    },
    {
     "name": "enrolled",
     "type": "string",
     "note": "Default view: 1 or true keeps agent-enrolled devices only. With view=list enrolled devices are the default and 0 or false widens the list."
    },
    {
     "name": "pending",
     "type": "string",
     "enum": [
      "1",
      "true"
     ],
     "note": "Default view: include enrolments awaiting approval."
    },
    {
     "name": "retired",
     "type": "string",
     "enum": [
      "1",
      "true"
     ],
     "note": "Include retired devices."
    },
    {
     "name": "view",
     "type": "string",
     "enum": [
      "list"
     ],
     "note": "list answers the filtered, sorted, paged fleet list. The parameters below are read only with view=list."
    },
    {
     "name": "page",
     "type": "integer",
     "default": 1,
     "minimum": 1,
     "maximum": 1000000
    },
    {
     "name": "pageSize",
     "type": "integer",
     "default": 50,
     "minimum": 1,
     "maximum": 500
    },
    {
     "name": "sort",
     "type": "string",
     "default": "last"
    },
    {
     "name": "dir",
     "type": "string",
     "enum": [
      "asc",
      "desc"
     ]
    },
    {
     "name": "q",
     "type": "string",
     "note": "A longer value is cut to 200 characters."
    },
    {
     "name": "platform",
     "type": "string"
    },
    {
     "name": "status",
     "type": "string"
    },
    {
     "name": "updates",
     "type": "string"
    },
    {
     "name": "risk",
     "type": "string"
    },
    {
     "name": "assign",
     "type": "string"
    },
    {
     "name": "lifecycle",
     "type": "string"
    },
    {
     "name": "compliance",
     "type": "string"
    },
    {
     "name": "issue",
     "type": "string"
    },
    {
     "name": "silentDays",
     "type": "string"
    }
   ],
   "replyShapes": [
    "Device[]",
    "{ items: array, total: integer, page: integer, pageSize: integer, kpis: object, staleDays, truncated }"
   ],
   "request": "Query (default view): ?limit (default 200, max 1000), ?offset (default 0), ?enrolled=1 (agent-enrolled devices only), ?pending=1 (include enrolments awaiting approval), ?retired=1 (include retired devices); ordered by id ascending. With ?view=list the fleet list is returned instead: ?page (from 1), ?pageSize (default 50, max 500), ?sort, ?dir (asc | desc), ?q (at most 200 characters) and the filters platform, status, updates, risk, assign, lifecycle, compliance, issue, silentDays",
   "response": "200 [ Device ] with Owner:{ id, firstName, lastName, email } — a bare array, no total | with ?view=list: { items[], total, page, pageSize, kpis, staleDays, truncated }",
   "responses": [
    {
     "status": 200,
     "text": "[ Device ] with Owner:{ id, firstName, lastName, email } — a bare array, no total | with ?view=list: { items[], total, page, pageSize, kpis, staleDays, truncated }"
    }
   ],
   "errors": [],
   "guard": "two paging conventions on one path: limit/offset for the default view, page/pageSize for ?view=list"
  },
  {
   "id": "POST /user-management/devices",
   "group": "logs-devices",
   "method": "POST",
   "path": "/user-management/devices",
   "purpose": "Register a device in inventory.",
   "auth": {
    "text": "Session + manage_devices",
    "kind": "session",
    "permissions": [
     "manage_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "A DeviceCreate: name (the Device model does not allow it to be empty), type, serialNumber, status, manufacturer, userId, assetTag, department, location, costCenter. Any other fields dropped.",
   "response": "201 the Device record as created (JSON). Writes a DEVICE_CREATED audit entry with actor from x-user header.",
   "responses": [
    {
     "status": 201,
     "text": "the Device record as created (JSON). Writes a DEVICE_CREATED audit entry with actor from x-user header."
    }
   ],
   "errors": [],
   "guard": "requires manage_devices; mass-assignment prevented by pickDevice() column allowlist; not deduplicated by this route: serialNumber is unique, so a repeat carrying the same serial fails on that constraint, and a repeat without one creates a second record"
  },
  {
   "id": "POST /agent/enroll",
   "group": "agent",
   "method": "POST",
   "path": "/agent/enroll",
   "purpose": "First-run enrolment: the agent sends its device report, an enrolment token and its public key; the instance pins the key and returns a device ID (one record per physical machine, keyed by serial).",
   "auth": {
    "text": "Enrolment token",
    "kind": "other",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "An AgentEnrollRequest (typed under Response Objects): { enrollmentToken, serialNumber (string), publicKey (P-256 SPKI PEM / base64 DER / raw X9.63 point) } required; optional inventory scalars (hostname, osVersion, osBuild, architecture, chip, modelName, cpuCores, memoryBytes, diskTotalBytes, diskFreeBytes, osUpdateAvailable, agentVersion, loggedInUser, platform) + inventory object blob",
   "response": "201 an AgentEnrollReply: { deviceId } | 400 missing field, invalid/non-P-256 key, or a serialNumber over 255 characters or containing a NUL | 401 invalid/expired/exhausted token | 403 the serial belongs to a retired device (an administrator must return it to service first) | 409 already enrolled with a different signing key (needs admin reEnrollAllowed)",
   "responses": [
    {
     "status": 201,
     "text": "an AgentEnrollReply: { deviceId }"
    },
    {
     "status": 400,
     "text": "missing field, invalid/non-P-256 key, or a serialNumber over 255 characters or containing a NUL"
    },
    {
     "status": 401,
     "text": "invalid/expired/exhausted token"
    },
    {
     "status": 403,
     "text": "the serial belongs to a retired device (an administrator must return it to service first)"
    },
    {
     "status": 409,
     "text": "already enrolled with a different signing key (needs admin reEnrollAllowed)"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "missing field, invalid/non-P-256 key, or a serialNumber over 255 characters or containing a NUL"
    },
    {
     "status": 401,
     "text": "invalid/expired/exhausted token"
    },
    {
     "status": 403,
     "text": "the serial belongs to a retired device (an administrator must return it to service first)"
    },
    {
     "status": 409,
     "text": "already enrolled with a different signing key (needs admin reEnrollAllowed)"
    }
   ],
   "guard": "no session — enrollment token only: vendor-signed token verified OFFLINE against committed keys (fail-closed if API_DOMAIN unset) OR legacy hashed single-use token atomically reserved; identity-rotation guard refuses re-pinning a different key on an enrolled device without admin approval; upsert by unique serialNumber"
  },
  {
   "id": "POST /agent/heartbeat",
   "group": "agent",
   "method": "POST",
   "path": "/agent/heartbeat",
   "purpose": "Signed check-in carrying inventory + security posture; the instance verifies the signature, timestamp and nonce, then returns fleet policy, any pending command, and self-update instructions.",
   "auth": {
    "text": "Device signature (P-256)",
    "kind": "other",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "An AgentHeartbeatRequest (typed under Response Objects). No key is required — auth is by signed headers (x-zx-device-id, x-zx-timestamp, x-zx-nonce, x-zx-signature). Body carries inventory scalars (same set as enroll incl. platform, agentVersion, loggedInUser) + free-form inventory object; signature is over the exact raw bytes.",
   "response": "200 an AgentHeartbeatReply (typed under Response Objects, with AgentPolicy, AgentUpdate and AgentCommand): { ok:true, deviceId, command, trustBundle, policy:{allowUserQuit, checkInIntervalMinutes, manageUpdates, update*/compliance* (null unless managed)}, policyHash, update:{version,url,sha256,sig,kid}|null (null unless a strictly newer agent version is targeted) }, plus commands[] and commandResultsAccepted for agents that declare commands_v2; the full contract is on Fleet Policy & Agent Signing | 401 missing headers / stale timestamp / unknown device / bad signature / replayed nonce",
   "responses": [
    {
     "status": 200,
     "text": "an AgentHeartbeatReply (typed under Response Objects, with AgentPolicy, AgentUpdate and AgentCommand): { ok:true, deviceId, command, trustBundle, policy:{allowUserQuit, checkInIntervalMinutes, manageUpdates, update*/compliance* (null unless managed)}, policyHash, update:{version,url,sha256,sig,kid}|null (null unless a strictly newer agent version is targeted) }, plus commands[] and commandResultsAccepted for agents that declare commands_v2; the full contract is on Fleet Policy & Agent Signing"
    },
    {
     "status": 401,
     "text": "missing headers / stale timestamp / unknown device / bad signature / replayed nonce"
    }
   ],
   "errors": [
    {
     "status": 401,
     "text": "missing headers / stale timestamp / unknown device / bad signature / replayed nonce"
    }
   ],
   "guard": "P-256 ECDSA/SHA-256 signature over canonical(zx-agent-v1\\nMETHOD\\npath\\nts\\nnonce\\nbodySha256) using the pinned device key; ±300s timestamp window; one-time nonce enforced by unique (deviceId,nonce) index (duplicate insert = replay 401); anti-rollback semver.gt gate on offered self-update"
  },
  {
   "id": "GET /agent/pkg",
   "group": "agent",
   "method": "GET",
   "path": "/agent/pkg",
   "purpose": "Download the macOS agent installer package (?v= selects a version).",
   "auth": {
    "text": "Public (rate-limited)",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [
    {
     "name": "v",
     "type": "string",
     "note": "A hosted version. Left out: the current hosted one."
    }
   ],
   "request": "Query: ?v= optional version; omitted, the current hosted package",
   "response": "200 the package bytes (application/octet-stream, Content-Disposition attachment, X-Zx-Pkg-Sha256 header) | 404 { error:'No macos agent … is hosted' }",
   "responses": [
    {
     "status": 200,
     "text": "the package bytes (application/octet-stream, Content-Disposition attachment, X-Zx-Pkg-Sha256 header)"
    },
    {
     "status": 404,
     "text": "{ error:'No macos agent … is hosted' }"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "{ error:'No macos agent … is hosted' }"
    }
   ],
   "guard": "the package carries no secret; the agent verifies the vendor release signature before it updates itself"
  },
  {
   "id": "GET /agent/linux/binary",
   "group": "agent",
   "method": "GET",
   "path": "/agent/linux/binary",
   "purpose": "Download the Linux agent binary.",
   "auth": {
    "text": "Public (rate-limited)",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [
    {
     "name": "v",
     "type": "string",
     "note": "A hosted version. Left out: the current hosted one."
    }
   ],
   "request": "Query: ?v= optional version",
   "response": "200 the binary (application/octet-stream, X-Zx-Pkg-Sha256 header) | 404 { error:'No linux agent … is hosted' }",
   "responses": [
    {
     "status": 200,
     "text": "the binary (application/octet-stream, X-Zx-Pkg-Sha256 header)"
    },
    {
     "status": 404,
     "text": "{ error:'No linux agent … is hosted' }"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "{ error:'No linux agent … is hosted' }"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /agent/linux/install",
   "group": "agent",
   "method": "GET",
   "path": "/agent/linux/install",
   "purpose": "The one-line Linux install script.",
   "auth": {
    "text": "Public (rate-limited)",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [
    {
     "name": "v",
     "type": "string",
     "note": "A hosted version. Left out: the current hosted one."
    }
   ],
   "request": "Query: ?v= optional version; an unknown version is ignored and the current one is used",
   "response": "200 a shell script (text/x-shellscript). When the instance has no API host, enrolment token or Linux binary yet, still 200: a script that prints what is missing and exits 1",
   "responses": [
    {
     "status": 200,
     "text": "a shell script (text/x-shellscript). When the instance has no API host, enrolment token or Linux binary yet, still 200: a script that prints what is missing and exits 1"
    }
   ],
   "errors": [],
   "guard": "the enrolment token baked into the script is scoped to your instance and reusable"
  },
  {
   "id": "GET /agent/windows/binary",
   "group": "agent",
   "method": "GET",
   "path": "/agent/windows/binary",
   "purpose": "Download the Windows agent MSI.",
   "auth": {
    "text": "Public (rate-limited)",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [
    {
     "name": "v",
     "type": "string",
     "note": "A hosted version. Left out: the current hosted one."
    }
   ],
   "request": "Query: ?v= optional version",
   "response": "200 the MSI (application/octet-stream, X-Zx-Pkg-Sha256 header) | 404 { error:'No windows agent … is hosted' }",
   "responses": [
    {
     "status": 200,
     "text": "the MSI (application/octet-stream, X-Zx-Pkg-Sha256 header)"
    },
    {
     "status": 404,
     "text": "{ error:'No windows agent … is hosted' }"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "{ error:'No windows agent … is hosted' }"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /agent/windows/install",
   "group": "agent",
   "method": "GET",
   "path": "/agent/windows/install",
   "purpose": "The elevated-PowerShell Windows install one-liner.",
   "auth": {
    "text": "Public (rate-limited)",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [
    {
     "name": "v",
     "type": "string",
     "note": "A hosted version. Left out: the current hosted one."
    }
   ],
   "request": "Query: ?v= optional version; an unknown version is ignored and the current one is used",
   "response": "200 a PowerShell script (text/plain). When the instance has no API host, enrolment token or Windows MSI yet, still 200: a script that writes the error and exits 1",
   "responses": [
    {
     "status": 200,
     "text": "a PowerShell script (text/plain). When the instance has no API host, enrolment token or Windows MSI yet, still 200: a script that writes the error and exits 1"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /agent/escrow/recovery-key",
   "group": "agent",
   "method": "POST",
   "path": "/agent/escrow/recovery-key",
   "purpose": "An enrolled device escrows its disk-encryption recovery key (BitLocker on Windows; FileVault recovery-key escrow is part of the preview, not yet validated end-to-end).",
   "auth": {
    "text": "Device signature (P-256)",
    "kind": "other",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "An AgentRecoveryKeyEscrow: { keyType:'bitlocker'|'filevault', recoveryKey } required; volumeId optional (at most 255 characters). Signed with the same x-zx-* headers as the heartbeat",
   "response": "200 { ok:true, escrowed:true } | 400 { error:'keyType (bitlocker|filevault) and recoveryKey are required' } | 401 signature, timestamp or nonce rejected",
   "responses": [
    {
     "status": 200,
     "text": "{ ok:true, escrowed:true }"
    },
    {
     "status": 400,
     "text": "{ error:'keyType (bitlocker|filevault) and recoveryKey are required' }"
    },
    {
     "status": 401,
     "text": "signature, timestamp or nonce rejected"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ error:'keyType (bitlocker|filevault) and recoveryKey are required' }"
    },
    {
     "status": 401,
     "text": "signature, timestamp or nonce rejected"
    }
   ],
   "guard": "bound to the enrolled device by its signature over the body; the key is stored encrypted at rest; safe to repeat: one key is kept per device and key type, and a later call replaces it (audited as a rotation)"
  },
  {
   "id": "POST /agent/mdm-enroll-redeem",
   "group": "agent",
   "method": "POST",
   "path": "/agent/mdm-enroll-redeem",
   "purpose": "Redeem an MDM enrolment code from your instance's /enroll page and receive the enrolment profile.",
   "auth": {
    "text": "Enrolment code",
    "kind": "other",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "An AgentMdmEnrollRedeem: { code } required — at least 6 letters or digits; body limited to 4 KB",
   "response": "200 the enrolment profile (application/x-apple-aspen-config, attachment zonitix-enroll.mobileconfig) | 400 { error } the code is too short, did not work, or MDM is not set up | 502 { error } the enrolment service is not reachable",
   "responses": [
    {
     "status": 200,
     "text": "the enrolment profile (application/x-apple-aspen-config, attachment zonitix-enroll.mobileconfig)"
    },
    {
     "status": 400,
     "text": "{ error } the code is too short, did not work, or MDM is not set up"
    },
    {
     "status": 502,
     "text": "{ error } the enrolment service is not reachable"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ error } the code is too short, did not work, or MDM is not set up"
    },
    {
     "status": 502,
     "text": "{ error } the enrolment service is not reachable"
    }
   ],
   "guard": "the code is redeemed with your instance's own MDM key, and the returned profile only enrols into your instance; rate-limited separately from the other agent routes"
  },
  {
   "id": "GET /user-management/devices",
   "group": "agent",
   "method": "GET",
   "path": "/user-management/devices",
   "purpose": "View the fleet the agents populate (same records as the manual catalog).",
   "auth": {
    "text": "Session + view_devices",
    "kind": "session",
    "permissions": [
     "view_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [
    "limit",
    "offset",
    "page",
    "pageSize"
   ],
   "query": [
    {
     "name": "limit",
     "type": "integer",
     "default": 200,
     "minimum": 1,
     "maximum": 1000,
     "note": "Default view only."
    },
    {
     "name": "offset",
     "type": "integer",
     "default": 0,
     "minimum": 0,
     "note": "Default view only."
    },
    {
     "name": "enrolled",
     "type": "string",
     "note": "Default view: 1 or true keeps agent-enrolled devices only. With view=list enrolled devices are the default and 0 or false widens the list."
    },
    {
     "name": "pending",
     "type": "string",
     "enum": [
      "1",
      "true"
     ],
     "note": "Default view: include enrolments awaiting approval."
    },
    {
     "name": "retired",
     "type": "string",
     "enum": [
      "1",
      "true"
     ],
     "note": "Include retired devices."
    },
    {
     "name": "view",
     "type": "string",
     "enum": [
      "list"
     ],
     "note": "list answers the filtered, sorted, paged fleet list. The parameters below are read only with view=list."
    },
    {
     "name": "page",
     "type": "integer",
     "default": 1,
     "minimum": 1,
     "maximum": 1000000
    },
    {
     "name": "pageSize",
     "type": "integer",
     "default": 50,
     "minimum": 1,
     "maximum": 500
    },
    {
     "name": "sort",
     "type": "string",
     "default": "last"
    },
    {
     "name": "dir",
     "type": "string",
     "enum": [
      "asc",
      "desc"
     ]
    },
    {
     "name": "q",
     "type": "string",
     "note": "A longer value is cut to 200 characters."
    },
    {
     "name": "platform",
     "type": "string"
    },
    {
     "name": "status",
     "type": "string"
    },
    {
     "name": "updates",
     "type": "string"
    },
    {
     "name": "risk",
     "type": "string"
    },
    {
     "name": "assign",
     "type": "string"
    },
    {
     "name": "lifecycle",
     "type": "string"
    },
    {
     "name": "compliance",
     "type": "string"
    },
    {
     "name": "issue",
     "type": "string"
    },
    {
     "name": "silentDays",
     "type": "string"
    }
   ],
   "replyShapes": [
    "Device[]",
    "{ items: array, total: integer, page: integer, pageSize: integer, kpis: object, staleDays, truncated }"
   ],
   "request": "Query (default view): ?limit (default 200, max 1000), ?offset (default 0), ?enrolled=1 (agent-enrolled devices only), ?pending=1 (include enrolments awaiting approval), ?retired=1 (include retired devices); ordered by id ascending. With ?view=list the fleet list is returned instead: ?page (from 1), ?pageSize (default 50, max 500), ?sort, ?dir (asc | desc), ?q (at most 200 characters) and the filters platform, status, updates, risk, assign, lifecycle, compliance, issue, silentDays",
   "response": "200 [ Device ] with Owner:{ id, firstName, lastName, email } — a bare array, no total | with ?view=list: { items[], total, page, pageSize, kpis, staleDays, truncated }",
   "responses": [
    {
     "status": 200,
     "text": "[ Device ] with Owner:{ id, firstName, lastName, email } — a bare array, no total | with ?view=list: { items[], total, page, pageSize, kpis, staleDays, truncated }"
    }
   ],
   "errors": [],
   "guard": "two paging conventions on one path: limit/offset for the default view, page/pageSize for ?view=list"
  },
  {
   "id": "GET /user-management/devices",
   "group": "device-management",
   "method": "GET",
   "path": "/user-management/devices",
   "purpose": "The device list. Query: ?limit (default 200, 1–1000) and ?offset; ?enrolled=1 for enrolled machines only; ?pending=1 and ?retired=1 to include those; ordered by id ascending. ?view=list switches to the filtered, sorted, paged list the Inventory page uses.",
   "auth": {
    "text": "Session + view_devices",
    "kind": "session",
    "permissions": [
     "view_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [
    "page",
    "pageSize"
   ],
   "query": [
    {
     "name": "limit",
     "type": "integer",
     "default": 200,
     "minimum": 1,
     "maximum": 1000,
     "note": "Default view only."
    },
    {
     "name": "offset",
     "type": "integer",
     "default": 0,
     "minimum": 0,
     "note": "Default view only."
    },
    {
     "name": "enrolled",
     "type": "string",
     "note": "Default view: 1 or true keeps agent-enrolled devices only. With view=list enrolled devices are the default and 0 or false widens the list."
    },
    {
     "name": "pending",
     "type": "string",
     "enum": [
      "1",
      "true"
     ],
     "note": "Default view: include enrolments awaiting approval."
    },
    {
     "name": "retired",
     "type": "string",
     "enum": [
      "1",
      "true"
     ],
     "note": "Include retired devices."
    },
    {
     "name": "view",
     "type": "string",
     "enum": [
      "list"
     ],
     "note": "list answers the filtered, sorted, paged fleet list. The parameters below are read only with view=list."
    },
    {
     "name": "page",
     "type": "integer",
     "default": 1,
     "minimum": 1,
     "maximum": 1000000
    },
    {
     "name": "pageSize",
     "type": "integer",
     "default": 50,
     "minimum": 1,
     "maximum": 500
    },
    {
     "name": "sort",
     "type": "string",
     "default": "last"
    },
    {
     "name": "dir",
     "type": "string",
     "enum": [
      "asc",
      "desc"
     ]
    },
    {
     "name": "q",
     "type": "string",
     "note": "A longer value is cut to 200 characters."
    },
    {
     "name": "platform",
     "type": "string"
    },
    {
     "name": "status",
     "type": "string"
    },
    {
     "name": "updates",
     "type": "string"
    },
    {
     "name": "risk",
     "type": "string"
    },
    {
     "name": "assign",
     "type": "string"
    },
    {
     "name": "lifecycle",
     "type": "string"
    },
    {
     "name": "compliance",
     "type": "string"
    },
    {
     "name": "issue",
     "type": "string"
    },
    {
     "name": "silentDays",
     "type": "string"
    }
   ],
   "replyShapes": [
    "Device[]",
    "{ items: array, total: integer, page: integer, pageSize: integer, kpis: object, staleDays, truncated }"
   ],
   "request": "Query string, not a JSON body, with ?view=list: ?page (default 1), ?pageSize (default 50, 1–500), ?sort = last (default), host, disk, platform, os, agent, owner, serial or issues, ?dir = asc or desc, ?q (searched text, at most 200 characters), ?platform = macos, linux or windows, ?status = online, offline, stale, d1_7, d7_30 or d30plus, ?updates = avail, os, agent or uptodate, ?risk = risk or ok, ?assign = assigned or unassigned, ?lifecycle = in_use, available, pending, retired or reserved, ?compliance = compliant, noncompliant or unknown, ?issue, ?silentDays (1–365), ?retired=1, ?enrolled=0. A value outside these lists is ignored, not refused",
   "response": "200 Device[] (typed under Response Objects), each with Owner { id, firstName, lastName, email } | with ?view=list: 200 { items, total, page, pageSize, kpis, staleDays, truncated } — truncated is true when the fleet is larger than the 20,000 devices one request scans",
   "responses": [
    {
     "status": 200,
     "text": "Device[] (typed under Response Objects), each with Owner { id, firstName, lastName, email } | with ?view=list: 200 { items, total, page, pageSize, kpis, staleDays, truncated } — truncated is true when the fleet is larger than the 20,000 devices one request scans"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /user-management/devices",
   "group": "device-management",
   "method": "POST",
   "path": "/user-management/devices",
   "purpose": "Create a device record by hand. Repeating the call creates another record unless it carries a serialNumber: that column is unique, and the handler has no specific answer for a duplicate, so it surfaces as a 500 rather than a 409.",
   "auth": {
    "text": "Session + manage_devices",
    "kind": "session",
    "permissions": [
     "manage_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "A DeviceCreate: { name (required), type, serialNumber, status, manufacturer, userId, assetTag, department, location, costCenter } — other fields are ignored",
   "response": "201 the Device record as created",
   "responses": [
    {
     "status": 201,
     "text": "the Device record as created"
    }
   ],
   "errors": [],
   "guard": "Repeat: creates another device record. serialNumber is unique, so a serial number already on a record is not accepted a second time"
  },
  {
   "id": "GET /user-management/devices/:id",
   "group": "device-management",
   "method": "GET",
   "path": "/user-management/devices/:id",
   "purpose": "One device (CSV of the filtered list at /devices/export.csv).",
   "auth": {
    "text": "Session + view_devices",
    "kind": "session",
    "permissions": [
     "view_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 the Device with Owner { id, firstName, lastName, email }; the agent's public key is never included | 404 unknown device. The CSV export takes the ?view=list filters and is audited as DEVICE_EXPORT",
   "responses": [
    {
     "status": 200,
     "text": "the Device with Owner { id, firstName, lastName, email }; the agent's public key is never included"
    },
    {
     "status": 404,
     "text": "unknown device. The CSV export takes the ?view=list filters and is audited as DEVICE_EXPORT"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "unknown device. The CSV export takes the ?view=list filters and is audited as DEVICE_EXPORT"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /user-management/devices/lite",
   "group": "device-management",
   "method": "GET",
   "path": "/user-management/devices/lite",
   "purpose": "A short device list for pickers: id, names, serial number, platform, owner and signed-in user only. Query: ?q searches hostname, name, serial number, asset tag and signed-in user (the first 100 characters are used); ?limit (default 25, 1–100); ?enrolled=1 keeps agent-enrolled devices only; or ?ids= (one comma-separated list of at most 100 device ids) to fetch exactly those, in which case q, limit and enrolled are ignored. Retired devices and devices still waiting for enrolment approval are never included, in either mode. Ordered by hostname; no offset parameter, so narrow with ?q rather than paging.",
   "auth": {
    "text": "Session + view_devices",
    "kind": "session",
    "permissions": [
     "view_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [
    "limit"
   ],
   "query": [
    {
     "name": "q",
     "type": "string",
     "note": "A longer value is cut to 100 characters."
    },
    {
     "name": "limit",
     "type": "integer",
     "default": 25,
     "minimum": 1,
     "maximum": 100
    },
    {
     "name": "enrolled",
     "type": "string",
     "enum": [
      "1",
      "true"
     ],
     "note": "Agent-enrolled devices only."
    },
    {
     "name": "ids",
     "type": "string",
     "note": "One comma-separated list of at most 100 device ids. When sent, q, limit and enrolled are not read."
    }
   ],
   "request": "Query string, not a JSON body: ?q, ?limit, ?enrolled, ?ids.",
   "response": "200 { items: DeviceLite[], total:integer } (typed under Response Objects) — each item { id, hostname, name, serialNumber, platform, ownerName, loggedInUser }; total counts every match, not only the items sent | 400 { error } when ids is repeated instead of comma-separated, holds a value that is not a number, or names more than 100 ids",
   "responses": [
    {
     "status": 200,
     "text": "{ items: DeviceLite[], total:integer } (typed under Response Objects) — each item { id, hostname, name, serialNumber, platform, ownerName, loggedInUser }; total counts every match, not only the items sent"
    },
    {
     "status": 400,
     "text": "{ error } when ids is repeated instead of comma-separated, holds a value that is not a number, or names more than 100 ids"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ error } when ids is repeated instead of comma-separated, holds a value that is not a number, or names more than 100 ids"
    }
   ],
   "guard": "read-only and safe to repeat"
  },
  {
   "id": "GET /user-management/devices/summary",
   "group": "device-management",
   "method": "GET",
   "path": "/user-management/devices/summary",
   "purpose": "Fleet totals for the Overview page. No parameters.",
   "auth": {
    "text": "Session + view_devices",
    "kind": "session",
    "permissions": [
     "view_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { generatedAt, total, pending, byPlatform, stale, updates, coverage, belowMinOs, compliant, complianceUnknown, attention, retiredReappeared, osVersions, agentVersions, alerts, staleDays }",
   "responses": [
    {
     "status": 200,
     "text": "{ generatedAt, total, pending, byPlatform, stale, updates, coverage, belowMinOs, compliant, complianceUnknown, attention, retiredReappeared, osVersions, agentVersions, alerts, staleDays }"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /user-management/devices/:id/effective-policy",
   "group": "device-management",
   "method": "GET",
   "path": "/user-management/devices/:id/effective-policy",
   "purpose": "The effective policy for one device (its Profiles & policy tab). Its groups are at /devices/:id/groups.",
   "auth": {
    "text": "Session + view_devices",
    "kind": "session",
    "permissions": [
     "view_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { platform, baseline, profiles: [{ id, name, payloadKeys }], effective, profileEnforcementOn, rollout: { target, frozen } } | 404 for an unknown device",
   "responses": [
    {
     "status": 200,
     "text": "{ platform, baseline, profiles: [{ id, name, payloadKeys }], effective, profileEnforcementOn, rollout: { target, frozen } }"
    },
    {
     "status": 404,
     "text": "for an unknown device"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "for an unknown device"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /user-management/devices/:id/activity",
   "group": "device-management",
   "method": "GET",
   "path": "/user-management/devices/:id/activity",
   "purpose": "The device's audit history (its Activity tab). Query: ?limit (default 50, 1–200). Newest first.",
   "auth": {
    "text": "Session + view_devices",
    "kind": "session",
    "permissions": [
     "view_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 DeviceActivityEntry[] (typed under Response Objects) { id, action, level, userEmail, createdAt, details }; no offset parameter",
   "responses": [
    {
     "status": 200,
     "text": "DeviceActivityEntry[] (typed under Response Objects) { id, action, level, userEmail, createdAt, details }; no offset parameter"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /user-management/devices/trends",
   "group": "device-management",
   "method": "GET",
   "path": "/user-management/devices/trends",
   "purpose": "Daily fleet snapshots for the trend chart. Query: ?days (default 30, at most 365).",
   "auth": {
    "text": "Session + view_devices",
    "kind": "session",
    "permissions": [
     "view_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "[{ day, metrics }], oldest first",
   "responses": [
    {
     "status": null,
     "text": "[{ day, metrics }], oldest first"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /user-management/devices/compliance/coverage",
   "group": "device-management",
   "method": "GET",
   "path": "/user-management/devices/compliance/coverage",
   "purpose": "Which compliance requirements your profiles cover (Profiles → Compliance coverage). Query: ?framework = SOC2, ISO27001, HIPAA, PCI, NIST, HITRUST or all.",
   "auth": {
    "text": "Session + view_devices",
    "kind": "session",
    "permissions": [
     "view_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { framework, onlineDays, kpis, requirements } | 400 for an unknown framework",
   "responses": [
    {
     "status": 200,
     "text": "{ framework, onlineDays, kpis, requirements }"
    },
    {
     "status": 400,
     "text": "for an unknown framework"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "for an unknown framework"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /user-management/devices/settings/alerts",
   "group": "device-management",
   "method": "GET",
   "path": "/user-management/devices/settings/alerts",
   "purpose": "Device alert settings.",
   "auth": {
    "text": "Session + view_devices",
    "kind": "session",
    "permissions": [
     "view_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "{ pendingEnrollment, noncompliant, encryptionOff, silentDays, enrollTokenDays, pushCertDays[], recipients, configured }",
   "responses": [
    {
     "status": null,
     "text": "{ pendingEnrollment, noncompliant, encryptionOff, silentDays, enrollTokenDays, pushCertDays[], recipients, configured }"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "PUT /user-management/devices/settings/alerts",
   "group": "device-management",
   "method": "PUT",
   "path": "/user-management/devices/settings/alerts",
   "purpose": "Change device alert settings; only the fields sent are changed. Audited.",
   "auth": {
    "text": "Session + manage_devices",
    "kind": "session",
    "permissions": [
     "manage_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "A DeviceAlertSettingsUpdate: pendingEnrollment, noncompliant, encryptionOff: true or false; silentDays: 1–365 or null (off), default 7; enrollTokenDays: 1–90 or null (off), default 7; pushCertDays: up to 6 whole numbers, each 1–365, default [30, 14, 7, 1]; recipients cannot be changed from manage_devices",
   "response": "200 the full settings | 400 with the reason for a value out of range",
   "responses": [
    {
     "status": 200,
     "text": "the full settings"
    },
    {
     "status": 400,
     "text": "with the reason for a value out of range"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "with the reason for a value out of range"
    }
   ],
   "guard": "only the keys sent are changed. Repeat: safe; the same values are saved again and the audit entry records no change"
  },
  {
   "id": "GET /user-management/devices/alerts/recent",
   "group": "device-management",
   "method": "GET",
   "path": "/user-management/devices/alerts/recent",
   "purpose": "Alerts that fired or cleared recently. Query: ?limit (default 50, 1–200). Newest first.",
   "auth": {
    "text": "Session + view_devices",
    "kind": "session",
    "permissions": [
     "view_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 an array of alert events: the stored row (subjectType, subjectId, state, sentAt, createdAt and so on) plus a readable label",
   "responses": [
    {
     "status": 200,
     "text": "an array of alert events: the stored row (subjectType, subjectId, state, sentAt, createdAt and so on) plus a readable label"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /user-management/devices/settings/stale",
   "group": "device-management",
   "method": "GET",
   "path": "/user-management/devices/settings/stale",
   "purpose": "Days without a check-in before a device counts as stale.",
   "auth": {
    "text": "Session + view_devices",
    "kind": "session",
    "permissions": [
     "view_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "{ staleDays, deviceStaleDays, default: 7, min: 1, max: 365 }",
   "responses": [
    {
     "status": null,
     "text": "{ staleDays, deviceStaleDays, default: 7, min: 1, max: 365 }"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "PUT /user-management/devices/settings/stale",
   "group": "device-management",
   "method": "PUT",
   "path": "/user-management/devices/settings/stale",
   "purpose": "Set the stale threshold. Audited with the old and new value. Safe to repeat: saving the value it already has changes nothing.",
   "auth": {
    "text": "Session + manage_devices",
    "kind": "session",
    "permissions": [
     "manage_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "A DeviceStaleSettingUpdate: { staleDays } — a whole number from 1 to 365",
   "response": "200 the same shape as the GET | 400 when out of range",
   "responses": [
    {
     "status": 200,
     "text": "the same shape as the GET"
    },
    {
     "status": 400,
     "text": "when out of range"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "when out of range"
    }
   ],
   "guard": "Repeat: safe; saving the value it already has changes nothing"
  },
  {
   "id": "GET /user-management/devices/custom-fields",
   "group": "device-management",
   "method": "GET",
   "path": "/user-management/devices/custom-fields",
   "purpose": "Your own device fields.",
   "auth": {
    "text": "Session + view_devices",
    "kind": "session",
    "permissions": [
     "view_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "{ fields }",
   "responses": [
    {
     "status": null,
     "text": "{ fields }"
    }
   ],
   "errors": [],
   "guard": "the whole field list in one reply (no paging)"
  },
  {
   "id": "POST /user-management/devices/custom-fields",
   "group": "device-management",
   "method": "POST",
   "path": "/user-management/devices/custom-fields",
   "purpose": "Replace the set of custom device fields. Audited. A replace-style write, safe to repeat.",
   "auth": {
    "text": "Session + manage_devices",
    "kind": "session",
    "permissions": [
     "manage_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "A DeviceCustomFieldsWrite: { fields } — at most 20; each is { key, label (cut to 60 characters), type: text | select | number | date, options (a select keeps up to 50) }; a malformed entry is skipped, not refused",
   "response": "{ fields }",
   "responses": [
    {
     "status": null,
     "text": "{ fields }"
    }
   ],
   "errors": [],
   "guard": "replaces the whole list. Repeat: safe; the same list is stored again (each call is audited)"
  },
  {
   "id": "POST /user-management/devices/install-invites",
   "group": "device-management",
   "method": "POST",
   "path": "/user-management/devices/install-invites",
   "purpose": "Email the macOS install link to chosen people. The server builds the link itself.",
   "auth": {
    "text": "Session + manage_devices",
    "kind": "session",
    "permissions": [
     "manage_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "An InstallInvitesSend: { userIds } — the users to email",
   "response": "{ sent, skipped, failed } | 400 when no user is selected, no agent package is hosted, or the download address cannot be built",
   "responses": [
    {
     "status": null,
     "text": "{ sent, skipped, failed }"
    },
    {
     "status": 400,
     "text": "when no user is selected, no agent package is hosted, or the download address cannot be built"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "when no user is selected, no agent package is hosted, or the download address cannot be built"
    }
   ],
   "guard": "sends real email. Repeat: every user named is emailed again"
  },
  {
   "id": "POST /user-management/devices/agent-pkg",
   "group": "device-management",
   "method": "POST",
   "path": "/user-management/devices/agent-pkg",
   "purpose": "Upload an agent installer for the instance to host.",
   "auth": {
    "text": "Session + manage_devices",
    "kind": "session",
    "permissions": [
     "manage_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The file itself as the raw request body, Content-Type application/octet-stream, at most 150 MB: a signed .zxagent bundle, or a raw installer. Query: ?platform= (macos | linux | windows; anything else is read as macos), ?version= and ?filename= (a bundle's own version wins over ?version).",
   "response": "201 an AgentPackageUpload (typed under Response Objects) | 400 { error } for an empty body, a raw installer where the instance requires a signed bundle, a bundle whose SHA-256 does not match its header, or a bundle whose release signature is not verified",
   "responses": [
    {
     "status": 201,
     "text": "an AgentPackageUpload (typed under Response Objects)"
    },
    {
     "status": 400,
     "text": "{ error } for an empty body, a raw installer where the instance requires a signed bundle, a bundle whose SHA-256 does not match its header, or a bundle whose release signature is not verified"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ error } for an empty body, a raw installer where the instance requires a signed bundle, a bundle whose SHA-256 does not match its header, or a bundle whose release signature is not verified"
    }
   ],
   "guard": "Replaces the hosted installer of the same platform and version; with no version, every hosted installer of that platform. Audited as DEVICE_AGENT_PKG_UPLOADED."
  },
  {
   "id": "GET /user-management/devices/agent-pkg",
   "group": "device-management",
   "method": "GET",
   "path": "/user-management/devices/agent-pkg",
   "purpose": "Whether an installer is hosted for a platform, and which one is newest.",
   "auth": {
    "text": "Session + view_devices",
    "kind": "session",
    "permissions": [
     "view_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query: ?platform= (macos | linux | windows; default macos).",
   "response": "200 { exists:false, platform } when none is hosted, else an AgentPackage (typed under Response Objects) with exists:true and platform",
   "responses": [
    {
     "status": 200,
     "text": "{ exists:false, platform } when none is hosted, else an AgentPackage (typed under Response Objects) with exists:true and platform"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /user-management/devices/agent-pkgs",
   "group": "device-management",
   "method": "GET",
   "path": "/user-management/devices/agent-pkgs",
   "purpose": "Every hosted version of a platform's installer.",
   "auth": {
    "text": "Session + view_devices",
    "kind": "session",
    "permissions": [
     "view_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query: ?platform= (macos | linux | windows; default macos).",
   "response": "200 { platform, versions: AgentPackage[] } (typed under Response Objects: each { version, filename, sha256, sizeBytes, createdAt }), newest first, one entry per version; an installer stored without a version is left out",
   "responses": [
    {
     "status": 200,
     "text": "{ platform, versions: AgentPackage[] } (typed under Response Objects: each { version, filename, sha256, sizeBytes, createdAt }), newest first, one entry per version; an installer stored without a version is left out"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /user-management/devices/agent-pkg/download",
   "group": "device-management",
   "method": "GET",
   "path": "/user-management/devices/agent-pkg/download",
   "purpose": "Download the newest hosted installer of a platform.",
   "auth": {
    "text": "Session + view_devices",
    "kind": "session",
    "permissions": [
     "view_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query: ?platform= (macos | linux | windows; default macos).",
   "response": "200 the file (application/octet-stream, Content-Disposition attachment, X-Zx-Pkg-Sha256 header) | 404 { error:'No <platform> agent is hosted' }",
   "responses": [
    {
     "status": 200,
     "text": "the file (application/octet-stream, Content-Disposition attachment, X-Zx-Pkg-Sha256 header)"
    },
    {
     "status": 404,
     "text": "{ error:'No <platform> agent is hosted' }"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "{ error:'No <platform> agent is hosted' }"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /user-management/devices/setup-state",
   "group": "device-management",
   "method": "GET",
   "path": "/user-management/devices/setup-state",
   "purpose": "Which platforms are in use on the instance: an installer hosted, or devices enrolled.",
   "auth": {
    "text": "Session + view_devices",
    "kind": "session",
    "permissions": [
     "view_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 a DeviceSetupState (typed under Response Objects)",
   "responses": [
    {
     "status": 200,
     "text": "a DeviceSetupState (typed under Response Objects)"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /user-management/devices/owner",
   "group": "device-management",
   "method": "GET",
   "path": "/user-management/devices/owner",
   "purpose": "Data-subject export: every device owned by one person. Query: ?userId.",
   "auth": {
    "text": "Session + manage_devices or manage_security",
    "kind": "session",
    "permissions": [
     "manage_devices",
     "manage_security"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { devices: Device[] } — empty, never an error, for a missing or unknown userId",
   "responses": [
    {
     "status": 200,
     "text": "{ devices: Device[] } — empty, never an error, for a missing or unknown userId"
    }
   ],
   "errors": [],
   "guard": "every device of that person in one reply, id ascending (no paging)"
  },
  {
   "id": "DELETE /user-management/devices/owner",
   "group": "device-management",
   "method": "DELETE",
   "path": "/user-management/devices/owner",
   "purpose": "Data-subject erasure for one person's devices: clears loggedInUser, hostname and lastCheckinIp and unassigns the owner on each, keeping the asset record. Safe to repeat. Audited as DEVICE_OWNER_ERASED. Query: ?userId.",
   "auth": {
    "text": "Session + manage_devices or manage_security",
    "kind": "session",
    "permissions": [
     "manage_devices",
     "manage_security"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No body. Query: ?userId, the person whose devices are cleared",
   "response": "200 { erased } — the number of devices changed; { erased: 0 } for a missing or non-numeric userId",
   "responses": [
    {
     "status": 200,
     "text": "{ erased } — the number of devices changed; { erased: 0 } for a missing or non-numeric userId"
    }
   ],
   "errors": [],
   "guard": "Repeat: safe; a second call finds no device of that person, answers { erased: 0 }"
  },
  {
   "id": "GET /user-management/devices/reservations/suggestions",
   "group": "device-management",
   "method": "GET",
   "path": "/user-management/devices/reservations/suggestions",
   "purpose": "New-hire computers: enrolled, unowned devices that may be a reserved computer, with the reservations they could match.",
   "auth": {
    "text": "Session + view_devices",
    "kind": "session",
    "permissions": [
     "view_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "{ suggestions, open }",
   "responses": [
    {
     "status": null,
     "text": "{ suggestions, open }"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /user-management/devices/:id/link-reservation",
   "group": "device-management",
   "method": "POST",
   "path": "/user-management/devices/:id/link-reservation",
   "purpose": "Confirm that an enrolled device is a reserved new-hire computer; the new hire becomes its owner.",
   "auth": {
    "text": "Session + manage_devices",
    "kind": "session",
    "permissions": [
     "manage_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "A ReservationLink: { reservationId }",
   "response": "{ id, linked, userId } | 400 no reservationId or device not enrolled | 404 unknown device | 409 reservation no longer open",
   "responses": [
    {
     "status": null,
     "text": "{ id, linked, userId }"
    },
    {
     "status": 400,
     "text": "no reservationId or device not enrolled"
    },
    {
     "status": 404,
     "text": "unknown device"
    },
    {
     "status": 409,
     "text": "reservation no longer open"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "no reservationId or device not enrolled"
    },
    {
     "status": 404,
     "text": "unknown device"
    },
    {
     "status": 409,
     "text": "reservation no longer open"
    }
   ],
   "guard": "Repeat: 409; the reservation record is removed once it is linked, so it is no longer open"
  },
  {
   "id": "POST /user-management/devices/:id/dismiss-reservation-suggestion",
   "group": "device-management",
   "method": "POST",
   "path": "/user-management/devices/:id/dismiss-reservation-suggestion",
   "purpose": "Mark a device as not a new hire's, so it stops being suggested. Audited.",
   "auth": {
    "text": "Session + manage_devices",
    "kind": "session",
    "permissions": [
     "manage_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "{ id, dismissed } | 404 unknown device",
   "responses": [
    {
     "status": null,
     "text": "{ id, dismissed }"
    },
    {
     "status": 404,
     "text": "unknown device"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "unknown device"
    }
   ],
   "guard": "Repeat: safe; the device stays dismissed"
  },
  {
   "id": "GET /user-management/devices/enroll-tokens",
   "group": "device-management",
   "method": "GET",
   "path": "/user-management/devices/enroll-tokens",
   "purpose": "List enrolment tokens: the newest 200, with status and use count, never the token value. No query parameters.",
   "auth": {
    "text": "Session + view_devices",
    "kind": "session",
    "permissions": [
     "view_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 EnrollmentToken[] (typed under Response Objects) { id, label, expiresAt, maxUses, useCount, usedAt, revokedAt, createdAt, status }: the 200 newest, newest first, with no paging parameter",
   "responses": [
    {
     "status": 200,
     "text": "EnrollmentToken[] (typed under Response Objects) { id, label, expiresAt, maxUses, useCount, usedAt, revokedAt, createdAt, status }: the 200 newest, newest first, with no paging parameter"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /user-management/devices/enroll-tokens",
   "group": "device-management",
   "method": "POST",
   "path": "/user-management/devices/enroll-tokens",
   "purpose": "Issue an extra enrolment token. The install commands in Devices → Settings already carry the instance's reusable token, so this is only for a separate short-lived one. The token value is returned once; only its hash is stored. Repeating the call issues another token.",
   "auth": {
    "text": "Session + manage_devices",
    "kind": "session",
    "permissions": [
     "manage_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "An EnrollmentTokenCreate: { label (cut to 120 characters), ttlHours (default 24, clamped 1–720), maxUses (default 1, clamped 1–500) }",
   "response": "201 an EnrollmentTokenIssued { id, token, label, expiresAt, maxUses } (typed under Response Objects); token is shown only here",
   "responses": [
    {
     "status": 201,
     "text": "an EnrollmentTokenIssued { id, token, label, expiresAt, maxUses } (typed under Response Objects); token is shown only here"
    }
   ],
   "errors": [],
   "guard": "Repeat: issues another token; two calls are never treated as one"
  },
  {
   "id": "POST /user-management/devices/enroll-tokens/:id/revoke",
   "group": "device-management",
   "method": "POST",
   "path": "/user-management/devices/enroll-tokens/:id/revoke",
   "purpose": "Revoke an enrolment token. No body. Safe to repeat: a second call returns revoked: false.",
   "auth": {
    "text": "Session + manage_devices",
    "kind": "session",
    "permissions": [
     "manage_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { revoked } — true only when this call revoked it",
   "responses": [
    {
     "status": 200,
     "text": "{ revoked } — true only when this call revoked it"
    }
   ],
   "errors": [],
   "guard": "Repeat: safe; a second call answers { revoked: false }"
  },
  {
   "id": "POST /user-management/devices/:id/approve-enrollment",
   "group": "device-management",
   "method": "POST",
   "path": "/user-management/devices/:id/approve-enrollment",
   "purpose": "Approve a device that is waiting for enrolment approval (reject at /devices/:id/reject-enrollment; several at /devices/batch/approve). No body. Repeating it leaves the device approved.",
   "auth": {
    "text": "Session + manage_devices",
    "kind": "session",
    "permissions": [
     "manage_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { id, enrollmentApproved: true } | 404 | 409 when the device is retired",
   "responses": [
    {
     "status": 200,
     "text": "{ id, enrollmentApproved: true }"
    },
    {
     "status": 404,
     "text": ""
    },
    {
     "status": 409,
     "text": "when the device is retired"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": ""
    },
    {
     "status": 409,
     "text": "when the device is retired"
    }
   ],
   "guard": "Repeat: safe; an approved device stays approved (each call is audited)"
  },
  {
   "id": "POST /user-management/devices/:id/reject-enrollment",
   "group": "device-management",
   "method": "POST",
   "path": "/user-management/devices/:id/reject-enrollment",
   "purpose": "Reject a device that is waiting for enrolment approval.",
   "auth": {
    "text": "Session + manage_devices",
    "kind": "session",
    "permissions": [
     "manage_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "An EnrollmentReject: { reason } — optional, at most 234 characters",
   "response": "200 { id, rejected: true, outcome } | 400 reason too long | 404 'Device not found' | 409 unless the enrolment is still pending",
   "responses": [
    {
     "status": 200,
     "text": "{ id, rejected: true, outcome }"
    },
    {
     "status": 400,
     "text": "reason too long"
    },
    {
     "status": 404,
     "text": "'Device not found'"
    },
    {
     "status": 409,
     "text": "unless the enrolment is still pending"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "reason too long"
    },
    {
     "status": 404,
     "text": "'Device not found'"
    },
    {
     "status": 409,
     "text": "unless the enrolment is still pending"
    }
   ],
   "guard": "Repeat: 409; after the first call the enrolment is no longer pending"
  },
  {
   "id": "POST /user-management/devices/batch/approve",
   "group": "device-management",
   "method": "POST",
   "path": "/user-management/devices/batch/approve",
   "purpose": "Approve several waiting devices in one call.",
   "auth": {
    "text": "Session + manage_devices",
    "kind": "session",
    "permissions": [
     "manage_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "A DeviceIdsRequest: { ids } — 1 to 500 device ids",
   "response": "200 a DeviceBatchOutcome { ok: [{ deviceId, enrollmentApproved: true }], failed: [{ deviceId, error }] } | 400 'ids must be a non-empty list of device ids' or 'At most 500 devices per request'",
   "responses": [
    {
     "status": 200,
     "text": "a DeviceBatchOutcome { ok: [{ deviceId, enrollmentApproved: true }], failed: [{ deviceId, error }] }"
    },
    {
     "status": 400,
     "text": "'ids must be a non-empty list of device ids' or 'At most 500 devices per request'"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "'ids must be a non-empty list of device ids' or 'At most 500 devices per request'"
    }
   ],
   "guard": "each device is tried on its own: one failure does not stop the rest, so read failed[] even on a 200. Repeat: safe; an approved device is approved again and listed in ok"
  },
  {
   "id": "POST /user-management/devices/:id/allow-reenroll",
   "group": "device-management",
   "method": "POST",
   "path": "/user-management/devices/:id/allow-reenroll",
   "purpose": "Let an already-enrolled device enrol again with a new signing key. No body. Audited as DEVICE_REENROLL_ALLOWED.",
   "auth": {
    "text": "Session + manage_devices",
    "kind": "session",
    "permissions": [
     "manage_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { id, reEnrollAllowed: true } | 404",
   "responses": [
    {
     "status": 200,
     "text": "{ id, reEnrollAllowed: true }"
    },
    {
     "status": 404,
     "text": ""
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": ""
    }
   ],
   "guard": "Repeat: safe; the approval stays set until the device next enrols, which uses it up (each call is audited)"
  },
  {
   "id": "GET /user-management/devices/policy",
   "group": "device-management",
   "method": "GET",
   "path": "/user-management/devices/policy",
   "purpose": "Read the fleet device policy (the write is PUT on the same path, the next row).",
   "auth": {
    "text": "Session + view_devices",
    "kind": "session",
    "permissions": [
     "view_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { macos, linux, windows, profilesOnly } — one policy view per platform. profilesOnly: true means the hardening and software-update settings are managed in Profiles and can no longer be changed through the policy write",
   "responses": [
    {
     "status": 200,
     "text": "{ macos, linux, windows, profilesOnly } — one policy view per platform. profilesOnly: true means the hardening and software-update settings are managed in Profiles and can no longer be changed through the policy write"
    }
   ],
   "errors": [],
   "guard": "one object, not a list"
  },
  {
   "id": "PUT /user-management/devices/policy",
   "group": "device-management",
   "method": "PUT",
   "path": "/user-management/devices/policy",
   "purpose": "Change the fleet device policy of one platform. Only the fields sent are changed. Audited as DEVICE_POLICY_UPDATED with the old and new values.",
   "auth": {
    "text": "Session + manage_devices",
    "kind": "session",
    "permissions": [
     "manage_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "A DevicePolicyUpdate: { platform: macos (default) | linux | windows, checkInIntervalMinutes (whole number, held to 5–1440), allowUserQuit (boolean), minOsVersion (dotted number such as 14.5, 24.04 or 10.0.22631; '' or null clears it), minAgentVersion (such as 1.1.0; '' or null clears it; not above the adopted agent version) }. Example: { \"platform\": \"macos\", \"checkInIntervalMinutes\": 15, \"minOsVersion\": \"14.5\" }",
   "response": "200 the saved policy of that platform | 400 { error } for a minOsVersion or minAgentVersion that is not a version, or a minAgentVersion above the adopted version | 409 { error, profilesOnly: true } when GET /devices/policy reports profilesOnly: true and the body changes a hardening or software-update setting (manageCompliance, the compliance* switches, complianceScreenLockMinutes, manageUpdates or the update* switches): change those in a profile instead",
   "responses": [
    {
     "status": 200,
     "text": "the saved policy of that platform"
    },
    {
     "status": 400,
     "text": "{ error } for a minOsVersion or minAgentVersion that is not a version, or a minAgentVersion above the adopted version"
    },
    {
     "status": 409,
     "text": "{ error, profilesOnly: true } when GET /devices/policy reports profilesOnly: true and the body changes a hardening or software-update setting (manageCompliance, the compliance* switches, complianceScreenLockMinutes, manageUpdates or the update* switches): change those in a profile instead"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ error } for a minOsVersion or minAgentVersion that is not a version, or a minAgentVersion above the adopted version"
    },
    {
     "status": 409,
     "text": "{ error, profilesOnly: true } when GET /devices/policy reports profilesOnly: true and the body changes a hardening or software-update setting (manageCompliance, the compliance* switches, complianceScreenLockMinutes, manageUpdates or the update* switches): change those in a profile instead"
    }
   ],
   "guard": "in profilesOnly mode a hardening or update field sent with its current value is ignored, so an old client that echoes the whole policy still saves; safe to repeat"
  },
  {
   "id": "POST /user-management/devices/:id/assign",
   "group": "device-management",
   "method": "POST",
   "path": "/user-management/devices/:id/assign",
   "purpose": "Assign a device to a person (several at /devices/batch/assign). A replace-style write, safe to repeat. The owner is written on every call: a body without userId removes the owner, so send the current owner's id when you only want to change the asset fields.",
   "auth": {
    "text": "Session + manage_devices",
    "kind": "session",
    "permissions": [
     "manage_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "A DeviceAssign: { userId (a user id; null or left out unassigns the owner), assetTag, department, location, costCenter (each cut to 255 characters; an empty string clears it; one left out keeps its value), customFields }",
   "response": "200 { id, userId, assetTag, department, location, costCenter } | 404",
   "responses": [
    {
     "status": 200,
     "text": "{ id, userId, assetTag, department, location, costCenter }"
    },
    {
     "status": 404,
     "text": ""
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": ""
    }
   ],
   "guard": "Repeat: safe; a replace-style write (each call is audited as DEVICE_ASSIGNED)"
  },
  {
   "id": "POST /user-management/devices/batch/assign",
   "group": "device-management",
   "method": "POST",
   "path": "/user-management/devices/batch/assign",
   "purpose": "Assign several devices to one person, or unassign them, in one call.",
   "auth": {
    "text": "Session + manage_devices",
    "kind": "session",
    "permissions": [
     "manage_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "A DeviceBatchAssign: { ids — 1 to 500 device ids, userId (required: a user id, or null to unassign) }",
   "response": "200 a DeviceBatchOutcome { ok: [{ deviceId, userId }], failed: [{ deviceId, error }] } | 400 'userId is required (a user id, or null to unassign)' / 'userId must be a user id or null (unassign)' / no ids or more than 500",
   "responses": [
    {
     "status": 200,
     "text": "a DeviceBatchOutcome { ok: [{ deviceId, userId }], failed: [{ deviceId, error }] }"
    },
    {
     "status": 400,
     "text": "'userId is required (a user id, or null to unassign)' / 'userId must be a user id or null (unassign)' / no ids or more than 500"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "'userId is required (a user id, or null to unassign)' / 'userId must be a user id or null (unassign)' / no ids or more than 500"
    }
   ],
   "guard": "each device is tried on its own, so read failed[] even on a 200; a replace-style write, safe to repeat"
  },
  {
   "id": "GET /user-management/devices/commands/catalog",
   "group": "device-management",
   "method": "GET",
   "path": "/user-management/devices/commands/catalog",
   "purpose": "The commands the agent can run: lock, uninstall, restart, update_os and rename.",
   "auth": {
    "text": "Session + view_devices",
    "kind": "session",
    "permissions": [
     "view_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { types: DeviceCommandType[] } (typed under Response Objects), each { type, label, platforms, destructive, args }",
   "responses": [
    {
     "status": 200,
     "text": "{ types: DeviceCommandType[] } (typed under Response Objects), each { type, label, platforms, destructive, args }"
    }
   ],
   "errors": [],
   "guard": "a fixed list, not paged"
  },
  {
   "id": "GET /user-management/devices/:id/commands",
   "group": "device-management",
   "method": "GET",
   "path": "/user-management/devices/:id/commands",
   "purpose": "A device's command queue and history. Query: ?limit (default 50, 1–200). Newest first.",
   "auth": {
    "text": "Session + view_devices",
    "kind": "session",
    "permissions": [
     "view_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 DeviceCommand[] (typed under Response Objects) { id, deviceId, type, args, status, requestedBy, reason, batchId, result, deliveredAt, completedAt, createdAt, updatedAt } | 404 unknown device",
   "responses": [
    {
     "status": 200,
     "text": "DeviceCommand[] (typed under Response Objects) { id, deviceId, type, args, status, requestedBy, reason, batchId, result, deliveredAt, completedAt, createdAt, updatedAt }"
    },
    {
     "status": 404,
     "text": "unknown device"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "unknown device"
    }
   ],
   "guard": null
  },
  {
   "id": "POST /user-management/devices/:id/commands",
   "group": "device-management",
   "method": "POST",
   "path": "/user-management/devices/:id/commands",
   "purpose": "Queue a command for a device (several devices at /devices/batch/commands; cancel with DELETE /devices/:id/commands/:commandId). Safe to repeat: when the same command with the same arguments is already queued, the call returns that command's id with noop: true and queues nothing new.",
   "auth": {
    "text": "Session + manage_devices",
    "kind": "session",
    "permissions": [
     "manage_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "A DeviceCommandCreate: { type: lock | uninstall | restart | update_os | rename (required), args ({ name } for rename: letters, digits and hyphens, at most 63 characters, 15 on Windows), reason (required, at least 3 characters, for uninstall; cut to 500) }. Batch: the same plus ids — 1 to 500 device ids",
   "response": "200 a DeviceCommandResult (typed under Response Objects) { ok: [{ deviceId, commandId, noop }], failed: [] } — noop is present, as true, only when nothing new was queued | 400 { error } unknown command or invalid arguments | 404 | 409 with failed[].code no_agent, a retired device, an uninstall already queued, or the same command queued with different arguments | 422 with failed[].code unsupported for this platform — a 404, 409 or 422 answers { ok: [], failed, error, pendingCommand } | 400 { error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo). The service answers 401 and the gateway returns it as 400 on this route, so match on the error text, not the status (uninstall only). Batch: 200 { ok, failed: [{ deviceId, error, pendingCommand }], batchId }. Cancel: 200 { id, deviceId, status: 'cancelled', pendingCommand } | 404 | 409 unless still queued",
   "responses": [
    {
     "status": 200,
     "text": "a DeviceCommandResult (typed under Response Objects) { ok: [{ deviceId, commandId, noop }], failed: [] } — noop is present, as true, only when nothing new was queued"
    },
    {
     "status": 400,
     "text": "{ error } unknown command or invalid arguments"
    },
    {
     "status": 404,
     "text": ""
    },
    {
     "status": 409,
     "text": "with failed[].code no_agent, a retired device, an uninstall already queued, or the same command queued with different arguments"
    },
    {
     "status": 422,
     "text": "with failed[].code unsupported for this platform — a 404, 409 or 422 answers { ok: [], failed, error, pendingCommand }"
    },
    {
     "status": 400,
     "text": "{ error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo). The service answers 401 and the gateway returns it as 400 on this route, so match on the error text, not the status (uninstall only). Batch: 200 { ok, failed: [{ deviceId, error, pendingCommand }], batchId }. Cancel: 200 { id, deviceId, status: 'cancelled', pendingCommand }"
    },
    {
     "status": 404,
     "text": ""
    },
    {
     "status": 409,
     "text": "unless still queued"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ error } unknown command or invalid arguments"
    },
    {
     "status": 404,
     "text": ""
    },
    {
     "status": 409,
     "text": "with failed[].code no_agent, a retired device, an uninstall already queued, or the same command queued with different arguments"
    },
    {
     "status": 422,
     "text": "with failed[].code unsupported for this platform — a 404, 409 or 422 answers { ok: [], failed, error, pendingCommand }"
    },
    {
     "status": 400,
     "text": "{ error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo). The service answers 401 and the gateway returns it as 400 on this route, so match on the error text, not the status (uninstall only). Batch: 200 { ok, failed: [{ deviceId, error, pendingCommand }], batchId }. Cancel: 200 { id, deviceId, status: 'cancelled', pendingCommand }"
    },
    {
     "status": 404,
     "text": ""
    },
    {
     "status": 409,
     "text": "unless still queued"
    }
   ],
   "guard": "a destructive command (uninstall) additionally needs manage_device_destructive and a fresh sudo before it is queued. Repeat: while the first is still queued, the same command with the same arguments is not queued twice: the call answers 200 with the first command's id and noop: true. Once it has been delivered, a repeat queues a new command"
  },
  {
   "id": "POST /user-management/devices/batch/commands",
   "group": "device-management",
   "method": "POST",
   "path": "/user-management/devices/batch/commands",
   "purpose": "Queue one command for several devices.",
   "auth": {
    "text": "Session + manage_devices (uninstall also needs manage_device_destructive and a fresh step-up)",
    "kind": "session",
    "permissions": [
     "manage_device_destructive",
     "manage_devices"
    ],
    "stepUp": "conditional",
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "A DeviceBatchCommandCreate: { ids — 1 to 500 device ids, type: lock | uninstall | restart | update_os | rename (required), args, reason (required for uninstall) } — the same type, args and reason rules as POST /devices/:id/commands",
   "response": "200 a DeviceCommandResult (typed under Response Objects) { ok: [{ deviceId, commandId, noop }], failed: [{ deviceId, error, pendingCommand? }], batchId } | 400 { error } for an unknown command, invalid arguments, no ids or more than 500 | 400 { error: 'SUDO_REQUIRED' } for uninstall without a fresh step-up (the service answers 401; the gateway returns it as 400)",
   "responses": [
    {
     "status": 200,
     "text": "a DeviceCommandResult (typed under Response Objects) { ok: [{ deviceId, commandId, noop }], failed: [{ deviceId, error, pendingCommand? }], batchId }"
    },
    {
     "status": 400,
     "text": "{ error } for an unknown command, invalid arguments, no ids or more than 500"
    },
    {
     "status": 400,
     "text": "{ error: 'SUDO_REQUIRED' } for uninstall without a fresh step-up (the service answers 401; the gateway returns it as 400)"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ error } for an unknown command, invalid arguments, no ids or more than 500"
    },
    {
     "status": 400,
     "text": "{ error: 'SUDO_REQUIRED' } for uninstall without a fresh step-up (the service answers 401; the gateway returns it as 400)"
    }
   ],
   "guard": "each device is tried on its own, so read failed[] even on a 200. Repeat: a device that still has the same command queued with the same arguments is listed in ok with noop: true and nothing new is queued for it"
  },
  {
   "id": "DELETE /user-management/devices/:id/commands/:commandId",
   "group": "device-management",
   "method": "DELETE",
   "path": "/user-management/devices/:id/commands/:commandId",
   "purpose": "Cancel a command that is still queued.",
   "auth": {
    "text": "Session + manage_devices",
    "kind": "session",
    "permissions": [
     "manage_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    },
    {
     "name": "commandId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { id, deviceId, status: 'cancelled', pendingCommand } | 404 'Device not found' or 'Command not found' | 409 when the command is no longer queued, or was delivered before it could be cancelled",
   "responses": [
    {
     "status": 200,
     "text": "{ id, deviceId, status: 'cancelled', pendingCommand }"
    },
    {
     "status": 404,
     "text": "'Device not found' or 'Command not found'"
    },
    {
     "status": 409,
     "text": "when the command is no longer queued, or was delivered before it could be cancelled"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'Device not found' or 'Command not found'"
    },
    {
     "status": 409,
     "text": "when the command is no longer queued, or was delivered before it could be cancelled"
    }
   ],
   "guard": "Repeat: 409; only a command that is still queued can be cancelled"
  },
  {
   "id": "POST /user-management/devices/retire",
   "group": "device-management",
   "method": "POST",
   "path": "/user-management/devices/retire",
   "purpose": "Retire devices (bring back with /devices/unretire). Safe to repeat: an already retired device is reported in ok and is not audited again. The record, its owner and its escrowed recovery keys are kept; queued commands are cancelled.",
   "auth": {
    "text": "Session + manage_devices",
    "kind": "session",
    "permissions": [
     "manage_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "A DeviceRetire: { ids — 1 to 500 device ids, reason (required, 3–255 characters) }. Unretire: { ids }",
   "response": "200 a DeviceLifecycleOutcome { ok: [ids], failed: [{ id, error }] } | 400 no ids, more than 500, or a missing or over-long reason",
   "responses": [
    {
     "status": 200,
     "text": "a DeviceLifecycleOutcome { ok: [ids], failed: [{ id, error }] }"
    },
    {
     "status": 400,
     "text": "no ids, more than 500, or a missing or over-long reason"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "no ids, more than 500, or a missing or over-long reason"
    }
   ],
   "guard": "each device is tried on its own, so read failed[] even on a 200. Repeat: safe; a device that is already retired is listed in ok and nothing about it is changed"
  },
  {
   "id": "POST /user-management/devices/unretire",
   "group": "device-management",
   "method": "POST",
   "path": "/user-management/devices/unretire",
   "purpose": "Bring retired devices back into service.",
   "auth": {
    "text": "Session + manage_devices",
    "kind": "session",
    "permissions": [
     "manage_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "A DeviceIdsRequest: { ids } — 1 to 500 device ids",
   "response": "200 a DeviceLifecycleOutcome { ok: [ids], failed: [{ id, error }] } | 400 no ids or more than 500",
   "responses": [
    {
     "status": 200,
     "text": "a DeviceLifecycleOutcome { ok: [ids], failed: [{ id, error }] }"
    },
    {
     "status": 400,
     "text": "no ids or more than 500"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "no ids or more than 500"
    }
   ],
   "guard": "safe to repeat: a device that is not retired is reported in ok and nothing is changed"
  },
  {
   "id": "DELETE /user-management/devices/:id",
   "group": "device-management",
   "method": "DELETE",
   "path": "/user-management/devices/:id",
   "purpose": "Delete a device record. A repeat answers 404.",
   "auth": {
    "text": "Session + manage_devices and manage_device_destructive + sudo",
    "kind": "session",
    "permissions": [
     "manage_device_destructive",
     "manage_devices"
    ],
    "stepUp": "required",
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "{ reason } in the body, or ?reason — required, 3–255 characters",
   "response": "200 { id, deleted: true } | 400 | 404 | 400 { error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo). The service answers 401 and the gateway returns it as 400 on this route, so match on the error text, not the status",
   "responses": [
    {
     "status": 200,
     "text": "{ id, deleted: true }"
    },
    {
     "status": 400,
     "text": ""
    },
    {
     "status": 404,
     "text": ""
    },
    {
     "status": 400,
     "text": "{ error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo). The service answers 401 and the gateway returns it as 400 on this route, so match on the error text, not the status"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": ""
    },
    {
     "status": 404,
     "text": ""
    },
    {
     "status": 400,
     "text": "{ error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo). The service answers 401 and the gateway returns it as 400 on this route, so match on the error text, not the status"
    }
   ],
   "guard": "Repeat: 404; the record is gone"
  },
  {
   "id": "GET /user-management/devices/groups",
   "group": "device-management",
   "method": "GET",
   "path": "/user-management/devices/groups",
   "purpose": "Device groups (members at /devices/groups/:id/devices). No query parameters; sorted by name, not paged.",
   "auth": {
    "text": "Session + view_devices",
    "kind": "session",
    "permissions": [
     "view_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { groups: DeviceGroup[], fields, ops, fieldOps, complianceValues } (DeviceGroup is typed under Response Objects: { id, name, description, kind, platform, criteria, createdBy, createdAt, memberCount, enforceableCount }). Members: 200 { group, devices, memberCount, enforceableCount } | 404",
   "responses": [
    {
     "status": 200,
     "text": "{ groups: DeviceGroup[], fields, ops, fieldOps, complianceValues } (DeviceGroup is typed under Response Objects: { id, name, description, kind, platform, criteria, createdBy, createdAt, memberCount, enforceableCount }). Members: 200 { group, devices, memberCount, enforceableCount }"
    },
    {
     "status": 404,
     "text": ""
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": ""
    }
   ],
   "guard": null
  },
  {
   "id": "POST /user-management/devices/groups",
   "group": "device-management",
   "method": "POST",
   "path": "/user-management/devices/groups",
   "purpose": "Create a device group (PUT or DELETE /devices/groups/:id; add members at /devices/groups/:id/members). Repeating the POST creates another group: names are not unique. Adding members is safe to repeat; a device already in the group is not added twice.",
   "auth": {
    "text": "Session + manage_devices",
    "kind": "session",
    "permissions": [
     "manage_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "A DeviceGroupCreate: { name (required), description, kind: 'static' (default) or 'smart', platform: macos | linux | windows (optional), criteria (smart groups), deviceIds (static groups) }. PUT: { name, description, criteria (smart groups only; ignored on a static group) }. Members: { deviceIds }",
   "response": "201 { id } | 400 no name or invalid criteria. PUT: 200 { ok: true, memberCount, enforceableCount } | 404. DELETE (?force=1 to strip it from the scopes that use it): 200 { ok: true, strippedFrom } | 404 | 409 with usedBy when a profile or agent rollout still uses it. Members: 200 { added } | 400 for a smart group, no deviceIds or a device of another platform | 404 unknown group",
   "responses": [
    {
     "status": 201,
     "text": "{ id }"
    },
    {
     "status": 400,
     "text": "no name or invalid criteria. PUT: 200 { ok: true, memberCount, enforceableCount }"
    },
    {
     "status": 404,
     "text": ". DELETE (?force=1 to strip it from the scopes that use it): 200 { ok: true, strippedFrom }"
    },
    {
     "status": 404,
     "text": ""
    },
    {
     "status": 409,
     "text": "with usedBy when a profile or agent rollout still uses it. Members: 200 { added }"
    },
    {
     "status": 400,
     "text": "for a smart group, no deviceIds or a device of another platform"
    },
    {
     "status": 404,
     "text": "unknown group"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "no name or invalid criteria. PUT: 200 { ok: true, memberCount, enforceableCount }"
    },
    {
     "status": 404,
     "text": ". DELETE (?force=1 to strip it from the scopes that use it): 200 { ok: true, strippedFrom }"
    },
    {
     "status": 404,
     "text": ""
    },
    {
     "status": 409,
     "text": "with usedBy when a profile or agent rollout still uses it. Members: 200 { added }"
    },
    {
     "status": 400,
     "text": "for a smart group, no deviceIds or a device of another platform"
    },
    {
     "status": 404,
     "text": "unknown group"
    }
   ],
   "guard": "Repeat: creates a second group; group names are not required to be unique"
  },
  {
   "id": "POST /user-management/devices/groups/preview",
   "group": "device-management",
   "method": "POST",
   "path": "/user-management/devices/groups/preview",
   "purpose": "Count the devices a smart group's criteria would match, without saving anything.",
   "auth": {
    "text": "Session + view_devices",
    "kind": "session",
    "permissions": [
     "view_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "A DeviceGroupPreview: { criteria } (kind, when sent, must be 'smart')",
   "response": "200 { count, sample, memberCount } | 400 { error } for a static group or invalid criteria",
   "responses": [
    {
     "status": 200,
     "text": "{ count, sample, memberCount }"
    },
    {
     "status": 400,
     "text": "{ error } for a static group or invalid criteria"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ error } for a static group or invalid criteria"
    }
   ],
   "guard": "Repeat: safe; nothing is stored"
  },
  {
   "id": "PUT /user-management/devices/groups/:id",
   "group": "device-management",
   "method": "PUT",
   "path": "/user-management/devices/groups/:id",
   "purpose": "Rename a device group, change its description, or change a smart group's criteria. Audited as DEVICE_GROUP_UPDATED.",
   "auth": {
    "text": "Session + manage_devices",
    "kind": "session",
    "permissions": [
     "manage_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "A DeviceGroupUpdate: { name, description, criteria (smart groups only; ignored on a static group) } — all optional",
   "response": "200 { ok: true, memberCount, enforceableCount } | 400 invalid criteria | 404 'group not found'",
   "responses": [
    {
     "status": 200,
     "text": "{ ok: true, memberCount, enforceableCount }"
    },
    {
     "status": 400,
     "text": "invalid criteria"
    },
    {
     "status": 404,
     "text": "'group not found'"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "invalid criteria"
    },
    {
     "status": 404,
     "text": "'group not found'"
    }
   ],
   "guard": "Repeat: safe; a replace-style write (each call is audited)"
  },
  {
   "id": "DELETE /user-management/devices/groups/:id",
   "group": "device-management",
   "method": "DELETE",
   "path": "/user-management/devices/groups/:id",
   "purpose": "Delete a device group. Audited as DEVICE_GROUP_DELETED.",
   "auth": {
    "text": "Session + manage_devices",
    "kind": "session",
    "permissions": [
     "manage_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "Query: ?force=1 (or true) also removes the group from every profile and agent-rollout scope that uses it",
   "response": "200 { ok: true, strippedFrom: { profiles, rollouts } } | 404 'group not found' | 409 { error, usedBy } when a profile or agent rollout still uses it and force is not set",
   "responses": [
    {
     "status": 200,
     "text": "{ ok: true, strippedFrom: { profiles, rollouts } }"
    },
    {
     "status": 404,
     "text": "'group not found'"
    },
    {
     "status": 409,
     "text": "{ error, usedBy } when a profile or agent rollout still uses it and force is not set"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'group not found'"
    },
    {
     "status": 409,
     "text": "{ error, usedBy } when a profile or agent rollout still uses it and force is not set"
    }
   ],
   "guard": "check GET /devices/groups/:id/usage first; a repeat answers 404"
  },
  {
   "id": "GET /user-management/devices/groups/:id/usage",
   "group": "device-management",
   "method": "GET",
   "path": "/user-management/devices/groups/:id/usage",
   "purpose": "The profiles and agent rollouts whose scope uses this group.",
   "auth": {
    "text": "Session + view_devices",
    "kind": "session",
    "permissions": [
     "view_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { groupId, usedBy: { profiles, rollouts }, count } | 404 'group not found'",
   "responses": [
    {
     "status": 200,
     "text": "{ groupId, usedBy: { profiles, rollouts }, count }"
    },
    {
     "status": 404,
     "text": "'group not found'"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'group not found'"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /user-management/devices/groups/:id/devices",
   "group": "device-management",
   "method": "GET",
   "path": "/user-management/devices/groups/:id/devices",
   "purpose": "The devices in a group.",
   "auth": {
    "text": "Session + view_devices",
    "kind": "session",
    "permissions": [
     "view_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { group: { id, name, kind, platform, criteria, description }, devices, memberCount, enforceableCount } | 404 'group not found'",
   "responses": [
    {
     "status": 200,
     "text": "{ group: { id, name, kind, platform, criteria, description }, devices, memberCount, enforceableCount }"
    },
    {
     "status": 404,
     "text": "'group not found'"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'group not found'"
    }
   ],
   "guard": "every member in one reply (no paging)"
  },
  {
   "id": "POST /user-management/devices/groups/:id/members",
   "group": "device-management",
   "method": "POST",
   "path": "/user-management/devices/groups/:id/members",
   "purpose": "Add devices to a static group. Audited as DEVICE_GROUP_MEMBERS_ADDED.",
   "auth": {
    "text": "Session + manage_devices",
    "kind": "session",
    "permissions": [
     "manage_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "A DeviceGroupMembers: { deviceIds } — a non-empty array of device ids",
   "response": "200 { added } — the number newly added | 400 for a smart group, no deviceIds, or a device of another platform in a platform-bound group | 404 'group not found'",
   "responses": [
    {
     "status": 200,
     "text": "{ added } — the number newly added"
    },
    {
     "status": 400,
     "text": "for a smart group, no deviceIds, or a device of another platform in a platform-bound group"
    },
    {
     "status": 404,
     "text": "'group not found'"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "for a smart group, no deviceIds, or a device of another platform in a platform-bound group"
    },
    {
     "status": 404,
     "text": "'group not found'"
    }
   ],
   "guard": "safe to repeat: a device already in the group is not added twice"
  },
  {
   "id": "DELETE /user-management/devices/groups/:id/members/:deviceId",
   "group": "device-management",
   "method": "DELETE",
   "path": "/user-management/devices/groups/:id/members/:deviceId",
   "purpose": "Remove one device from a group.",
   "auth": {
    "text": "Session + manage_devices",
    "kind": "session",
    "permissions": [
     "manage_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    },
    {
     "name": "deviceId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { removed } — 1, or 0 when the device was not a member",
   "responses": [
    {
     "status": 200,
     "text": "{ removed } — 1, or 0 when the device was not a member"
    }
   ],
   "errors": [],
   "guard": "safe to repeat"
  },
  {
   "id": "POST /user-management/devices/groups/:id/members/bulk-remove",
   "group": "device-management",
   "method": "POST",
   "path": "/user-management/devices/groups/:id/members/bulk-remove",
   "purpose": "Remove several devices from a group.",
   "auth": {
    "text": "Session + manage_devices",
    "kind": "session",
    "permissions": [
     "manage_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "A DeviceGroupMembers: { deviceIds } — a non-empty array of device ids",
   "response": "200 { removed } — the number removed | 400 'deviceIds[] required'",
   "responses": [
    {
     "status": 200,
     "text": "{ removed } — the number removed"
    },
    {
     "status": 400,
     "text": "'deviceIds[] required'"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "'deviceIds[] required'"
    }
   ],
   "guard": "safe to repeat"
  },
  {
   "id": "GET /user-management/devices/profiles",
   "group": "device-management",
   "method": "GET",
   "path": "/user-management/devices/profiles",
   "purpose": "Configuration profiles (catalogue at /devices/profiles/catalog, per-device status at /devices/:id/profile-status). No query parameters; sorted by name, not paged.",
   "auth": {
    "text": "Session + view_devices",
    "kind": "session",
    "permissions": [
     "view_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { profiles: [{ id, name, description, platform, mixed, payloads, scope, scopeLabel, scopeCount, scopeTotal, statusTracked, enabled, version, coversRequirements, createdBy, ... }] }",
   "responses": [
    {
     "status": 200,
     "text": "{ profiles: [{ id, name, description, platform, mixed, payloads, scope, scopeLabel, scopeCount, scopeTotal, statusTracked, enabled, version, coversRequirements, createdBy, ... }] }"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /user-management/devices/profiles",
   "group": "device-management",
   "method": "POST",
   "path": "/user-management/devices/profiles",
   "purpose": "Create a configuration profile (PUT or DELETE /devices/profiles/:id). Repeating the POST creates another profile: names are not unique.",
   "auth": {
    "text": "Session + manage_devices",
    "kind": "session",
    "permissions": [
     "manage_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "A DeviceProfileCreate: { name (required), platform (required: macos, linux or windows; cannot be changed later), payloads, scope: { type: all | groups | devices, groupIds, deviceIds, excludeGroupIds, excludeDeviceIds }, description, enabled }. PUT takes the same fields, all optional",
   "response": "201 { id } | 400 no name, no or unknown platform, or a payload the platform does not support. PUT: 200 { ok: true, version } | 400 | 404 | 409 while the profile is rolling out in stages. DELETE: 200 { ok: true, mdmRemoval, warning } | 404",
   "responses": [
    {
     "status": 201,
     "text": "{ id }"
    },
    {
     "status": 400,
     "text": "no name, no or unknown platform, or a payload the platform does not support. PUT: 200 { ok: true, version }"
    },
    {
     "status": 400,
     "text": ""
    },
    {
     "status": 404,
     "text": ""
    },
    {
     "status": 409,
     "text": "while the profile is rolling out in stages. DELETE: 200 { ok: true, mdmRemoval, warning }"
    },
    {
     "status": 404,
     "text": ""
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "no name, no or unknown platform, or a payload the platform does not support. PUT: 200 { ok: true, version }"
    },
    {
     "status": 400,
     "text": ""
    },
    {
     "status": 404,
     "text": ""
    },
    {
     "status": 409,
     "text": "while the profile is rolling out in stages. DELETE: 200 { ok: true, mdmRemoval, warning }"
    },
    {
     "status": 404,
     "text": ""
    }
   ],
   "guard": "Repeat: creates a second profile; profile names are not required to be unique"
  },
  {
   "id": "PUT /user-management/devices/profiles/:id",
   "group": "device-management",
   "method": "PUT",
   "path": "/user-management/devices/profiles/:id",
   "purpose": "Change a configuration profile.",
   "auth": {
    "text": "Session + manage_devices",
    "kind": "session",
    "permissions": [
     "manage_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "A DeviceProfileUpdate: the fields of the create call, all optional; platform cannot be changed. Add rollout: { rings, autoPromote, promoteAfterHours } to save the change and start a staged rollout of it in one call",
   "response": "200 { ok: true, version }, plus rollout and mdm when rollout was sent | 400, including a rollout asked for when nothing changed on a profile that is already on | 404 | 409 while the profile is rolling out in stages",
   "responses": [
    {
     "status": 200,
     "text": "{ ok: true, version }, plus rollout and mdm when rollout was sent"
    },
    {
     "status": 400,
     "text": ", including a rollout asked for when nothing changed on a profile that is already on"
    },
    {
     "status": 404,
     "text": ""
    },
    {
     "status": 409,
     "text": "while the profile is rolling out in stages"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": ", including a rollout asked for when nothing changed on a profile that is already on"
    },
    {
     "status": 404,
     "text": ""
    },
    {
     "status": 409,
     "text": "while the profile is rolling out in stages"
    }
   ],
   "guard": "Repeat: safe; a call that changes nothing keeps the version number (each call is audited)"
  },
  {
   "id": "DELETE /user-management/devices/profiles/:id",
   "group": "device-management",
   "method": "DELETE",
   "path": "/user-management/devices/profiles/:id",
   "purpose": "Delete a configuration profile.",
   "auth": {
    "text": "Session + manage_devices",
    "kind": "session",
    "permissions": [
     "manage_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { ok: true, mdmRemoval, warning } | 404",
   "responses": [
    {
     "status": 200,
     "text": "{ ok: true, mdmRemoval, warning }"
    },
    {
     "status": 404,
     "text": ""
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": ""
    }
   ],
   "guard": "also ends a staged rollout of the profile that is still in progress. Repeat: 404"
  },
  {
   "id": "POST /user-management/devices/profiles/:id/deploy",
   "group": "device-management",
   "method": "POST",
   "path": "/user-management/devices/profiles/:id/deploy",
   "purpose": "Deploy a profile (several at /devices/profiles/bulk-deploy). No body.",
   "auth": {
    "text": "Session + manage_devices",
    "kind": "session",
    "permissions": [
     "manage_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { deployed, devicesTargeted, inScope, agentPending, enforcementOn, notPushable, heldByRollout, note } | 400 when the profile is disabled, or it has only MDM payloads and MDM is not connected | 404",
   "responses": [
    {
     "status": 200,
     "text": "{ deployed, devicesTargeted, inScope, agentPending, enforcementOn, notPushable, heldByRollout, note }"
    },
    {
     "status": 400,
     "text": "when the profile is disabled, or it has only MDM payloads and MDM is not connected"
    },
    {
     "status": 404,
     "text": ""
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "when the profile is disabled, or it has only MDM payloads and MDM is not connected"
    },
    {
     "status": 404,
     "text": ""
    }
   ],
   "guard": null
  },
  {
   "id": "POST /user-management/devices/profiles/bulk-deploy",
   "group": "device-management",
   "method": "POST",
   "path": "/user-management/devices/profiles/bulk-deploy",
   "purpose": "Deploy several profiles in one call.",
   "auth": {
    "text": "Session + manage_devices",
    "kind": "session",
    "permissions": [
     "manage_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "A ProfileIdsRequest: { ids } — 1 to 100 profile ids; a repeated id is deployed once",
   "response": "200 { succeeded: [{ id, note }], failed: [{ id, error }] } | 400 'Select at least one profile.' / 'Too many profiles in one action (max 100).'",
   "responses": [
    {
     "status": 200,
     "text": "{ succeeded: [{ id, note }], failed: [{ id, error }] }"
    },
    {
     "status": 400,
     "text": "'Select at least one profile.' / 'Too many profiles in one action (max 100).'"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "'Select at least one profile.' / 'Too many profiles in one action (max 100).'"
    }
   ],
   "guard": "each profile is tried on its own, so read failed[] even on a 200"
  },
  {
   "id": "POST /user-management/devices/profiles/:id/rollout",
   "group": "device-management",
   "method": "POST",
   "path": "/user-management/devices/profiles/:id/rollout",
   "purpose": "Start a phased rollout of a profile; then /rollout/promote, /pause, /resume or /rollback. A second start answers 409 while a rollout is in progress.",
   "auth": {
    "text": "Session + manage_devices",
    "kind": "session",
    "permissions": [
     "manage_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "A ProfileRolloutStart: { rings (2 to 5; each is { type: 'groups', groupIds } or { type: 'percent', percent } with 1–99 and each share larger than the one before, plus an optional label of up to 40 characters; the last ring must be { type: 'all' }), autoPromote (default true), promoteAfterHours (a whole number, 1–720, default 24) }",
   "response": "200 { rollout: ProfileRollout, mdm } | 400 invalid rings or hours | 404 unknown profile | 409 when the profile is already applied to its whole scope, or is already rolling out. Pause, resume: 200 { rollout }. Promote: 200 { rollout, mdm }. Rollback: 200 { rollout, version, mdm }. Each of the four: 404 no rollout in progress | 409 the rollout has ended",
   "responses": [
    {
     "status": 200,
     "text": "{ rollout: ProfileRollout, mdm }"
    },
    {
     "status": 400,
     "text": "invalid rings or hours"
    },
    {
     "status": 404,
     "text": "unknown profile"
    },
    {
     "status": 409,
     "text": "when the profile is already applied to its whole scope, or is already rolling out. Pause, resume: 200 { rollout }. Promote: 200 { rollout, mdm }. Rollback: 200 { rollout, version, mdm }. Each of the four: 404 no rollout in progress"
    },
    {
     "status": 409,
     "text": "the rollout has ended"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "invalid rings or hours"
    },
    {
     "status": 404,
     "text": "unknown profile"
    },
    {
     "status": 409,
     "text": "when the profile is already applied to its whole scope, or is already rolling out. Pause, resume: 200 { rollout }. Promote: 200 { rollout, mdm }. Rollback: 200 { rollout, version, mdm }. Each of the four: 404 no rollout in progress"
    },
    {
     "status": 409,
     "text": "the rollout has ended"
    }
   ],
   "guard": "Repeat: 409; starting the rollout turns the profile on, and a profile that is on cannot be started this way again"
  },
  {
   "id": "GET /user-management/devices/profiles/:id/rollout",
   "group": "device-management",
   "method": "GET",
   "path": "/user-management/devices/profiles/:id/rollout",
   "purpose": "The current (or most recent) phased rollout of a profile, with per-ring device counts.",
   "auth": {
    "text": "Session + view_devices",
    "kind": "session",
    "permissions": [
     "view_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { rollout: ProfileRollout, rings, statusTracked, profile } — ProfileRollout is typed under Response Objects; rings carries device counts per ring. { rollout: null } alone when the profile has never been rolled out in stages | 400 'invalid profile id' | 404 unknown profile",
   "responses": [
    {
     "status": 200,
     "text": "{ rollout: ProfileRollout, rings, statusTracked, profile } — ProfileRollout is typed under Response Objects; rings carries device counts per ring. { rollout: null } alone when the profile has never been rolled out in stages"
    },
    {
     "status": 400,
     "text": "'invalid profile id'"
    },
    {
     "status": 404,
     "text": "unknown profile"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "'invalid profile id'"
    },
    {
     "status": 404,
     "text": "unknown profile"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /user-management/devices/profiles/rollouts",
   "group": "device-management",
   "method": "GET",
   "path": "/user-management/devices/profiles/rollouts",
   "purpose": "The latest rollout of every profile, keyed by profile id.",
   "auth": {
    "text": "Session + view_devices",
    "kind": "session",
    "permissions": [
     "view_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { rollouts: { [profileId]: { id, state, currentRing, rings, toVersion, holdReason, label, … } } }",
   "responses": [
    {
     "status": 200,
     "text": "{ rollouts: { [profileId]: { id, state, currentRing, rings, toVersion, holdReason, label, … } } }"
    }
   ],
   "errors": [],
   "guard": "one object, not paged; built from the 500 most recent rollout records"
  },
  {
   "id": "PUT /user-management/devices/profiles/:id/rollout",
   "group": "device-management",
   "method": "PUT",
   "path": "/user-management/devices/profiles/:id/rollout",
   "purpose": "Change a running rollout's automatic promotion. Audited as DEVICE_PROFILE_ROLLOUT_UPDATED.",
   "auth": {
    "text": "Session + manage_devices",
    "kind": "session",
    "permissions": [
     "manage_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "A ProfileRolloutSettings: { autoPromote (boolean), promoteAfterHours (whole number, 1–720) } — either or both",
   "response": "200 { rollout: ProfileRollout } | 400 'Promote after 1–720 hours.' | 404 unknown profile, or no rollout in progress",
   "responses": [
    {
     "status": 200,
     "text": "{ rollout: ProfileRollout }"
    },
    {
     "status": 400,
     "text": "'Promote after 1–720 hours.'"
    },
    {
     "status": 404,
     "text": "unknown profile, or no rollout in progress"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "'Promote after 1–720 hours.'"
    },
    {
     "status": 404,
     "text": "unknown profile, or no rollout in progress"
    }
   ],
   "guard": "Repeat: safe; a replace-style write (each call is audited)"
  },
  {
   "id": "POST /user-management/devices/profiles/:id/rollout/promote",
   "group": "device-management",
   "method": "POST",
   "path": "/user-management/devices/profiles/:id/rollout/promote",
   "purpose": "Move a rollout on to its next ring now.",
   "auth": {
    "text": "Session + manage_devices",
    "kind": "session",
    "permissions": [
     "manage_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { rollout: ProfileRollout, mdm } | 404 unknown profile, or no rollout in progress | 409 the rollout has ended",
   "responses": [
    {
     "status": 200,
     "text": "{ rollout: ProfileRollout, mdm }"
    },
    {
     "status": 404,
     "text": "unknown profile, or no rollout in progress"
    },
    {
     "status": 409,
     "text": "the rollout has ended"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "unknown profile, or no rollout in progress"
    },
    {
     "status": 409,
     "text": "the rollout has ended"
    }
   ],
   "guard": "Repeat: not safe; each call moves the rollout on one more ring. Entering the last ring completes it, and a call after that answers 404"
  },
  {
   "id": "POST /user-management/devices/profiles/:id/rollout/pause",
   "group": "device-management",
   "method": "POST",
   "path": "/user-management/devices/profiles/:id/rollout/pause",
   "purpose": "Pause a rollout.",
   "auth": {
    "text": "Session + manage_devices",
    "kind": "session",
    "permissions": [
     "manage_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { rollout: ProfileRollout } | 404 unknown profile, or no rollout in progress | 409 the rollout has ended",
   "responses": [
    {
     "status": 200,
     "text": "{ rollout: ProfileRollout }"
    },
    {
     "status": 404,
     "text": "unknown profile, or no rollout in progress"
    },
    {
     "status": 409,
     "text": "the rollout has ended"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "unknown profile, or no rollout in progress"
    },
    {
     "status": 409,
     "text": "the rollout has ended"
    }
   ],
   "guard": "Repeat: safe; a paused rollout stays paused (each call is audited)"
  },
  {
   "id": "POST /user-management/devices/profiles/:id/rollout/resume",
   "group": "device-management",
   "method": "POST",
   "path": "/user-management/devices/profiles/:id/rollout/resume",
   "purpose": "Resume a paused rollout.",
   "auth": {
    "text": "Session + manage_devices",
    "kind": "session",
    "permissions": [
     "manage_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { rollout: ProfileRollout } | 404 unknown profile, or no rollout in progress | 409 the rollout has ended",
   "responses": [
    {
     "status": 200,
     "text": "{ rollout: ProfileRollout }"
    },
    {
     "status": 404,
     "text": "unknown profile, or no rollout in progress"
    },
    {
     "status": 409,
     "text": "the rollout has ended"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "unknown profile, or no rollout in progress"
    },
    {
     "status": 409,
     "text": "the rollout has ended"
    }
   ],
   "guard": "Repeat: the rollout stays running, but each call restarts the wait before automatic promotion"
  },
  {
   "id": "POST /user-management/devices/profiles/:id/rollout/rollback",
   "group": "device-management",
   "method": "POST",
   "path": "/user-management/devices/profiles/:id/rollout/rollback",
   "purpose": "Roll a rollout back to the version the profile had before it.",
   "auth": {
    "text": "Session + manage_devices",
    "kind": "session",
    "permissions": [
     "manage_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { rollout: ProfileRollout, version, mdm } | 404 unknown profile, or no rollout in progress | 409 the rollout has ended",
   "responses": [
    {
     "status": 200,
     "text": "{ rollout: ProfileRollout, version, mdm }"
    },
    {
     "status": 404,
     "text": "unknown profile, or no rollout in progress"
    },
    {
     "status": 409,
     "text": "the rollout has ended"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "unknown profile, or no rollout in progress"
    },
    {
     "status": 409,
     "text": "the rollout has ended"
    }
   ],
   "guard": "Repeat: 404; the rollout has ended, so none is in progress"
  },
  {
   "id": "GET /user-management/devices/mdm/status",
   "group": "device-management",
   "method": "GET",
   "path": "/user-management/devices/mdm/status",
   "purpose": "Apple MDM connection status (preview). Enrolled Macs at /devices/mdm/devices.",
   "auth": {
    "text": "Session + view_devices",
    "kind": "session",
    "permissions": [
     "view_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 an MdmStatus (typed under Response Objects): { configured: false }, or { configured: true, connected:boolean, tenant, deviceCount, enrollmentUrl, enrollmentReady, hasCert, certNotAfter }",
   "responses": [
    {
     "status": 200,
     "text": "an MdmStatus (typed under Response Objects): { configured: false }, or { configured: true, connected:boolean, tenant, deviceCount, enrollmentUrl, enrollmentReady, hasCert, certNotAfter }"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /user-management/devices/mdm/enroll-code",
   "group": "device-management",
   "method": "POST",
   "path": "/user-management/devices/mdm/enroll-code",
   "purpose": "Create an MDM enrolment code for a Mac. Audited as DEVICE_MDM_ENROLL_CODE.",
   "auth": {
    "text": "Session + manage_devices",
    "kind": "session",
    "permissions": [
     "manage_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "An MdmEnrollCodeCreate: { reusable } — true for a reusable code; otherwise single-use",
   "response": "the MDM control plane's own status and body | 400 when Apple MDM is not configured | 424 when the MDM control plane did not answer",
   "responses": [
    {
     "status": null,
     "text": "the MDM control plane's own status and body"
    },
    {
     "status": 400,
     "text": "when Apple MDM is not configured"
    },
    {
     "status": 424,
     "text": "when the MDM control plane did not answer"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "when Apple MDM is not configured"
    },
    {
     "status": 424,
     "text": "when the MDM control plane did not answer"
    }
   ],
   "guard": "Repeat: asks the MDM control plane for another code"
  },
  {
   "id": "POST /user-management/devices/mdm/pushcert",
   "group": "device-management",
   "method": "POST",
   "path": "/user-management/devices/mdm/pushcert",
   "purpose": "Upload your Apple push (APNs) certificate (start from GET /devices/mdm/pushcert/request). Audited as DEVICE_MDM_PUSHCERT_UPLOAD.",
   "auth": {
    "text": "Session + manage_devices",
    "kind": "session",
    "permissions": [
     "manage_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "An MdmPushCertUpload: { certPem, keyPem } or { p12, passphrase }",
   "response": "the MDM control plane's own status and body | 400 when the certificate is missing or the .p12 cannot be opened | 413 over 200,000 characters | 400 when Apple MDM is not configured | 424 when the MDM control plane did not answer — after a 424 reload the status before retrying, because the certificate may or may not have been installed",
   "responses": [
    {
     "status": null,
     "text": "the MDM control plane's own status and body"
    },
    {
     "status": 400,
     "text": "when the certificate is missing or the .p12 cannot be opened"
    },
    {
     "status": 413,
     "text": "over 200,000 characters"
    },
    {
     "status": 400,
     "text": "when Apple MDM is not configured"
    },
    {
     "status": 424,
     "text": "when the MDM control plane did not answer — after a 424 reload the status before retrying, because the certificate may or may not have been installed"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "when the certificate is missing or the .p12 cannot be opened"
    },
    {
     "status": 413,
     "text": "over 200,000 characters"
    },
    {
     "status": 400,
     "text": "when Apple MDM is not configured"
    },
    {
     "status": 424,
     "text": "when the MDM control plane did not answer — after a 424 reload the status before retrying, because the certificate may or may not have been installed"
    }
   ],
   "guard": "Repeat: sends the certificate to the MDM control plane again; after a 424 reload the status first"
  },
  {
   "id": "POST /user-management/devices/mdm/:udid/lock",
   "group": "device-management",
   "method": "POST",
   "path": "/user-management/devices/mdm/:udid/lock",
   "purpose": "Remote-lock a Mac through MDM. Audited as DEVICE_MDM_LOCK.",
   "auth": {
    "text": "Session + manage_devices",
    "kind": "session",
    "permissions": [
     "manage_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "udid",
     "type": "string",
     "basis": "unknown"
    }
   ],
   "paging": [],
   "query": [],
   "request": "An MdmLockRequest: { message } — optional",
   "response": "the MDM control plane's own status and body | 400 when Apple MDM is not configured | 424 when the MDM control plane did not answer, or answered but could not queue the lock",
   "responses": [
    {
     "status": null,
     "text": "the MDM control plane's own status and body"
    },
    {
     "status": 400,
     "text": "when Apple MDM is not configured"
    },
    {
     "status": 424,
     "text": "when the MDM control plane did not answer, or answered but could not queue the lock"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "when Apple MDM is not configured"
    },
    {
     "status": 424,
     "text": "when the MDM control plane did not answer, or answered but could not queue the lock"
    }
   ],
   "guard": "an unanswered request may or may not have been queued: check the device before sending it again. Repeat: each call sends the MDM control plane another lock request"
  },
  {
   "id": "POST /user-management/devices/mdm/:udid/wipe",
   "group": "device-management",
   "method": "POST",
   "path": "/user-management/devices/mdm/:udid/wipe",
   "purpose": "Remote-wipe a Mac through MDM. Audited as DEVICE_MDM_WIPE at critical level, including when the call fails.",
   "auth": {
    "text": "Session + manage_device_destructive + sudo",
    "kind": "session",
    "permissions": [
     "manage_device_destructive"
    ],
    "stepUp": "required",
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "udid",
     "type": "string",
     "basis": "unknown"
    }
   ],
   "paging": [],
   "query": [],
   "request": "A DestructiveReason: { reason } — required, at least 3 characters, cut to 500",
   "response": "the MDM control plane's own status and body | 400 no reason | 400 when Apple MDM is not configured | 424 when the control plane answered but could not queue the wipe | 502 when it did not answer | 400 { error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo). The service answers 401 and the gateway returns it as 400 on this route, so match on the error text, not the status",
   "responses": [
    {
     "status": null,
     "text": "the MDM control plane's own status and body"
    },
    {
     "status": 400,
     "text": "no reason"
    },
    {
     "status": 400,
     "text": "when Apple MDM is not configured"
    },
    {
     "status": 424,
     "text": "when the control plane answered but could not queue the wipe"
    },
    {
     "status": 502,
     "text": "when it did not answer"
    },
    {
     "status": 400,
     "text": "{ error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo). The service answers 401 and the gateway returns it as 400 on this route, so match on the error text, not the status"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "no reason"
    },
    {
     "status": 400,
     "text": "when Apple MDM is not configured"
    },
    {
     "status": 424,
     "text": "when the control plane answered but could not queue the wipe"
    },
    {
     "status": 502,
     "text": "when it did not answer"
    },
    {
     "status": 400,
     "text": "{ error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo). The service answers 401 and the gateway returns it as 400 on this route, so match on the error text, not the status"
    }
   ],
   "guard": "an unanswered wipe may or may not have been queued: check the device before sending it again. Repeat: each call sends the MDM control plane another wipe request"
  },
  {
   "id": "GET /user-management/devices/software",
   "group": "device-management",
   "method": "GET",
   "path": "/user-management/devices/software",
   "purpose": "Software inventory across the fleet (CSV at /devices/software/export.csv). Query: ?q (name search, at most 200 characters), ?page (default 1), ?pageSize (default 50, 1–500). Most-installed first, then by name.",
   "auth": {
    "text": "Session + view_devices",
    "kind": "session",
    "permissions": [
     "view_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 a SoftwarePage (typed under Response Objects) { items: [{ name, publisher, deviceCount, platforms, versions: [{ version, count }] }], total, page, pageSize }",
   "responses": [
    {
     "status": 200,
     "text": "a SoftwarePage (typed under Response Objects) { items: [{ name, publisher, deviceCount, platforms, versions: [{ version, count }] }], total, page, pageSize }"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /user-management/devices/:id/recovery-keys",
   "group": "device-management",
   "method": "GET",
   "path": "/user-management/devices/:id/recovery-keys",
   "purpose": "Which recovery keys are escrowed for a device — never the key itself. FileVault recovery-key escrow is part of the preview, not yet validated end-to-end.",
   "auth": {
    "text": "Session + view_devices",
    "kind": "session",
    "permissions": [
     "view_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { keys: [{ keyType, volumeId, escrowedAt }] }",
   "responses": [
    {
     "status": 200,
     "text": "{ keys: [{ keyType, volumeId, escrowedAt }] }"
    }
   ],
   "errors": [],
   "guard": "every escrowed key record of the device in one reply (no paging)"
  },
  {
   "id": "POST /user-management/devices/:id/recovery-key/:type/reveal",
   "group": "device-management",
   "method": "POST",
   "path": "/user-management/devices/:id/recovery-key/:type/reveal",
   "purpose": "Reveal an escrowed recovery key. Audited as DEVICE_RECOVERY_KEY_REVEALED at critical level, with the reason.",
   "auth": {
    "text": "Session + manage_device_destructive + sudo",
    "kind": "session",
    "permissions": [
     "manage_device_destructive"
    ],
    "stepUp": "required",
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    },
    {
     "name": "type",
     "type": "string",
     "basis": "unknown"
    }
   ],
   "paging": [],
   "query": [],
   "request": "A DestructiveReason: { reason } — required, at least 3 characters",
   "response": "200 { keyType, recoveryKey, volumeId, rotationScheduled } | 400 no reason | 404 no key escrowed for this device | 500 when the stored key cannot be decrypted | 400 { error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo). The service answers 401 and the gateway returns it as 400 on this route, so match on the error text, not the status",
   "responses": [
    {
     "status": 200,
     "text": "{ keyType, recoveryKey, volumeId, rotationScheduled }"
    },
    {
     "status": 400,
     "text": "no reason"
    },
    {
     "status": 404,
     "text": "no key escrowed for this device"
    },
    {
     "status": 500,
     "text": "when the stored key cannot be decrypted"
    },
    {
     "status": 400,
     "text": "{ error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo). The service answers 401 and the gateway returns it as 400 on this route, so match on the error text, not the status"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "no reason"
    },
    {
     "status": 404,
     "text": "no key escrowed for this device"
    },
    {
     "status": 500,
     "text": "when the stored key cannot be decrypted"
    },
    {
     "status": 400,
     "text": "{ error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo). The service answers 401 and the gateway returns it as 400 on this route, so match on the error text, not the status"
    }
   ],
   "guard": "Repeat: reveals the key again; every reveal is audited at critical level"
  },
  {
   "id": "POST /user-management/devices/:id/filevault/rotate",
   "group": "device-management",
   "method": "POST",
   "path": "/user-management/devices/:id/filevault/rotate",
   "purpose": "Ask a Mac to replace its FileVault recovery key at the next login (preview). A second request while one is waiting answers 409.",
   "auth": {
    "text": "Session + manage_device_destructive + sudo",
    "kind": "session",
    "permissions": [
     "manage_device_destructive"
    ],
    "stepUp": "required",
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "A DestructiveReason: { reason } — required, at least 3 characters, cut to 500",
   "response": "200 { requestId, reason: 'manual', requestedAt } | 400 | 404 | 409 when a new key is already requested, or this Mac cannot be re-keyed now | 400 { error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo). The service answers 401 and the gateway returns it as 400 on this route, so match on the error text, not the status",
   "responses": [
    {
     "status": 200,
     "text": "{ requestId, reason: 'manual', requestedAt }"
    },
    {
     "status": 400,
     "text": ""
    },
    {
     "status": 404,
     "text": ""
    },
    {
     "status": 409,
     "text": "when a new key is already requested, or this Mac cannot be re-keyed now"
    },
    {
     "status": 400,
     "text": "{ error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo). The service answers 401 and the gateway returns it as 400 on this route, so match on the error text, not the status"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": ""
    },
    {
     "status": 404,
     "text": ""
    },
    {
     "status": 409,
     "text": "when a new key is already requested, or this Mac cannot be re-keyed now"
    },
    {
     "status": 400,
     "text": "{ error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo). The service answers 401 and the gateway returns it as 400 on this route, so match on the error text, not the status"
    }
   ],
   "guard": "Repeat: 409 while the new key asked for by the first call is still outstanding"
  },
  {
   "id": "GET /user-management/devices/agent-rollouts",
   "group": "device-management",
   "method": "GET",
   "path": "/user-management/devices/agent-rollouts",
   "purpose": "Agent version rollouts in progress. Query: ?platform = macos (default), linux or windows. Newest first, not paged.",
   "auth": {
    "text": "Session + view_devices",
    "kind": "session",
    "permissions": [
     "view_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { platform, rollouts: AgentRollout[] } (AgentRollout is typed under Response Objects: { id, platform, version, enabled, label, scope, scopeLabel, deviceCount, onTarget, createdBy, createdAt })",
   "responses": [
    {
     "status": 200,
     "text": "{ platform, rollouts: AgentRollout[] } (AgentRollout is typed under Response Objects: { id, platform, version, enabled, label, scope, scopeLabel, deviceCount, onTarget, createdBy, createdAt })"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /user-management/devices/agent-rollouts/:id/hold",
   "group": "device-management",
   "method": "POST",
   "path": "/user-management/devices/agent-rollouts/:id/hold",
   "purpose": "Hold an agent rollout (DELETE /devices/agent-rollouts/:id cancels it). The body sets the state, so the call is safe to repeat.",
   "auth": {
    "text": "Session + manage_devices",
    "kind": "session",
    "permissions": [
     "manage_devices"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "An AgentRolloutHold: { enabled } — false (or left out) holds the rollout, true resumes it",
   "response": "200 { ok: true, id, enabled } | 404. DELETE: 200 { ok: true } | 404",
   "responses": [
    {
     "status": 200,
     "text": "{ ok: true, id, enabled }"
    },
    {
     "status": 404,
     "text": ". DELETE: 200 { ok: true }"
    },
    {
     "status": 404,
     "text": ""
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": ". DELETE: 200 { ok: true }"
    },
    {
     "status": 404,
     "text": ""
    }
   ],
   "guard": "Repeat: safe; a replace-style write (each call is audited)"
  },
  {
   "id": "GET /identity-admin/api-tokens",
   "group": "api-tokens",
   "method": "GET",
   "path": "/identity-admin/api-tokens",
   "purpose": "List your own tokens (metadata only). No query parameters: the whole list comes back, newest first.",
   "auth": {
    "text": "Session + create_api_tokens",
    "kind": "session",
    "permissions": [
     "create_api_tokens"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 ApiToken[], each { id, userId, owner, name, tokenPrefix, scopes[], createdAt, expiresAt, lastUsedAt, lastUsedIp, revokedAt, revokedBy, revokeReason, status } (field types under Response Objects as ApiToken) — status is active, expired, revoked or invalidated (the owner signed out everywhere or changed password after the token was made) | 403 code SESSION_REQUIRED when the caller is an API token",
   "responses": [
    {
     "status": 200,
     "text": "ApiToken[], each { id, userId, owner, name, tokenPrefix, scopes[], createdAt, expiresAt, lastUsedAt, lastUsedIp, revokedAt, revokedBy, revokeReason, status } (field types under Response Objects as ApiToken) — status is active, expired, revoked or invalidated (the owner signed out everywhere or changed password after the token was made)"
    },
    {
     "status": 403,
     "text": "code SESSION_REQUIRED when the caller is an API token"
    }
   ],
   "errors": [
    {
     "status": 403,
     "text": "code SESSION_REQUIRED when the caller is an API token"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /identity-admin/api-tokens/scopes",
   "group": "api-tokens",
   "method": "GET",
   "path": "/identity-admin/api-tokens/scopes",
   "purpose": "The permissions you may give a token: your own current permissions, minus create_api_tokens.",
   "auth": {
    "text": "Session + create_api_tokens",
    "kind": "session",
    "permissions": [
     "create_api_tokens"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { scopes: [{ name:string, module, description }] sorted by name, defaultExpiryDays:integer (90), maxExpiryDays:integer (365) } | 403 code SESSION_REQUIRED when the caller is an API token",
   "responses": [
    {
     "status": 200,
     "text": "{ scopes: [{ name:string, module, description }] sorted by name, defaultExpiryDays:integer (90), maxExpiryDays:integer (365) }"
    },
    {
     "status": 403,
     "text": "code SESSION_REQUIRED when the caller is an API token"
    }
   ],
   "errors": [
    {
     "status": 403,
     "text": "code SESSION_REQUIRED when the caller is an API token"
    }
   ],
   "guard": null
  },
  {
   "id": "POST /identity-admin/api-tokens",
   "group": "api-tokens",
   "method": "POST",
   "path": "/identity-admin/api-tokens",
   "purpose": "Create a token. The raw value is returned once and stored only as a hash. Repeating the call creates another token each time; nothing deduplicates by name.",
   "auth": {
    "text": "Session + create_api_tokens + sudo",
    "kind": "session",
    "permissions": [
     "create_api_tokens"
    ],
    "stepUp": "required",
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "An ApiTokenCreate: { name (required, cut to 120 characters), scopes (required, a non-empty array of permission names), expiresInDays (optional whole number 1–365, default 90) }",
   "response": "201 an ApiTokenCreated: the token record (ApiToken) plus token:string (the raw value, shown only here: zx_ followed by 43 characters from A-Z, a-z, 0-9, - and _, 46 characters in all) | 400 no name, expiry out of range or no scopes | 400 code SCOPE_EXCEEDS_OWNER when a scope is a permission you do not hold, or is create_api_tokens (the one permission no token can carry: 'cannot be granted to a token') | 400 { error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo). The service answers 401 and the gateway returns it as 400 on this route, so match on the error text, not the status | 403 code MFA_REQUIRED_FOR_API_TOKENS; 403 when the account is not active | 409 when you already have 20 active tokens | 403 code SESSION_REQUIRED when the caller is an API token",
   "responses": [
    {
     "status": 201,
     "text": "an ApiTokenCreated: the token record (ApiToken) plus token:string (the raw value, shown only here: zx_ followed by 43 characters from A-Z, a-z, 0-9, - and _, 46 characters in all)"
    },
    {
     "status": 400,
     "text": "no name, expiry out of range or no scopes"
    },
    {
     "status": 400,
     "text": "code SCOPE_EXCEEDS_OWNER when a scope is a permission you do not hold, or is create_api_tokens (the one permission no token can carry: 'cannot be granted to a token')"
    },
    {
     "status": 400,
     "text": "{ error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo). The service answers 401 and the gateway returns it as 400 on this route, so match on the error text, not the status"
    },
    {
     "status": 403,
     "text": "code MFA_REQUIRED_FOR_API_TOKENS; 403 when the account is not active"
    },
    {
     "status": 409,
     "text": "when you already have 20 active tokens"
    },
    {
     "status": 403,
     "text": "code SESSION_REQUIRED when the caller is an API token"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "no name, expiry out of range or no scopes"
    },
    {
     "status": 400,
     "text": "code SCOPE_EXCEEDS_OWNER when a scope is a permission you do not hold, or is create_api_tokens (the one permission no token can carry: 'cannot be granted to a token')"
    },
    {
     "status": 400,
     "text": "{ error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo). The service answers 401 and the gateway returns it as 400 on this route, so match on the error text, not the status"
    },
    {
     "status": 403,
     "text": "code MFA_REQUIRED_FOR_API_TOKENS; 403 when the account is not active"
    },
    {
     "status": 409,
     "text": "when you already have 20 active tokens"
    },
    {
     "status": 403,
     "text": "code SESSION_REQUIRED when the caller is an API token"
    }
   ],
   "guard": "scopes must be a subset of the creator's own current permissions, re-derived from the database; on every later call the token carries only the scopes its owner still holds. A refused creation is audited as API_TOKEN_CREATE_DENIED. Repeat: creates a second token, even under the same name; 409 only once you hold 20 active tokens"
  },
  {
   "id": "POST /identity-admin/api-tokens/:id/revoke",
   "group": "api-tokens",
   "method": "POST",
   "path": "/identity-admin/api-tokens/:id/revoke",
   "purpose": "Revoke one of your tokens. No body. Safe to repeat: a token that is already revoked returns the same record.",
   "auth": {
    "text": "Session + create_api_tokens",
    "kind": "session",
    "permissions": [
     "create_api_tokens"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 the ApiToken record | 404 for an unknown id or a token that is not yours | 403 code SESSION_REQUIRED when the caller is an API token",
   "responses": [
    {
     "status": 200,
     "text": "the ApiToken record"
    },
    {
     "status": 404,
     "text": "for an unknown id or a token that is not yours"
    },
    {
     "status": 403,
     "text": "code SESSION_REQUIRED when the caller is an API token"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "for an unknown id or a token that is not yours"
    },
    {
     "status": 403,
     "text": "code SESSION_REQUIRED when the caller is an API token"
    }
   ],
   "guard": "Repeat: safe; a token that is already revoked answers 200 with its record unchanged"
  },
  {
   "id": "GET /identity-admin/api-tokens/all",
   "group": "api-tokens",
   "method": "GET",
   "path": "/identity-admin/api-tokens/all",
   "purpose": "Every user's tokens: owner, prefix, scopes, last used (never the token or its hash). No query parameters; newest first, capped at the newest 2000.",
   "auth": {
    "text": "Session + manage_security",
    "kind": "session",
    "permissions": [
     "manage_security"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 ApiToken[]: the same record, with owner set to the owner's e-mail | 403 code SESSION_REQUIRED when the caller is an API token",
   "responses": [
    {
     "status": 200,
     "text": "ApiToken[]: the same record, with owner set to the owner's e-mail"
    },
    {
     "status": 403,
     "text": "code SESSION_REQUIRED when the caller is an API token"
    }
   ],
   "errors": [
    {
     "status": 403,
     "text": "code SESSION_REQUIRED when the caller is an API token"
    }
   ],
   "guard": null
  },
  {
   "id": "POST /identity-admin/api-tokens/:id/admin-revoke",
   "group": "api-tokens",
   "method": "POST",
   "path": "/identity-admin/api-tokens/:id/admin-revoke",
   "purpose": "Revoke any user's token. Safe to repeat, as for the owner's revoke.",
   "auth": {
    "text": "Session + manage_security",
    "kind": "session",
    "permissions": [
     "manage_security"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "An ApiTokenAdminRevoke: { reason } — optional, cut to 200 characters; stored as revokeReason",
   "response": "200 the ApiToken record | 404 unknown id | 403 code SESSION_REQUIRED when the caller is an API token",
   "responses": [
    {
     "status": 200,
     "text": "the ApiToken record"
    },
    {
     "status": 404,
     "text": "unknown id"
    },
    {
     "status": 403,
     "text": "code SESSION_REQUIRED when the caller is an API token"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "unknown id"
    },
    {
     "status": 403,
     "text": "code SESSION_REQUIRED when the caller is an API token"
    }
   ],
   "guard": "Repeat: safe; a token that is already revoked answers 200 with its record unchanged, and the first reason is kept"
  },
  {
   "id": "GET /auth/sso/status",
   "group": "sso",
   "method": "GET",
   "path": "/auth/sso/status",
   "purpose": "Whether SSO is enabled, for the sign-in screen.",
   "auth": {
    "text": "Public",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 an SsoStatus { enabled:boolean, protocol: 'oidc' | 'saml', preset?:string (only while enabled: okta, entra, google or other), smtpConfigured:boolean } — { enabled: false } when the identity service cannot be reached",
   "responses": [
    {
     "status": 200,
     "text": "an SsoStatus { enabled:boolean, protocol: 'oidc' | 'saml', preset?:string (only while enabled: okta, entra, google or other), smtpConfigured:boolean } — { enabled: false } when the identity service cannot be reached"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /auth/sso/login",
   "group": "sso",
   "method": "GET",
   "path": "/auth/sso/login",
   "purpose": "Start a sign-in with whichever protocol is configured (redirects to your identity provider). No parameters.",
   "auth": {
    "text": "Public (rate-limited)",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "302 to the identity provider, with a signed sso_flow cookie valid for 10 minutes | 302 to /login?sso_error=config when SSO is off or the provider's login address cannot be built",
   "responses": [
    {
     "status": 302,
     "text": "to the identity provider, with a signed sso_flow cookie valid for 10 minutes"
    },
    {
     "status": 302,
     "text": "to /login?sso_error=config when SSO is off or the provider's login address cannot be built"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /auth/sso/callback",
   "group": "sso",
   "method": "GET",
   "path": "/auth/sso/callback",
   "purpose": "OIDC redirect target that completes the sign-in.",
   "auth": {
    "text": "Public (rate-limited)",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query string, not a JSON body: ?code and ?state, both sent by the identity provider; state must match the one bound to the browser's sso_flow cookie",
   "response": "302 to / with the session cookie set | 302 to /login?sso=continue when MFA or terms acceptance is still owed | 302 to /login?sso_error=state (missing or mismatched flow cookie), no_account (no active user matches), failed or denied",
   "responses": [
    {
     "status": 302,
     "text": "to / with the session cookie set"
    },
    {
     "status": 302,
     "text": "to /login?sso=continue when MFA or terms acceptance is still owed"
    },
    {
     "status": 302,
     "text": "to /login?sso_error=state (missing or mismatched flow cookie), no_account (no active user matches), failed or denied"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /auth/sso/pending",
   "group": "sso",
   "method": "GET",
   "path": "/auth/sso/pending",
   "purpose": "The sign-in page calls this after an SSO sign-in was redirected to /login?sso=continue: it hands back the MFA or terms step still owed.",
   "auth": {
    "text": "Public (reads the httpOnly sso_pending cookie set by the SSO redirect)",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 an SsoPendingStep (typed under Response Objects): the fields of the step still owed, from { mfaRequired, mfaStatus, userId, mfaToken, mfaMethods, termsAcceptanceRequired, termsToken, termsVersion } | 404 { error:'No pending SSO sign-in.' } when the cookie is absent | 410 { error:'Your SSO sign-in expired. Please sign in again.' } when the pending sign-in is expired or already redeemed",
   "responses": [
    {
     "status": 200,
     "text": "an SsoPendingStep (typed under Response Objects): the fields of the step still owed, from { mfaRequired, mfaStatus, userId, mfaToken, mfaMethods, termsAcceptanceRequired, termsToken, termsVersion }"
    },
    {
     "status": 404,
     "text": "{ error:'No pending SSO sign-in.' } when the cookie is absent"
    },
    {
     "status": 410,
     "text": "{ error:'Your SSO sign-in expired. Please sign in again.' } when the pending sign-in is expired or already redeemed"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "{ error:'No pending SSO sign-in.' } when the cookie is absent"
    },
    {
     "status": 410,
     "text": "{ error:'Your SSO sign-in expired. Please sign in again.' } when the pending sign-in is expired or already redeemed"
    }
   ],
   "guard": "single use: the cookie is cleared on every call, whatever the outcome; the pending sign-in lives for 5 minutes"
  },
  {
   "id": "GET /auth/sso/saml/login",
   "group": "sso",
   "method": "GET",
   "path": "/auth/sso/saml/login",
   "purpose": "Start a SAML sign-in. The same handler as /auth/sso/login.",
   "auth": {
    "text": "Public (rate-limited)",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "as /auth/sso/login",
   "responses": [
    {
     "status": null,
     "text": "as /auth/sso/login"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /auth/sso/saml/acs",
   "group": "sso",
   "method": "POST",
   "path": "/auth/sso/saml/acs",
   "purpose": "SAML assertion consumer service — your identity provider posts the response here. RelayState is ignored.",
   "auth": {
    "text": "Public (rate-limited)",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "form-encoded SAMLResponse (required), at most 100 kB",
   "response": "302 to / with the session cookie set | 302 to /login?sso=continue when MFA or terms acceptance is still owed | 302 to /login?sso_error=state (missing or mismatched flow cookie), no_account (no active user matches), failed or denied",
   "responses": [
    {
     "status": 302,
     "text": "to / with the session cookie set"
    },
    {
     "status": 302,
     "text": "to /login?sso=continue when MFA or terms acceptance is still owed"
    },
    {
     "status": 302,
     "text": "to /login?sso_error=state (missing or mismatched flow cookie), no_account (no active user matches), failed or denied"
    }
   ],
   "errors": [],
   "guard": "the response must answer the request id bound to this browser's sso_flow cookie"
  },
  {
   "id": "GET /auth/sso/saml/metadata",
   "group": "sso",
   "method": "GET",
   "path": "/auth/sso/saml/metadata",
   "purpose": "Service-provider metadata to give your identity provider.",
   "auth": {
    "text": "Public (rate-limited)",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 application/samlmetadata+xml | 502 { error } when the metadata cannot be built",
   "responses": [
    {
     "status": 200,
     "text": "application/samlmetadata+xml"
    },
    {
     "status": 502,
     "text": "{ error } when the metadata cannot be built"
    }
   ],
   "errors": [
    {
     "status": 502,
     "text": "{ error } when the metadata cannot be built"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /identity-admin/sso/config",
   "group": "sso",
   "method": "GET",
   "path": "/identity-admin/sso/config",
   "purpose": "Read the SSO configuration (secrets masked).",
   "auth": {
    "text": "Session + manage_security",
    "kind": "session",
    "permissions": [
     "manage_security"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 an SsoConfig (typed under Response Objects) { enabled, protocol, preset, issuer, clientId, clientSecretSet, saml: { idpEntityId, idpSsoUrl, certs: [{ subject, notAfter, fingerprint256, expired }] }, jit: { enabled, allowedDomains[], defaultRoleId, defaultGroupId }, mfaAcrValues[] } | 403",
   "responses": [
    {
     "status": 200,
     "text": "an SsoConfig (typed under Response Objects) { enabled, protocol, preset, issuer, clientId, clientSecretSet, saml: { idpEntityId, idpSsoUrl, certs: [{ subject, notAfter, fingerprint256, expired }] }, jit: { enabled, allowedDomains[], defaultRoleId, defaultGroupId }, mfaAcrValues[] }"
    },
    {
     "status": 403,
     "text": ""
    }
   ],
   "errors": [
    {
     "status": 403,
     "text": ""
    }
   ],
   "guard": null
  },
  {
   "id": "POST /identity-admin/sso/config",
   "group": "sso",
   "method": "POST",
   "path": "/identity-admin/sso/config",
   "purpose": "Save the SSO configuration. A replace-style write: sending the same body again stores the same configuration. Audited as SSO_CONFIG_UPDATED.",
   "auth": {
    "text": "Session + manage_security + sudo",
    "kind": "session",
    "permissions": [
     "manage_security"
    ],
    "stepUp": "required",
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ enabled, protocol: 'oidc' | 'saml', preset: okta | entra | google | other, issuer (an https URL), clientId, clientSecret, saml: { idpEntityId, idpSsoUrl, idpCerts }, jit: { enabled, allowedDomains, defaultRoleId, defaultGroupId }, mfaAcrValues (up to 20 strings) } — a field left out keeps its stored value, except enabled, which is saved as false unless sent as true",
   "response": "200 the masked configuration, as the GET | 400 { error } with the reason: unknown protocol or preset, issuer not https, client ID or client secret missing when turning OIDC on, SAML settings that fail validation, an invalid JIT domain, or JIT turned on with no allowed domain | 400 { error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo). The service answers 401 and the gateway returns it as 400 on this route, so match on the error text, not the status",
   "responses": [
    {
     "status": 200,
     "text": "the masked configuration, as the GET"
    },
    {
     "status": 400,
     "text": "{ error } with the reason: unknown protocol or preset, issuer not https, client ID or client secret missing when turning OIDC on, SAML settings that fail validation, an invalid JIT domain, or JIT turned on with no allowed domain"
    },
    {
     "status": 400,
     "text": "{ error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo). The service answers 401 and the gateway returns it as 400 on this route, so match on the error text, not the status"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ error } with the reason: unknown protocol or preset, issuer not https, client ID or client secret missing when turning OIDC on, SAML settings that fail validation, an invalid JIT domain, or JIT turned on with no allowed domain"
    },
    {
     "status": 400,
     "text": "{ error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo). The service answers 401 and the gateway returns it as 400 on this route, so match on the error text, not the status"
    }
   ],
   "guard": null
  },
  {
   "id": "POST /identity-admin/sso/saml/parse-metadata",
   "group": "sso",
   "method": "POST",
   "path": "/identity-admin/sso/saml/parse-metadata",
   "purpose": "Parse identity-provider metadata into the form fields. Saves nothing.",
   "auth": {
    "text": "Session + manage_security",
    "kind": "session",
    "permissions": [
     "manage_security"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ metadataUrl } (an https address the server fetches) or { metadataXmlB64 } (the XML, base64-encoded, at most 700 kB of base64)",
   "response": "200 { idpEntityId, idpSsoUrl, idpCerts[], certs: [{ subject, notAfter, fingerprint256, expired }] } | 400 { error } when neither is sent, the XML cannot be parsed or it carries no signing certificate",
   "responses": [
    {
     "status": 200,
     "text": "{ idpEntityId, idpSsoUrl, idpCerts[], certs: [{ subject, notAfter, fingerprint256, expired }] }"
    },
    {
     "status": 400,
     "text": "{ error } when neither is sent, the XML cannot be parsed or it carries no signing certificate"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ error } when neither is sent, the XML cannot be parsed or it carries no signing certificate"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /scim/v2/ServiceProviderConfig",
   "group": "scim",
   "method": "GET",
   "path": "/scim/v2/ServiceProviderConfig",
   "purpose": "SCIM discovery document. /scim/v2/ResourceTypes and /scim/v2/Schemas are served too.",
   "auth": {
    "text": "SCIM bearer token",
    "kind": "other",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 | 401 SCIM error for a missing, malformed, unknown or revoked token | 404 SCIM error for any path outside the listed resources",
   "responses": [
    {
     "status": 200,
     "text": ""
    },
    {
     "status": 401,
     "text": "SCIM error for a missing, malformed, unknown or revoked token"
    },
    {
     "status": 404,
     "text": "SCIM error for any path outside the listed resources"
    }
   ],
   "errors": [
    {
     "status": 401,
     "text": "SCIM error for a missing, malformed, unknown or revoked token"
    },
    {
     "status": 404,
     "text": "SCIM error for any path outside the listed resources"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /scim/v2/Users",
   "group": "scim",
   "method": "GET",
   "path": "/scim/v2/Users",
   "purpose": "List or filter users. Query: ?filter on userName, externalId, emails.value or id; ?startIndex (1-based, default 1) and ?count (default 100, at most 200; 0 returns only the total); ordered by id ascending.",
   "auth": {
    "text": "SCIM bearer token",
    "kind": "other",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 a ScimListResponse { schemas, totalResults, startIndex, itemsPerPage, Resources } (typed under Response Objects; Resources holds ScimUser or ScimGroup objects) | 400 scimType invalidFilter for an attribute that cannot be filtered",
   "responses": [
    {
     "status": 200,
     "text": "a ScimListResponse { schemas, totalResults, startIndex, itemsPerPage, Resources } (typed under Response Objects; Resources holds ScimUser or ScimGroup objects)"
    },
    {
     "status": 400,
     "text": "scimType invalidFilter for an attribute that cannot be filtered"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "scimType invalidFilter for an attribute that cannot be filtered"
    }
   ],
   "guard": null
  },
  {
   "id": "POST /scim/v2/Users",
   "group": "scim",
   "method": "POST",
   "path": "/scim/v2/Users",
   "purpose": "Create a user. Repeating the same create answers 409; a user this connection deleted earlier is brought back by the POST instead of duplicated.",
   "auth": {
    "text": "SCIM bearer token",
    "kind": "other",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "a SCIM User resource (the attributes read are listed as ScimUserWrite under Response Objects; JSON, at most 1 MB); userName, or a primary e-mail, must be an e-mail address",
   "response": "201 the ScimUser, with a Location header | 400 scimType invalidValue or invalidSyntax | 409 scimType uniqueness when a live user already has that userName or externalId",
   "responses": [
    {
     "status": 201,
     "text": "the ScimUser, with a Location header"
    },
    {
     "status": 400,
     "text": "scimType invalidValue or invalidSyntax"
    },
    {
     "status": 409,
     "text": "scimType uniqueness when a live user already has that userName or externalId"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "scimType invalidValue or invalidSyntax"
    },
    {
     "status": 409,
     "text": "scimType uniqueness when a live user already has that userName or externalId"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /scim/v2/Users/:id",
   "group": "scim",
   "method": "GET",
   "path": "/scim/v2/Users/:id",
   "purpose": "Read one user. PUT replaces, PATCH updates, DELETE removes. PUT is applied as a merge: an attribute the request leaves out is not cleared. DELETE deactivates the account through the leaver path and marks it deleted; it is never a hard delete.",
   "auth": {
    "text": "SCIM bearer token",
    "kind": "other",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "string",
     "basis": "literal"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 the ScimUser (GET, PUT, PATCH) | 204 (DELETE) | 404 unknown or deleted user | 403 for the primary administrator, a user owned by another directory, or a record another SCIM connection owns | 409 for the support account; 409 scimType uniqueness when a new userName is taken | 400 scimType mutability when the sign-in e-mail of an account with elevated access would change",
   "responses": [
    {
     "status": 200,
     "text": "the ScimUser (GET, PUT, PATCH)"
    },
    {
     "status": 204,
     "text": "(DELETE)"
    },
    {
     "status": 404,
     "text": "unknown or deleted user"
    },
    {
     "status": 403,
     "text": "for the primary administrator, a user owned by another directory, or a record another SCIM connection owns"
    },
    {
     "status": 409,
     "text": "for the support account; 409 scimType uniqueness when a new userName is taken"
    },
    {
     "status": 400,
     "text": "scimType mutability when the sign-in e-mail of an account with elevated access would change"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "unknown or deleted user"
    },
    {
     "status": 403,
     "text": "for the primary administrator, a user owned by another directory, or a record another SCIM connection owns"
    },
    {
     "status": 409,
     "text": "for the support account; 409 scimType uniqueness when a new userName is taken"
    },
    {
     "status": 400,
     "text": "scimType mutability when the sign-in e-mail of an account with elevated access would change"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /scim/v2/Groups",
   "group": "scim",
   "method": "GET",
   "path": "/scim/v2/Groups",
   "purpose": "List groups. POST creates one. Query: ?filter on displayName, externalId or id; ?excludedAttributes; ?startIndex (1-based, default 1) and ?count (default 100, at most 200; 0 returns only the total); ordered by id ascending.",
   "auth": {
    "text": "SCIM bearer token",
    "kind": "other",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 a ScimListResponse { schemas, totalResults, startIndex, itemsPerPage, Resources } (typed under Response Objects; Resources holds ScimUser or ScimGroup objects) | 400 scimType invalidFilter for an attribute that cannot be filtered. POST: 201 the group, with a Location header | 400 scimType invalidValue without displayName | 403 when group creation by SCIM is turned off | 409 scimType uniqueness when the name exists",
   "responses": [
    {
     "status": 200,
     "text": "a ScimListResponse { schemas, totalResults, startIndex, itemsPerPage, Resources } (typed under Response Objects; Resources holds ScimUser or ScimGroup objects)"
    },
    {
     "status": 400,
     "text": "scimType invalidFilter for an attribute that cannot be filtered. POST: 201 the group, with a Location header"
    },
    {
     "status": 400,
     "text": "scimType invalidValue without displayName"
    },
    {
     "status": 403,
     "text": "when group creation by SCIM is turned off"
    },
    {
     "status": 409,
     "text": "scimType uniqueness when the name exists"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "scimType invalidFilter for an attribute that cannot be filtered. POST: 201 the group, with a Location header"
    },
    {
     "status": 400,
     "text": "scimType invalidValue without displayName"
    },
    {
     "status": 403,
     "text": "when group creation by SCIM is turned off"
    },
    {
     "status": 409,
     "text": "scimType uniqueness when the name exists"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /scim/v2/Groups/:id",
   "group": "scim",
   "method": "GET",
   "path": "/scim/v2/Groups/:id",
   "purpose": "Read one group. PUT replaces, PATCH updates, DELETE removes.",
   "auth": {
    "text": "SCIM bearer token",
    "kind": "other",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "string",
     "basis": "literal"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 the ScimGroup (GET, PUT, PATCH) | 204 (DELETE) | 404 for an unknown group or one that is not SCIM-managed | 403 when deleting a Zonitix-owned group that was opted in to SCIM, or adding a member to a group that grants privileged access the administrator has not allowed | 409 scimType uniqueness on a rename to an existing name",
   "responses": [
    {
     "status": 200,
     "text": "the ScimGroup (GET, PUT, PATCH)"
    },
    {
     "status": 204,
     "text": "(DELETE)"
    },
    {
     "status": 404,
     "text": "for an unknown group or one that is not SCIM-managed"
    },
    {
     "status": 403,
     "text": "when deleting a Zonitix-owned group that was opted in to SCIM, or adding a member to a group that grants privileged access the administrator has not allowed"
    },
    {
     "status": 409,
     "text": "scimType uniqueness on a rename to an existing name"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "for an unknown group or one that is not SCIM-managed"
    },
    {
     "status": 403,
     "text": "when deleting a Zonitix-owned group that was opted in to SCIM, or adding a member to a group that grants privileged access the administrator has not allowed"
    },
    {
     "status": 409,
     "text": "scimType uniqueness on a rename to an existing name"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /user-management/scim-provisioning/tokens",
   "group": "scim",
   "method": "GET",
   "path": "/user-management/scim-provisioning/tokens",
   "purpose": "List SCIM tokens (metadata only). No query parameters: every token, newest first.",
   "auth": {
    "text": "Session + manage_security",
    "kind": "session",
    "permissions": [
     "manage_security"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 ScimToken[] (typed under Response Objects) { id, name, tokenPrefix, createdBy, createdAt, lastUsedAt, revokedAt, revokedBy, familyId, active, owns: { users, groups } or null }",
   "responses": [
    {
     "status": 200,
     "text": "ScimToken[] (typed under Response Objects) { id, name, tokenPrefix, createdBy, createdAt, lastUsedAt, revokedAt, revokedBy, familyId, active, owns: { users, groups } or null }"
    }
   ],
   "errors": [],
   "guard": "bare array, every token (revoked ones included) in one reply, newest first; no paging"
  },
  {
   "id": "POST /user-management/scim-provisioning/tokens",
   "group": "scim",
   "method": "POST",
   "path": "/user-management/scim-provisioning/tokens",
   "purpose": "Issue a SCIM token. Repeating the call issues another token each time. Audited as SCIM_TOKEN_ISSUED.",
   "auth": {
    "text": "Session + manage_security",
    "kind": "session",
    "permissions": [
     "manage_security"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "A ScimTokenIssue: { name (required, cut to 120 characters), rotateFromId (optional: the id of the token this one replaces, so it keeps that connection's users and groups) }",
   "response": "201 the ScimToken record plus token (the raw zscim_ value, shown only here) | 400 no name | 404 rotateFromId not found | 409 when 10 tokens are already active",
   "responses": [
    {
     "status": 201,
     "text": "the ScimToken record plus token (the raw zscim_ value, shown only here)"
    },
    {
     "status": 400,
     "text": "no name"
    },
    {
     "status": 404,
     "text": "rotateFromId not found"
    },
    {
     "status": 409,
     "text": "when 10 tokens are already active"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "no name"
    },
    {
     "status": 404,
     "text": "rotateFromId not found"
    },
    {
     "status": 409,
     "text": "when 10 tokens are already active"
    }
   ],
   "guard": "Repeat: issues another token; 409 only once 10 tokens are active"
  },
  {
   "id": "POST /user-management/scim-provisioning/tokens/:id/revoke",
   "group": "scim",
   "method": "POST",
   "path": "/user-management/scim-provisioning/tokens/:id/revoke",
   "purpose": "Revoke a SCIM token. No body. Safe to repeat: an already revoked token returns the same record.",
   "auth": {
    "text": "Session + manage_security",
    "kind": "session",
    "permissions": [
     "manage_security"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 the ScimToken record, without owns | 404 unknown id",
   "responses": [
    {
     "status": 200,
     "text": "the ScimToken record, without owns"
    },
    {
     "status": 404,
     "text": "unknown id"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "unknown id"
    }
   ],
   "guard": "Repeat: safe; a token that is already revoked answers 200 with its record unchanged"
  },
  {
   "id": "POST /user-management/scim-provisioning/tokens/:id/rebind",
   "group": "scim",
   "method": "POST",
   "path": "/user-management/scim-provisioning/tokens/:id/rebind",
   "purpose": "Move the users and groups owned by another token's connection (body fromTokenId), or the records with no owning connection (body unowned: true), to this token's connection. Safe to repeat: a second call finds nothing left to move and returns zeros.",
   "auth": {
    "text": "Session + manage_security",
    "kind": "session",
    "permissions": [
     "manage_security"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "A ScimTokenRebind: { fromTokenId } or { unowned: true }",
   "response": "200 { users, groups } — how many were moved | 400 when neither is sent, or both tokens already belong to the same connection | 404 target or source token not found | 409 when the target token is revoked",
   "responses": [
    {
     "status": 200,
     "text": "{ users, groups } — how many were moved"
    },
    {
     "status": 400,
     "text": "when neither is sent, or both tokens already belong to the same connection"
    },
    {
     "status": 404,
     "text": "target or source token not found"
    },
    {
     "status": 409,
     "text": "when the target token is revoked"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "when neither is sent, or both tokens already belong to the same connection"
    },
    {
     "status": 404,
     "text": "target or source token not found"
    },
    {
     "status": 409,
     "text": "when the target token is revoked"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /user-management/scim-provisioning/ownership",
   "group": "scim",
   "method": "GET",
   "path": "/user-management/scim-provisioning/ownership",
   "purpose": "Count of SCIM-provisioned users and groups that have no owning connection.",
   "auth": {
    "text": "Session + manage_security",
    "kind": "session",
    "permissions": [
     "manage_security"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { unowned: { users, groups } }",
   "responses": [
    {
     "status": 200,
     "text": "{ unowned: { users, groups } }"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /user-management/scim-provisioning/settings",
   "group": "scim",
   "method": "GET",
   "path": "/user-management/scim-provisioning/settings",
   "purpose": "Read the SCIM settings (PUT the same path to change them). The PUT is a replace-style write and is audited as SCIM_POLICY_UPDATED.",
   "auth": {
    "text": "Session + manage_security",
    "kind": "session",
    "permissions": [
     "manage_security"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The PUT's JSON body (the GET takes none): any of onboardOnCreate, allowGroupCreate (true or false), managedGroupIds, privilegedAllowedGroupIds (arrays of group ids); a field left out keeps its value, and only local groups are kept in managedGroupIds",
   "response": "GET 200 ScimSettings (typed under Response Objects) { policy: { onboardOnCreate, allowGroupCreate, managedGroupIds[], privilegedAllowedGroupIds[] }, groups: [{ id, name, source, scimCreated, eligible, managed, privileged, confers[], privilegedAllowed }] } | PUT 200 the saved policy",
   "responses": [
    {
     "status": null,
     "text": "GET 200 ScimSettings (typed under Response Objects) { policy: { onboardOnCreate, allowGroupCreate, managedGroupIds[], privilegedAllowedGroupIds[] }, groups: [{ id, name, source, scimCreated, eligible, managed, privileged, confers[], privilegedAllowed }] } | PUT 200 the saved policy"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /user-management/access-reviews",
   "group": "access-governance",
   "method": "GET",
   "path": "/user-management/access-reviews",
   "purpose": "List access-review campaigns (one at /access-reviews/:id, CSV at /access-reviews/:id/export). Query: ?archived=1 returns the archived campaigns instead of the current ones. The list is not paged; newest first.",
   "auth": {
    "text": "Session + view_access_reviews | manage_access_reviews",
    "kind": "session",
    "permissions": [
     "manage_access_reviews",
     "view_access_reviews"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 AccessReviewCampaign[] (typed under Response Objects), every campaign of the kind asked for in one reply (no paging). GET /:id takes ?limit (default 200, 1–500) and ?offset and returns an AccessReviewCampaignDetail { campaign, items: AccessReviewItem[], total, limit, offset }, items ordered by person then access type | 404 unknown campaign. /:id/export returns text/csv",
   "responses": [
    {
     "status": 200,
     "text": "AccessReviewCampaign[] (typed under Response Objects), every campaign of the kind asked for in one reply (no paging). GET /:id takes ?limit (default 200, 1–500) and ?offset and returns an AccessReviewCampaignDetail { campaign, items: AccessReviewItem[], total, limit, offset }, items ordered by person then access type"
    },
    {
     "status": 404,
     "text": "unknown campaign. /:id/export returns text/csv"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "unknown campaign. /:id/export returns text/csv"
    }
   ],
   "guard": null
  },
  {
   "id": "POST /user-management/access-reviews",
   "group": "access-governance",
   "method": "POST",
   "path": "/user-management/access-reviews",
   "purpose": "Start an access-review campaign. Repeating the call starts another campaign; nothing deduplicates by name.",
   "auth": {
    "text": "Session + manage_access_reviews",
    "kind": "session",
    "permissions": [
     "manage_access_reviews"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "An AccessReviewCampaignCreate: { name (required), description, scopeType: 'all' (default) or 'department', scopeValue (the department; required for a department scope), dueDate, externalSource: none | persona (default) | actual | both }",
   "response": "201 the AccessReviewCampaign | 400 no name, or a department scope with no department",
   "responses": [
    {
     "status": 201,
     "text": "the AccessReviewCampaign"
    },
    {
     "status": 400,
     "text": "no name, or a department scope with no department"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "no name, or a department scope with no department"
    }
   ],
   "guard": "Repeat: starts a second review with its own items; names are not checked for duplicates"
  },
  {
   "id": "POST /user-management/access-reviews/:id/close",
   "group": "access-governance",
   "method": "POST",
   "path": "/user-management/access-reviews/:id/close",
   "purpose": "Close a campaign (also /archive and /restore; DELETE removes it). No body. Each is guarded by the campaign's state, so a repeat answers 409 instead of acting twice.",
   "auth": {
    "text": "Session + manage_access_reviews",
    "kind": "session",
    "permissions": [
     "manage_access_reviews"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body, for close, archive, restore and delete alike.",
   "response": "200 the AccessReviewCampaign | 404 | 409 when it is not active (close), already archived (archive) or not archived (restore). DELETE: 200 { deleted: true, id } | 409 when any item has been decided — archive it instead",
   "responses": [
    {
     "status": 200,
     "text": "the AccessReviewCampaign"
    },
    {
     "status": 404,
     "text": ""
    },
    {
     "status": 409,
     "text": "when it is not active (close), already archived (archive) or not archived (restore). DELETE: 200 { deleted: true, id }"
    },
    {
     "status": 409,
     "text": "when any item has been decided — archive it instead"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": ""
    },
    {
     "status": 409,
     "text": "when it is not active (close), already archived (archive) or not archived (restore). DELETE: 200 { deleted: true, id }"
    },
    {
     "status": 409,
     "text": "when any item has been decided — archive it instead"
    }
   ],
   "guard": "Repeat: 409; a closed review is no longer active, an archived one is already archived, and a restored one is no longer archived"
  },
  {
   "id": "GET /user-management/access-reviews/my-queue",
   "group": "access-governance",
   "method": "GET",
   "path": "/user-management/access-reviews/my-queue",
   "purpose": "The review items waiting for your decision, from active campaigns only. Query: ?limit (default 50, 1–200), ?offset (default 0), ?filter = all (default), attention or expected.",
   "auth": {
    "text": "Session (any signed-in user; only items assigned to you are returned)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 an AccessReviewQueue (typed under Response Objects) { items: AccessReviewItem[], total, limit, offset, filter, stats: { all, attention, expected, privileged, drift, people } }",
   "responses": [
    {
     "status": 200,
     "text": "an AccessReviewQueue (typed under Response Objects) { items: AccessReviewItem[], total, limit, offset, filter, stats: { all, attention, expected, privileged, drift, people } }"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /user-management/access-reviews/reviewers",
   "group": "access-governance",
   "method": "GET",
   "path": "/user-management/access-reviews/reviewers",
   "purpose": "The people an item can be reassigned to: every active holder of certify_access or manage_access_reviews. No query parameters; not paged, sorted by email.",
   "auth": {
    "text": "Session + manage_access_reviews",
    "kind": "session",
    "permissions": [
     "manage_access_reviews"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 an array of { email:string }",
   "responses": [
    {
     "status": 200,
     "text": "an array of { email:string }"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /user-management/access-reviews/grant-detail",
   "group": "access-governance",
   "method": "GET",
   "path": "/user-management/access-reviews/grant-detail",
   "purpose": "The permissions behind one role or group, for the expandable row of a review item. Query: ?accessType = role or group, ?accessId (the role or group id).",
   "auth": {
    "text": "Session + certify_access | view_access_reviews | manage_access_reviews, or the reviewer an item for that grant is assigned to",
    "kind": "session",
    "permissions": [
     "certify_access",
     "manage_access_reviews",
     "view_access_reviews"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query: ?accessType and ?accessId; accessId is required for a role or a group.",
   "response": "200 an AccessGrantDetail (typed under Response Objects): { accessType, accessId, accessLabel, external:false, permissions:string[] (sorted), permissionCount, privileged } for a role or group; for any other accessType { accessType, accessId, external:true, permissions:null, permissionCount:null, privileged:false } | 400 { error:'accessId is required.' } | 403 when you hold none of the three permissions and no item for that grant is assigned to you | 503 when your review assignments could not be checked; try again",
   "responses": [
    {
     "status": 200,
     "text": "an AccessGrantDetail (typed under Response Objects): { accessType, accessId, accessLabel, external:false, permissions:string[] (sorted), permissionCount, privileged } for a role or group; for any other accessType { accessType, accessId, external:true, permissions:null, permissionCount:null, privileged:false }"
    },
    {
     "status": 400,
     "text": "{ error:'accessId is required.' }"
    },
    {
     "status": 403,
     "text": "when you hold none of the three permissions and no item for that grant is assigned to you"
    },
    {
     "status": 503,
     "text": "when your review assignments could not be checked; try again"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ error:'accessId is required.' }"
    },
    {
     "status": 403,
     "text": "when you hold none of the three permissions and no item for that grant is assigned to you"
    },
    {
     "status": 503,
     "text": "when your review assignments could not be checked; try again"
    }
   ],
   "guard": "read-only and safe to repeat"
  },
  {
   "id": "POST /user-management/access-reviews/items/:id/decide",
   "group": "access-governance",
   "method": "POST",
   "path": "/user-management/access-reviews/items/:id/decide",
   "purpose": "Keep or revoke one reviewed grant (several at once at /access-reviews/items/bulk-decide). A second decision on the same item answers 409.",
   "auth": {
    "text": "Session + certify_access | manage_access_reviews, or the reviewer the item is assigned to",
    "kind": "session",
    "permissions": [
     "certify_access",
     "manage_access_reviews"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "An AccessReviewDecision: { decision: 'keep' | 'revoke' (required), note }. Bulk: { ids (1–500), decision, note }",
   "response": "200 the AccessReviewItem | 400 | 403 for your own access, an item not assigned to you (without manage_access_reviews), or the primary administrator's access | 404 | 409 already decided, campaign no longer active, or a revoke of baseline access. Bulk: 200 { decision, ok, failed, results: [{ id, ok, error }] }; items that cannot be decided are skipped, not fatal",
   "responses": [
    {
     "status": 200,
     "text": "the AccessReviewItem"
    },
    {
     "status": 400,
     "text": ""
    },
    {
     "status": 403,
     "text": "for your own access, an item not assigned to you (without manage_access_reviews), or the primary administrator's access"
    },
    {
     "status": 404,
     "text": ""
    },
    {
     "status": 409,
     "text": "already decided, campaign no longer active, or a revoke of baseline access. Bulk: 200 { decision, ok, failed, results: [{ id, ok, error }] }; items that cannot be decided are skipped, not fatal"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": ""
    },
    {
     "status": 403,
     "text": "for your own access, an item not assigned to you (without manage_access_reviews), or the primary administrator's access"
    },
    {
     "status": 404,
     "text": ""
    },
    {
     "status": 409,
     "text": "already decided, campaign no longer active, or a revoke of baseline access. Bulk: 200 { decision, ok, failed, results: [{ id, ok, error }] }; items that cannot be decided are skipped, not fatal"
    }
   ],
   "guard": "Repeat: 409; an item can be decided once"
  },
  {
   "id": "POST /user-management/access-reviews/items/:id/reassign",
   "group": "access-governance",
   "method": "POST",
   "path": "/user-management/access-reviews/items/:id/reassign",
   "purpose": "Hand a review item to another reviewer (several at /access-reviews/items/bulk-reassign). Safe to repeat: reassigning to the current reviewer changes nothing.",
   "auth": {
    "text": "Session + manage_access_reviews",
    "kind": "session",
    "permissions": [
     "manage_access_reviews"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "An AccessReviewReassign: { reviewerEmail }. Bulk: { ids (1–500), reviewerEmail }",
   "response": "200 the AccessReviewItem | 400 no reviewerEmail, the person whose access it is, or an inactive user | 404 item or user not found | 409 already decided. Bulk: 200 { ok, failed, results }",
   "responses": [
    {
     "status": 200,
     "text": "the AccessReviewItem"
    },
    {
     "status": 400,
     "text": "no reviewerEmail, the person whose access it is, or an inactive user"
    },
    {
     "status": 404,
     "text": "item or user not found"
    },
    {
     "status": 409,
     "text": "already decided. Bulk: 200 { ok, failed, results }"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "no reviewerEmail, the person whose access it is, or an inactive user"
    },
    {
     "status": 404,
     "text": "item or user not found"
    },
    {
     "status": 409,
     "text": "already decided. Bulk: 200 { ok, failed, results }"
    }
   ],
   "guard": "Repeat: safe while the item is still pending; the reviewer is set again"
  },
  {
   "id": "GET /user-management/access-reviews/schedules",
   "group": "access-governance",
   "method": "GET",
   "path": "/user-management/access-reviews/schedules",
   "purpose": "Recurring review schedules. Not paged; newest first.",
   "auth": {
    "text": "Session + view_access_reviews | manage_access_reviews",
    "kind": "session",
    "permissions": [
     "manage_access_reviews",
     "view_access_reviews"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 AccessReviewSchedule[] (typed under Response Objects), every schedule in one reply",
   "responses": [
    {
     "status": 200,
     "text": "AccessReviewSchedule[] (typed under Response Objects), every schedule in one reply"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /user-management/access-reviews/schedules",
   "group": "access-governance",
   "method": "POST",
   "path": "/user-management/access-reviews/schedules",
   "purpose": "Create a recurring review (PATCH or DELETE /schedules/:id; run now at /schedules/:id/run; pause or resume at /schedules/:id/status). Repeating the POST creates another schedule.",
   "auth": {
    "text": "Session + manage_access_reviews",
    "kind": "session",
    "permissions": [
     "manage_access_reviews"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "An AccessReviewScheduleWrite: { name (required), cadence: monthly | quarterly (default) | semiannual | annual | custom, intervalMonths (custom only, at least 1), scopeType: all | department, scopeValue, externalSource: none | persona | actual | both (default), dueDays (1–365, default 14), remind (default true), autoClose (default true), skipIfOpen (default false), firstRunAt (a date; default now) }. PATCH takes the same fields, all optional. Status: { status: 'active' | 'paused' }",
   "response": "201 the AccessReviewSchedule | 400 no name. PATCH: 200 | 400 a firstRunAt that is not a date | 404. Run: 201 the launched campaign | 409 when the previous run is still open and skipIfOpen is on. DELETE: 200 { deleted: true, id }",
   "responses": [
    {
     "status": 201,
     "text": "the AccessReviewSchedule"
    },
    {
     "status": 400,
     "text": "no name. PATCH: 200"
    },
    {
     "status": 400,
     "text": "a firstRunAt that is not a date"
    },
    {
     "status": 404,
     "text": ". Run: 201 the launched campaign"
    },
    {
     "status": 409,
     "text": "when the previous run is still open and skipIfOpen is on. DELETE: 200 { deleted: true, id }"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "no name. PATCH: 200"
    },
    {
     "status": 400,
     "text": "a firstRunAt that is not a date"
    },
    {
     "status": 404,
     "text": ". Run: 201 the launched campaign"
    },
    {
     "status": 409,
     "text": "when the previous run is still open and skipIfOpen is on. DELETE: 200 { deleted: true, id }"
    }
   ],
   "guard": "Repeat: creates a second schedule; names are not checked for duplicates. Run: 409 only when skipIfOpen is on and the previous run is still open, otherwise each call launches another review"
  },
  {
   "id": "GET /user-management/access-reviews/live",
   "group": "access-governance",
   "method": "GET",
   "path": "/user-management/access-reviews/live",
   "purpose": "Current access as the reviews see it, computed on request. Query: ?scopeType (all or department), ?scopeValue, ?externalSource (none, persona, actual or both; default both), ?driftOnly=1. Not paged: at most 2000 items.",
   "auth": {
    "text": "Session + view_access_reviews | manage_access_reviews",
    "kind": "session",
    "permissions": [
     "manage_access_reviews",
     "view_access_reviews"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 a LiveAccessSnapshot (typed under Response Objects) { generatedAt, scopeType, scopeValue, externalSource, userCount, total, driftCount, privilegedCount, providersActive, externalScanned, capped, items } — capped is true when the list was cut at 2000",
   "responses": [
    {
     "status": 200,
     "text": "a LiveAccessSnapshot (typed under Response Objects) { generatedAt, scopeType, scopeValue, externalSource, userCount, total, driftCount, privilegedCount, providersActive, externalScanned, capped, items } — capped is true when the list was cut at 2000"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /user-management/access-reviews/live/revoke",
   "group": "access-governance",
   "method": "POST",
   "path": "/user-management/access-reviews/live/revoke",
   "purpose": "Revoke a grant outside a campaign.",
   "auth": {
    "text": "Session + manage_access_reviews",
    "kind": "session",
    "permissions": [
     "manage_access_reviews"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "A LiveAccessRevoke: { userId, accessType, accessLabel (all required), accessId (required for a role or a group), providerType }",
   "response": "200 { revoked: true, runId } | 400 | 403 for the primary administrator's access | 404 user not found | 409 for baseline access, which is restored automatically",
   "responses": [
    {
     "status": 200,
     "text": "{ revoked: true, runId }"
    },
    {
     "status": 400,
     "text": ""
    },
    {
     "status": 403,
     "text": "for the primary administrator's access"
    },
    {
     "status": 404,
     "text": "user not found"
    },
    {
     "status": 409,
     "text": "for baseline access, which is restored automatically"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": ""
    },
    {
     "status": 403,
     "text": "for the primary administrator's access"
    },
    {
     "status": 404,
     "text": "user not found"
    },
    {
     "status": 409,
     "text": "for baseline access, which is restored automatically"
    }
   ],
   "guard": null
  },
  {
   "id": "POST /user-management/access-requests",
   "group": "access-governance",
   "method": "POST",
   "path": "/user-management/access-requests",
   "purpose": "Request access. Repeating the call files another pending request; nothing deduplicates.",
   "auth": {
    "text": "Session + request_access | approve_access_request | manage_users",
    "kind": "session",
    "permissions": [
     "approve_access_request",
     "manage_users",
     "request_access"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "An AccessRequestCreate: { itemLabel (required), itemKind (default 'app'), providerType, justification, customFields (only keys in the custom-field list are kept, 500 characters each), userId (honoured only for an approver or manage_users; otherwise the request is for you) }",
   "response": "201 the AccessRequest, status pending | 400 no itemLabel, or your account could not be resolved | 404 user not found",
   "responses": [
    {
     "status": 201,
     "text": "the AccessRequest, status pending"
    },
    {
     "status": 400,
     "text": "no itemLabel, or your account could not be resolved"
    },
    {
     "status": 404,
     "text": "user not found"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "no itemLabel, or your account could not be resolved"
    },
    {
     "status": 404,
     "text": "user not found"
    }
   ],
   "guard": "Repeat: files a second pending request; an earlier request for the same thing does not block it"
  },
  {
   "id": "GET /user-management/access-requests/mine",
   "group": "access-governance",
   "method": "GET",
   "path": "/user-management/access-requests/mine",
   "purpose": "Your own requests. No query parameters; not paged, newest first.",
   "auth": {
    "text": "Session + request_access | approve_access_request | manage_users",
    "kind": "session",
    "permissions": [
     "approve_access_request",
     "manage_users",
     "request_access"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 AccessRequest[] (typed under Response Objects), every request of yours in one reply",
   "responses": [
    {
     "status": 200,
     "text": "AccessRequest[] (typed under Response Objects), every request of yours in one reply"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /user-management/access-requests",
   "group": "access-governance",
   "method": "GET",
   "path": "/user-management/access-requests",
   "purpose": "All requests, for approvers. Query: ?status to keep one status (for example pending). Not paged; newest first.",
   "auth": {
    "text": "Session + approve_access_request | manage_users",
    "kind": "session",
    "permissions": [
     "approve_access_request",
     "manage_users"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 AccessRequest[] (typed under Response Objects), every matching request in one reply",
   "responses": [
    {
     "status": 200,
     "text": "AccessRequest[] (typed under Response Objects), every matching request in one reply"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /user-management/access-requests/:id/decide",
   "group": "access-governance",
   "method": "POST",
   "path": "/user-management/access-requests/:id/decide",
   "purpose": "Approve or deny a request (several at /access-requests/bulk-decide). A request that is already decided answers 409; one whose provisioning run could not be created comes back with status failed and can be decided again.",
   "auth": {
    "text": "Session + approve_access_request | manage_users",
    "kind": "session",
    "permissions": [
     "approve_access_request",
     "manage_users"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "An AccessRequestDecision: { approve (true approves; anything else rejects), comment }. Bulk: { ids (1–500), approve, comment }",
   "response": "200 the AccessRequest | 403 | 404 | 409 already decided. Bulk: 200 { approve, ok, failed, results: [{ id, ok, status, error }] }",
   "responses": [
    {
     "status": 200,
     "text": "the AccessRequest"
    },
    {
     "status": 403,
     "text": ""
    },
    {
     "status": 404,
     "text": ""
    },
    {
     "status": 409,
     "text": "already decided. Bulk: 200 { approve, ok, failed, results: [{ id, ok, status, error }] }"
    }
   ],
   "errors": [
    {
     "status": 403,
     "text": ""
    },
    {
     "status": 404,
     "text": ""
    },
    {
     "status": 409,
     "text": "already decided. Bulk: 200 { approve, ok, failed, results: [{ id, ok, status, error }] }"
    }
   ],
   "guard": "a non-administrator cannot approve a request they submitted or one for their own access (403); rejecting it is allowed. Repeat: 409; a request that is already decided cannot be decided again"
  },
  {
   "id": "GET /user-management/access-requests/custom-fields",
   "group": "access-governance",
   "method": "GET",
   "path": "/user-management/access-requests/custom-fields",
   "purpose": "The organisation's extra fields on the request form.",
   "auth": {
    "text": "Session + request_access | approve_access_request | manage_users",
    "kind": "session",
    "permissions": [
     "approve_access_request",
     "manage_users",
     "request_access"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { fields: [{ key, label, type: text | select | number | date, options }] }",
   "responses": [
    {
     "status": 200,
     "text": "{ fields: [{ key, label, type: text | select | number | date, options }] }"
    }
   ],
   "errors": [],
   "guard": "the whole field list in one reply (no paging)"
  },
  {
   "id": "POST /user-management/access-requests/custom-fields",
   "group": "access-governance",
   "method": "POST",
   "path": "/user-management/access-requests/custom-fields",
   "purpose": "Replace the extra request fields. A replace-style write, safe to repeat. Audited as ACCESS_REQUEST_CUSTOM_FIELDS_SET.",
   "auth": {
    "text": "Session + manage_users",
    "kind": "session",
    "permissions": [
     "manage_users"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "An AccessRequestFieldsWrite: { fields } — at most 20 fields; a label is cut to 60 characters and a select keeps up to 50 options; a malformed entry is skipped, not refused",
   "response": "200 { fields } as stored",
   "responses": [
    {
     "status": 200,
     "text": "{ fields } as stored"
    }
   ],
   "errors": [],
   "guard": "replaces the whole list. Repeat: safe; the same list is stored again"
  },
  {
   "id": "GET /user-management/departments",
   "group": "access-governance",
   "method": "GET",
   "path": "/user-management/departments",
   "purpose": "The department list used by intake, user and job forms. No query parameters; sorted by name, not paged.",
   "auth": {
    "text": "Session + view_users | manage_users | manage_security | manage_workflows | view_recruitment | manage_recruitment | source_candidates | conduct_interview | view_employee_records | manage_employee_records | manage_devices",
    "kind": "session",
    "permissions": [
     "conduct_interview",
     "manage_devices",
     "manage_employee_records",
     "manage_recruitment",
     "manage_security",
     "manage_users",
     "manage_workflows",
     "source_candidates",
     "view_employee_records",
     "view_recruitment",
     "view_users"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 an array of strings (the names, sorted). /departments/detailed (view_users | manage_users | manage_security) returns [{ name, memberCount, governed, variants }]",
   "responses": [
    {
     "status": 200,
     "text": "an array of strings (the names, sorted). /departments/detailed (view_users | manage_users | manage_security) returns [{ name, memberCount, governed, variants }]"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /user-management/departments",
   "group": "access-governance",
   "method": "POST",
   "path": "/user-management/departments",
   "purpose": "Add a department (also /departments/rename and /departments/merge; DELETE /departments/:name). A repeat of the same add answers 409.",
   "auth": {
    "text": "Session + manage_users | manage_personas",
    "kind": "session",
    "permissions": [
     "manage_personas",
     "manage_users"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "A DepartmentCreate: { name } — required, at most 100 characters. Rename: { from, to }. Merge: { from, into }",
   "response": "201 { name, departments, detailed } | 400 | 409 when the name already exists, ignoring case. Rename: 200 { from, to, moved, references, detailed } | 404 | 409 when the target exists or a connected HR system owns the department field. Merge: 200 { from, into, moved, references, detailed } | 400 into itself | 404 | 409. DELETE: 200 { name, detailed } | 404 not a governed department | 409 while any user is in it",
   "responses": [
    {
     "status": 201,
     "text": "{ name, departments, detailed }"
    },
    {
     "status": 400,
     "text": ""
    },
    {
     "status": 409,
     "text": "when the name already exists, ignoring case. Rename: 200 { from, to, moved, references, detailed }"
    },
    {
     "status": 404,
     "text": ""
    },
    {
     "status": 409,
     "text": "when the target exists or a connected HR system owns the department field. Merge: 200 { from, into, moved, references, detailed }"
    },
    {
     "status": 400,
     "text": "into itself"
    },
    {
     "status": 404,
     "text": ""
    },
    {
     "status": 409,
     "text": ". DELETE: 200 { name, detailed }"
    },
    {
     "status": 404,
     "text": "not a governed department"
    },
    {
     "status": 409,
     "text": "while any user is in it"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": ""
    },
    {
     "status": 409,
     "text": "when the name already exists, ignoring case. Rename: 200 { from, to, moved, references, detailed }"
    },
    {
     "status": 404,
     "text": ""
    },
    {
     "status": 409,
     "text": "when the target exists or a connected HR system owns the department field. Merge: 200 { from, into, moved, references, detailed }"
    },
    {
     "status": 400,
     "text": "into itself"
    },
    {
     "status": 404,
     "text": ""
    },
    {
     "status": 409,
     "text": ". DELETE: 200 { name, detailed }"
    },
    {
     "status": 404,
     "text": "not a governed department"
    },
    {
     "status": 409,
     "text": "while any user is in it"
    }
   ],
   "guard": "Repeat: 409; the name then already exists"
  },
  {
   "id": "GET /user-management/users/:id/export",
   "group": "user-admin",
   "method": "GET",
   "path": "/user-management/users/:id/export",
   "purpose": "Export another user's personal data by id, including a former employee who can no longer export their own. Audited as PEOPLE_DATA_EXPORTED.",
   "auth": {
    "text": "Session + manage_users",
    "kind": "session",
    "permissions": [
     "manage_users"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 a JSON document; it carries the 200 most recent audit entries for that e-mail | 404 user not found",
   "responses": [
    {
     "status": 200,
     "text": "a JSON document; it carries the 200 most recent audit entries for that e-mail"
    },
    {
     "status": 404,
     "text": "user not found"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "user not found"
    }
   ],
   "guard": null
  },
  {
   "id": "GET /user-management/users/import/template",
   "group": "user-admin",
   "method": "GET",
   "path": "/user-management/users/import/template",
   "purpose": "Download the CSV import template.",
   "auth": {
    "text": "Session + manage_users",
    "kind": "session",
    "permissions": [
     "manage_users"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 text/csv attachment with the header email, firstName, lastName, department, title, managerEmail, groups and one example row",
   "responses": [
    {
     "status": 200,
     "text": "text/csv attachment with the header email, firstName, lastName, department, title, managerEmail, groups and one example row"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /user-management/users/import/preview",
   "group": "user-admin",
   "method": "POST",
   "path": "/user-management/users/import/preview",
   "purpose": "Validate a CSV and preview what would be created. Writes nothing.",
   "auth": {
    "text": "Session + manage_users",
    "kind": "session",
    "permissions": [
     "manage_users"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ csv (the file text; required, at most 1,048,576 characters and 1000 rows), options: { existing: 'skip' (default) | 'update', sendInvites, addNewValues } }",
   "response": "200 { rows, summary: { total, create, update, skip, error }, newDepartments, newTitles, options } | 400 no csv, an empty file, no email column or more than 1000 rows | 413 file too large",
   "responses": [
    {
     "status": 200,
     "text": "{ rows, summary: { total, create, update, skip, error }, newDepartments, newTitles, options }"
    },
    {
     "status": 400,
     "text": "no csv, an empty file, no email column or more than 1000 rows"
    },
    {
     "status": 413,
     "text": "file too large"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "no csv, an empty file, no email column or more than 1000 rows"
    },
    {
     "status": 413,
     "text": "file too large"
    }
   ],
   "guard": null
  },
  {
   "id": "POST /user-management/users/import/commit",
   "group": "user-admin",
   "method": "POST",
   "path": "/user-management/users/import/commit",
   "purpose": "Create the users from the CSV. The file is validated again on the server; the preview is never trusted. With existing: 'skip' (the default) a row whose e-mail already has an account is skipped, so sending the same file again does not create the users twice. Audited as USERS_IMPORTED.",
   "auth": {
    "text": "Session + manage_users",
    "kind": "session",
    "permissions": [
     "manage_users"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "the same body as the preview",
   "response": "200 { rows, summary: { total, created, updated, skipped, errors, invitesSent, invitesNotEmailed }, links: [{ email, link, expiresAt }], options } — links holds a one-time set-password link for each invite that could not be e-mailed | 400 | 413",
   "responses": [
    {
     "status": 200,
     "text": "{ rows, summary: { total, created, updated, skipped, errors, invitesSent, invitesNotEmailed }, links: [{ email, link, expiresAt }], options } — links holds a one-time set-password link for each invite that could not be e-mailed"
    },
    {
     "status": 400,
     "text": ""
    },
    {
     "status": 413,
     "text": ""
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": ""
    },
    {
     "status": 413,
     "text": ""
    }
   ],
   "guard": null
  },
  {
   "id": "POST /user-management/users/:id/invite/resend",
   "group": "user-admin",
   "method": "POST",
   "path": "/user-management/users/:id/invite/resend",
   "purpose": "Resend the set-password invite. The previous link stops working. Each call issues a new 72-hour link.",
   "auth": {
    "text": "Session + manage_users",
    "kind": "session",
    "permissions": [
     "manage_users"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { sent: true, expiresAt } | 200 { sent: false, reason, link, expiresAt } when the e-mail could not be sent: the link is shown once | 200 { sent: false, reason, expiresAt, linkWithheld: true } when the account holds access you could not grant yourself | 404 | 409 when this account cannot be invited",
   "responses": [
    {
     "status": 200,
     "text": "{ sent: true, expiresAt }"
    },
    {
     "status": 200,
     "text": "{ sent: false, reason, link, expiresAt } when the e-mail could not be sent: the link is shown once"
    },
    {
     "status": 200,
     "text": "{ sent: false, reason, expiresAt, linkWithheld: true } when the account holds access you could not grant yourself"
    },
    {
     "status": 404,
     "text": ""
    },
    {
     "status": 409,
     "text": "when this account cannot be invited"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": ""
    },
    {
     "status": 409,
     "text": "when this account cannot be invited"
    }
   ],
   "guard": "Repeat: each call makes a new set-password link, which replaces the one before it, and sends the e-mail again"
  },
  {
   "id": "POST /user-management/users/:id/invite/revoke",
   "group": "user-admin",
   "method": "POST",
   "path": "/user-management/users/:id/invite/revoke",
   "purpose": "Revoke the invite link. A second call answers 409, because no invitation is pending any more.",
   "auth": {
    "text": "Session + manage_users",
    "kind": "session",
    "permissions": [
     "manage_users"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { revoked: true } | 404 | 409 no pending invitation",
   "responses": [
    {
     "status": 200,
     "text": "{ revoked: true }"
    },
    {
     "status": 404,
     "text": ""
    },
    {
     "status": 409,
     "text": "no pending invitation"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": ""
    },
    {
     "status": 409,
     "text": "no pending invitation"
    }
   ],
   "guard": "Repeat: 409; no invitation is pending any more"
  },
  {
   "id": "POST /user-management/users/bulk",
   "group": "user-admin",
   "method": "POST",
   "path": "/user-management/users/bulk",
   "purpose": "Change manager, department, persona, status, group or role on up to 500 users at once.",
   "auth": {
    "text": "Session + manage_users",
    "kind": "session",
    "permissions": [
     "manage_users"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "A UserBulkUpdate: { userIds (1–500), action: setManager | setDepartment | setPersona | setStatus | addGroup | removeGroup | addRole | removeRole, value } — setStatus accepts only active, inactive or disabled",
   "response": "200 { updated, skipped: [{ id, reason }] } | 400 no users, more than 500, an unknown action, or a value that does not exist | 403 | 409 when a connected HR system owns the manager or department field",
   "responses": [
    {
     "status": 200,
     "text": "{ updated, skipped: [{ id, reason }] }"
    },
    {
     "status": 400,
     "text": "no users, more than 500, an unknown action, or a value that does not exist"
    },
    {
     "status": 403,
     "text": ""
    },
    {
     "status": 409,
     "text": "when a connected HR system owns the manager or department field"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "no users, more than 500, an unknown action, or a value that does not exist"
    },
    {
     "status": 403,
     "text": ""
    },
    {
     "status": 409,
     "text": "when a connected HR system owns the manager or department field"
    }
   ],
   "guard": "role changes additionally need manage_roles and group changes manage_groups; offboarding, deletion, password resets and MFA resets cannot be done in bulk"
  },
  {
   "id": "POST /user-management/users/:id/expire-password",
   "group": "user-admin",
   "method": "POST",
   "path": "/user-management/users/:id/expire-password",
   "purpose": "Force a new password at the next sign-in and end every live session for the account. Local accounts only. No body.",
   "auth": {
    "text": "Session + manage_users",
    "kind": "session",
    "permissions": [
     "manage_users"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { expired: true, id, email } | 404",
   "responses": [
    {
     "status": 200,
     "text": "{ expired: true, id, email }"
    },
    {
     "status": 404,
     "text": ""
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": ""
    }
   ],
   "guard": "refuses the primary administrator and the support account (403), your own account (400) and directory-sourced accounts (409). Repeat: safe for the account, but each call ends its sessions again and writes another USER_PASSWORD_EXPIRED audit entry"
  },
  {
   "id": "POST /user-management/users/expire-password",
   "group": "user-admin",
   "method": "POST",
   "path": "/user-management/users/expire-password",
   "purpose": "The same for up to 500 users. Accounts that would be refused are skipped with a reason.",
   "auth": {
    "text": "Session + manage_users",
    "kind": "session",
    "permissions": [
     "manage_users"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "A PasswordExpiryBatch: { userIds } — 1 to 500 ids",
   "response": "200 { expired: [{ id, email }], skipped: [{ id, reason }] } | 400 no users or more than 500",
   "responses": [
    {
     "status": 200,
     "text": "{ expired: [{ id, email }], skipped: [{ id, reason }] }"
    },
    {
     "status": 400,
     "text": "no users or more than 500"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "no users or more than 500"
    }
   ],
   "guard": "each user is tried on its own, so read skipped[] even on a 200. Repeat: safe for the accounts, but each call ends their sessions again and audits each one again"
  },
  {
   "id": "GET /user-management/users/identity-availability",
   "group": "user-admin",
   "method": "GET",
   "path": "/user-management/users/identity-availability",
   "purpose": "Whether an email or account name is free, with suggestions that follow the naming convention. Query: ?email, ?username, ?firstName, ?lastName, ?excludeUserId.",
   "auth": {
    "text": "Session + execute_workflows | manage_users | view_users | view_dashboard",
    "kind": "session",
    "permissions": [
     "execute_workflows",
     "manage_users",
     "view_dashboard",
     "view_users"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { available, usernameAvailable, suggestions (up to 3), usernameSuggestions, onConvention, usernameOnConvention, convention, emailDomain, strategy } — available is null, with error: 'lookup_unavailable', when the lookup itself fails",
   "responses": [
    {
     "status": 200,
     "text": "{ available, usernameAvailable, suggestions (up to 3), usernameSuggestions, onConvention, usernameOnConvention, convention, emailDomain, strategy } — available is null, with error: 'lookup_unavailable', when the lookup itself fails"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /user-management/users/:id/onboard",
   "group": "user-admin",
   "method": "POST",
   "path": "/user-management/users/:id/onboard",
   "purpose": "Start the onboarding flow for a user. Safe to repeat: while a run is in progress the call answers { idempotent: true, message } and creates no second run.",
   "auth": {
    "text": "Session + manage_users",
    "kind": "session",
    "permissions": [
     "manage_users"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "A UserOnboard: { personaId, startDate, managerId } — all optional",
   "response": "201 the run | 202 { accountPrepared, runCreated: false, warning, rehire } when the account was prepared but no run could be created | 403 when the persona grants access you do not hold",
   "responses": [
    {
     "status": 201,
     "text": "the run"
    },
    {
     "status": 202,
     "text": "{ accountPrepared, runCreated: false, warning, rehire } when the account was prepared but no run could be created"
    },
    {
     "status": 403,
     "text": "when the persona grants access you do not hold"
    }
   ],
   "errors": [
    {
     "status": 403,
     "text": "when the persona grants access you do not hold"
    }
   ],
   "guard": null
  },
  {
   "id": "POST /auth/security/users/:userId/invite",
   "group": "user-admin",
   "method": "POST",
   "path": "/auth/security/users/:userId/invite",
   "purpose": "Invite a user: activates an account that is still in preboarding and emails a sign-in link. The account is activated only after the e-mail has gone out.",
   "auth": {
    "text": "Session + manage_users",
    "kind": "session",
    "permissions": [
     "manage_users"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "userId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { sent: true, activated, status } | 404 | 409 when the account is blocked from signing in, no sign-in address is configured, or e-mail (SMTP) is not configured | 424 when the e-mail could not be sent: the account is left unchanged",
   "responses": [
    {
     "status": 200,
     "text": "{ sent: true, activated, status }"
    },
    {
     "status": 404,
     "text": ""
    },
    {
     "status": 409,
     "text": "when the account is blocked from signing in, no sign-in address is configured, or e-mail (SMTP) is not configured"
    },
    {
     "status": 424,
     "text": "when the e-mail could not be sent: the account is left unchanged"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": ""
    },
    {
     "status": 409,
     "text": "when the account is blocked from signing in, no sign-in address is configured, or e-mail (SMTP) is not configured"
    },
    {
     "status": 424,
     "text": "when the e-mail could not be sent: the account is left unchanged"
    }
   ],
   "guard": "sends real e-mail. Repeat: each call sends another e-mail with a new single-use sign-in link"
  },
  {
   "id": "POST /auth/invite/inspect",
   "group": "user-admin",
   "method": "POST",
   "path": "/auth/invite/inspect",
   "purpose": "The set-password page checks an invitation token (body: token). 410 when the link is used, revoked or expired.",
   "auth": {
    "text": "Public (rate-limited)",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ token } — required string, at most 128 characters",
   "response": "200 an InviteInspection { valid: true, email, firstName, expiresAt, policy } | 400 | 410",
   "responses": [
    {
     "status": 200,
     "text": "an InviteInspection { valid: true, email, firstName, expiresAt, policy }"
    },
    {
     "status": 400,
     "text": ""
    },
    {
     "status": 410,
     "text": ""
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": ""
    },
    {
     "status": 410,
     "text": ""
    }
   ],
   "guard": "Repeat: safe; reading an invitation does not use it up"
  },
  {
   "id": "POST /auth/invite/accept",
   "group": "user-admin",
   "method": "POST",
   "path": "/auth/invite/accept",
   "purpose": "Set the first password from an invitation (body: token, password). Creates no session; the person then signs in normally. A second call with the same token answers 410.",
   "auth": {
    "text": "Public (rate-limited)",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "An InviteAcceptRequest: { token (required, at most 128 characters), password (required, at most 1024 characters) }",
   "response": "200 { success: true, email } | 400 { error } when the password fails the policy | 410",
   "responses": [
    {
     "status": 200,
     "text": "{ success: true, email }"
    },
    {
     "status": 400,
     "text": "{ error } when the password fails the policy"
    },
    {
     "status": 410,
     "text": ""
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ error } when the password fails the policy"
    },
    {
     "status": 410,
     "text": ""
    }
   ],
   "guard": "the password must pass the password policy (400 otherwise); the token works once. Repeat: 410; the first accepted call uses the token up"
  },
  {
   "id": "POST /user-management/personas/preview/mover-plan",
   "group": "user-admin",
   "method": "POST",
   "path": "/user-management/personas/preview/mover-plan",
   "purpose": "Preview a role change (body: userId, newPersonaId): what the new persona grants and what would be revoked. Writes nothing.",
   "auth": {
    "text": "Session + manage_users | manage_personas | manage_workflows",
    "kind": "session",
    "permissions": [
     "manage_personas",
     "manage_users",
     "manage_workflows"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ userId, newPersonaId } — both required",
   "response": "200 the plan | 400 a field is missing, or the user already has that role | 404 user or role not found",
   "responses": [
    {
     "status": 200,
     "text": "the plan"
    },
    {
     "status": 400,
     "text": "a field is missing, or the user already has that role"
    },
    {
     "status": 404,
     "text": "user or role not found"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "a field is missing, or the user already has that role"
    },
    {
     "status": 404,
     "text": "user or role not found"
    }
   ],
   "guard": null
  },
  {
   "id": "POST /user-management/personas/mover",
   "group": "user-admin",
   "method": "POST",
   "path": "/user-management/personas/mover",
   "purpose": "Start a role-change run (body: userId, newPersonaId, optional keep and revokeGraceDays). Returns 201 with the run.",
   "auth": {
    "text": "Session + manage_users",
    "kind": "session",
    "permissions": [
     "manage_users"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ userId, newPersonaId (both required), keep, revokeGraceDays }",
   "response": "201 the run | 400 a required field is missing | 403",
   "responses": [
    {
     "status": 201,
     "text": "the run"
    },
    {
     "status": 400,
     "text": "a required field is missing"
    },
    {
     "status": 403,
     "text": ""
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "a required field is missing"
    },
    {
     "status": 403,
     "text": ""
    }
   ],
   "guard": "403 when the new persona grants access the caller does not hold"
  },
  {
   "id": "GET /user-management/logs/forwarding",
   "group": "ops-admin",
   "method": "GET",
   "path": "/user-management/logs/forwarding",
   "purpose": "List the log-forwarding (SIEM) destinations. No query parameters; ordered by id, never more than 10.",
   "auth": {
    "text": "Session + administrator role",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": true,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { destinations: LogForwardDestination[], types, datadogSites, health: LogForwardingHealth } (typed under Response Objects), every destination in one reply, id ascending (no paging) — a destination is { id, name, type, typeLabel, enabled, url, port, config, secretSet, cursorSeq, lastAckAt, lastAttemptAt, lastError, lastErrorAt, consecutiveFailures, nextAttemptAt, totalSent, skippedCount, lastSkip, gapCount, lagEvents, lagSeconds, createdBy, updatedBy, createdAt, updatedAt }; the secret is never returned",
   "responses": [
    {
     "status": 200,
     "text": "{ destinations: LogForwardDestination[], types, datadogSites, health: LogForwardingHealth } (typed under Response Objects), every destination in one reply, id ascending (no paging) — a destination is { id, name, type, typeLabel, enabled, url, port, config, secretSet, cursorSeq, lastAckAt, lastAttemptAt, lastError, lastErrorAt, consecutiveFailures, nextAttemptAt, totalSent, skippedCount, lastSkip, gapCount, lagEvents, lagSeconds, createdBy, updatedBy, createdAt, updatedAt }; the secret is never returned"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /user-management/logs/forwarding",
   "group": "ops-admin",
   "method": "POST",
   "path": "/user-management/logs/forwarding",
   "purpose": "Add a destination (PUT or DELETE /logs/forwarding/:id; reset its cursor at /:id/reset-cursor). Repeating the POST adds another destination, up to the limit of 10.",
   "auth": {
    "text": "Session + administrator role + sudo",
    "kind": "session",
    "permissions": [],
    "stepUp": "required",
    "administratorRole": true,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ type: webhook | splunk_hec | datadog | syslog_tls (required; cannot be changed later), name (required, at most 80 characters), enabled (default true), url (an https URL for webhook and splunk_hec; a bare host name for syslog_tls), port (syslog_tls only, 1–65535, default 6514), secret (required for webhook, splunk_hec and datadog; a webhook signing secret is at least 16 characters), config: { index, sourcetype } for Splunk, { site, tags } for Datadog, { appName, caPem } for syslog, startFrom: 'now' (default) | 'all' }. PUT takes the same fields; a blank secret keeps the stored one. Reset: { to: 'now' (default) | 'all' }",
   "response": "201 the LogForwardDestination | 400 { error } for an invalid field, a refused address, or an eleventh destination. PUT: 200 the destination | 400 | 404. DELETE: 200 { ok: true } | 404. Reset: 200 { from, to, skippedEvents, destination } | 404. Each of these four writes: 400 { error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo). The service answers 401 and the gateway returns it as 400 on this route, so match on the error text, not the status",
   "responses": [
    {
     "status": 201,
     "text": "the LogForwardDestination"
    },
    {
     "status": 400,
     "text": "{ error } for an invalid field, a refused address, or an eleventh destination. PUT: 200 the destination"
    },
    {
     "status": 400,
     "text": ""
    },
    {
     "status": 404,
     "text": ". DELETE: 200 { ok: true }"
    },
    {
     "status": 404,
     "text": ". Reset: 200 { from, to, skippedEvents, destination }"
    },
    {
     "status": 404,
     "text": ". Each of these four writes: 400 { error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo). The service answers 401 and the gateway returns it as 400 on this route, so match on the error text, not the status"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ error } for an invalid field, a refused address, or an eleventh destination. PUT: 200 the destination"
    },
    {
     "status": 400,
     "text": ""
    },
    {
     "status": 404,
     "text": ". DELETE: 200 { ok: true }"
    },
    {
     "status": 404,
     "text": ". Reset: 200 { from, to, skippedEvents, destination }"
    },
    {
     "status": 404,
     "text": ". Each of these four writes: 400 { error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo). The service answers 401 and the gateway returns it as 400 on this route, so match on the error text, not the status"
    }
   ],
   "guard": "Repeat: adds another destination with the same settings; names are not required to be unique. 400 once 10 destinations exist"
  },
  {
   "id": "POST /user-management/logs/forwarding/:id/test",
   "group": "ops-admin",
   "method": "POST",
   "path": "/user-management/logs/forwarding/:id/test",
   "purpose": "Send a test event to a destination. It carries a synthetic event, not your audit data, and is audited as LOG_FORWARD_TEST_SENT.",
   "auth": {
    "text": "Session + administrator role",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": true,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { ok, status, error } — ok is false, with the reason, when delivery failed | 404 unknown destination",
   "responses": [
    {
     "status": 200,
     "text": "{ ok, status, error } — ok is false, with the reason, when delivery failed"
    },
    {
     "status": 404,
     "text": "unknown destination"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "unknown destination"
    }
   ],
   "guard": "Repeat: each call sends the destination another test event and writes another LOG_FORWARD_TEST_SENT audit entry"
  },
  {
   "id": "GET /user-management/support/access",
   "group": "ops-admin",
   "method": "GET",
   "path": "/user-management/support/access",
   "purpose": "Whether Zonitix support currently has a sign-in to your app.",
   "auth": {
    "text": "Session + manage_security",
    "kind": "session",
    "permissions": [
     "manage_security"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 a SupportAccessStatus (typed under Response Objects) { active, grant (a SupportAccessGrant or null), account: { email, status, mfaEnrolled, invitePending, lastActiveAt } or null, history, limits: { minHours: 1, maxHours: 72 }, readOnlyPermissions, supportMailbox }",
   "responses": [
    {
     "status": 200,
     "text": "a SupportAccessStatus (typed under Response Objects) { active, grant (a SupportAccessGrant or null), account: { email, status, mfaEnrolled, invitePending, lastActiveAt } or null, history, limits: { minHours: 1, maxHours: 72 }, readOnlyPermissions, supportMailbox }"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /user-management/support/access",
   "group": "ops-admin",
   "method": "POST",
   "path": "/user-management/support/access",
   "purpose": "Grant Zonitix support a sign-in to your app. A second grant while one is active answers 409: revoke first.",
   "auth": {
    "text": "Session + administrator role + sudo",
    "kind": "session",
    "permissions": [],
    "stepUp": "required",
    "administratorRole": true,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "A SupportAccessGrantRequest: { hours (a whole number, 1–72), email (the support address the invitation goes to), reason (5–500 characters, recorded in the audit log), scope: 'read_only' (default) | 'role', roleName (required for scope role), acknowledgeAdministrator: true (required when roleName is administrator) }",
   "response": "201 { grant, invite } | 400 { error } for an invalid field or an unknown role | 400 code ADMINISTRATOR_ACK_REQUIRED | 403 for a role you cannot grant | 409 when access is already active, or the address belongs to a regular account | 400 { error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo). The service answers 401 and the gateway returns it as 400 on this route, so match on the error text, not the status",
   "responses": [
    {
     "status": 201,
     "text": "{ grant, invite }"
    },
    {
     "status": 400,
     "text": "{ error } for an invalid field or an unknown role"
    },
    {
     "status": 400,
     "text": "code ADMINISTRATOR_ACK_REQUIRED"
    },
    {
     "status": 403,
     "text": "for a role you cannot grant"
    },
    {
     "status": 409,
     "text": "when access is already active, or the address belongs to a regular account"
    },
    {
     "status": 400,
     "text": "{ error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo). The service answers 401 and the gateway returns it as 400 on this route, so match on the error text, not the status"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ error } for an invalid field or an unknown role"
    },
    {
     "status": 400,
     "text": "code ADMINISTRATOR_ACK_REQUIRED"
    },
    {
     "status": 403,
     "text": "for a role you cannot grant"
    },
    {
     "status": 409,
     "text": "when access is already active, or the address belongs to a regular account"
    },
    {
     "status": 400,
     "text": "{ error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo). The service answers 401 and the gateway returns it as 400 on this route, so match on the error text, not the status"
    }
   ],
   "guard": "Repeat: 409 while the access granted by the first call is still active"
  },
  {
   "id": "POST /user-management/support/access/revoke",
   "group": "ops-admin",
   "method": "POST",
   "path": "/user-management/support/access/revoke",
   "purpose": "Revoke support access. No body. A second call answers 409, because nothing is active.",
   "auth": {
    "text": "Session + administrator role + sudo",
    "kind": "session",
    "permissions": [],
    "stepUp": "required",
    "administratorRole": true,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { revoked: true, grant } | 409 no active support access, or it had already ended | 400 { error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo). The service answers 401 and the gateway returns it as 400 on this route, so match on the error text, not the status",
   "responses": [
    {
     "status": 200,
     "text": "{ revoked: true, grant }"
    },
    {
     "status": 409,
     "text": "no active support access, or it had already ended"
    },
    {
     "status": 400,
     "text": "{ error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo). The service answers 401 and the gateway returns it as 400 on this route, so match on the error text, not the status"
    }
   ],
   "errors": [
    {
     "status": 409,
     "text": "no active support access, or it had already ended"
    },
    {
     "status": 400,
     "text": "{ error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo). The service answers 401 and the gateway returns it as 400 on this route, so match on the error text, not the status"
    }
   ],
   "guard": "Repeat: 409; there is then no active support access to revoke"
  },
  {
   "id": "PUT /user-management/support/mailbox",
   "group": "ops-admin",
   "method": "PUT",
   "path": "/user-management/support/mailbox",
   "purpose": "Set or clear the default address the Grant support access window is pre-filled with.",
   "auth": {
    "text": "Session + administrator role",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": true,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "A SupportMailboxUpdate: { email } — a valid address, or empty to clear it",
   "response": "200 { supportMailbox } | 400 not a valid e-mail address",
   "responses": [
    {
     "status": 200,
     "text": "{ supportMailbox }"
    },
    {
     "status": 400,
     "text": "not a valid e-mail address"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "not a valid e-mail address"
    }
   ],
   "guard": "audited as SUPPORT_MAILBOX_UPDATED; no step-up, because the grant itself still needs one. Repeat: safe; the same address is stored again (each call is audited)"
  },
  {
   "id": "GET /user-management/support/diagnostics",
   "group": "ops-admin",
   "method": "GET",
   "path": "/user-management/support/diagnostics",
   "purpose": "Build the diagnostics bundle you can send to support. Audited as DIAGNOSTICS_BUNDLE_GENERATED.",
   "auth": {
    "text": "Session + manage_security + sudo",
    "kind": "session",
    "permissions": [
     "manage_security"
    ],
    "stepUp": "required",
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 a JSON attachment named zonitix-diagnostics-<timestamp>.json | 401 { error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo) | 503 when the bundle could not be built",
   "responses": [
    {
     "status": 200,
     "text": "a JSON attachment named zonitix-diagnostics-<timestamp>.json"
    },
    {
     "status": 401,
     "text": "{ error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo)"
    },
    {
     "status": 503,
     "text": "when the bundle could not be built"
    }
   ],
   "errors": [
    {
     "status": 401,
     "text": "{ error: 'SUDO_REQUIRED' } without a fresh step-up (POST /auth/sudo)"
    },
    {
     "status": 503,
     "text": "when the bundle could not be built"
    }
   ],
   "guard": null
  },
  {
   "id": "POST /compliance/reports/generate",
   "group": "report-engine",
   "method": "POST",
   "path": "/compliance/reports/generate",
   "purpose": "Build a report on demand.",
   "auth": {
    "text": "Session + view_reporting (licensed)",
    "kind": "session",
    "permissions": [
     "view_reporting"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ kind? ('compliance' default | overview | query | lifecycle | device-posture | budget | crosswalk | type2), framework (required for compliance and type2; must be in the catalogue), frameworks[]? (crosswalk: none or at least two), queries[]?, columns?, groupBy?, variant? (budget), filters? (object), window? { from, to } (required for type2), lifecycleType? ('leaver' | 'joiner'), sections[]? (summary | controls | tables), formats[]?, seal? (true only), reportKey? }",
   "response": "200 the report as JSON; raw rows are capped at 250 for the preview. A compliance run adds seal { status: 'queued' | 'not_sealed' }; a type2 run adds archive { status: 'sealing', runId } | { status: 'not_sealed' } | { status: 'failed' }. 400 with the reason for any unknown kind, framework, data set, section or a bad window.",
   "responses": [
    {
     "status": 200,
     "text": "the report as JSON; raw rows are capped at 250 for the preview. A compliance run adds seal { status: 'queued' | 'not_sealed' }; a type2 run adds archive { status: 'sealing', runId } | { status: 'not_sealed' } | { status: 'failed' }. 400 with the reason for any unknown kind, framework, data set, section or a bad window."
    }
   ],
   "errors": [],
   "guard": "Only seal: true writes to the evidence vault. Repeat: a second sealing run of the same definition shortly after is joined onto the first (coalesced), not sealed twice."
  },
  {
   "id": "POST /compliance/reports/download",
   "group": "report-engine",
   "method": "POST",
   "path": "/compliance/reports/download",
   "purpose": "Download a report file.",
   "auth": {
    "text": "Session + view_reporting (licensed)",
    "kind": "session",
    "permissions": [
     "view_reporting"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query ?format= pdf (default) | csv | xlsx | pptx | zip. Body: the same definition as /generate, plus name? used in the file name.",
   "response": "200 the file as an attachment. format=zip archives the report to the vault first and returns the auditor pack built from the stored bytes, with header X-Report-Run-Id. 400 \"Unknown format\" or a rejected definition | 409 when a stored file fails its integrity check.",
   "responses": [
    {
     "status": 200,
     "text": "the file as an attachment. format=zip archives the report to the vault first and returns the auditor pack built from the stored bytes, with header X-Report-Run-Id. 400 \"Unknown format\" or a rejected definition"
    },
    {
     "status": 409,
     "text": "when a stored file fails its integrity check."
    }
   ],
   "errors": [
    {
     "status": 409,
     "text": "when a stored file fails its integrity check."
    }
   ],
   "guard": "Repeat: every zip download archives a new vault run."
  },
  {
   "id": "GET /compliance/reports/schedules",
   "group": "report-engine",
   "method": "GET",
   "path": "/compliance/reports/schedules",
   "purpose": "List scheduled reports.",
   "auth": {
    "text": "Session + view_reporting (licensed)",
    "kind": "session",
    "permissions": [
     "view_reporting"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { schedules: ReportSchedule[], smtpConfigured: boolean, frameworks: string[] } (ReportSchedule is typed under Response Objects). Everything, newest first; no paging parameters are read. Each schedule carries webhookUrl masked to host and last four characters, webhookConfigured, webhookSigned and owner.",
   "responses": [
    {
     "status": 200,
     "text": "{ schedules: ReportSchedule[], smtpConfigured: boolean, frameworks: string[] } (ReportSchedule is typed under Response Objects). Everything, newest first; no paging parameters are read. Each schedule carries webhookUrl masked to host and last four characters, webhookConfigured, webhookSigned and owner."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /compliance/reports/schedules",
   "group": "report-engine",
   "method": "POST",
   "path": "/compliance/reports/schedules",
   "purpose": "Create a scheduled report (PUT or DELETE /schedules/:id).",
   "auth": {
    "text": "Session + manage_compliance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ name (required, up to 120 characters), everyHours (required, whole number 1 to 2160), kind?, framework (required for compliance and type2), recipients[]? (email addresses, at most 50, checked against the recipient policy), formats[]? (pdf | csv | xlsx | pptx; default ['pdf']), webhookUrl?, queries[]?, columns?, groupBy?, variant?, filters?, window?, sections[]?, lifecycleType?, enabled? }. PUT takes any subset; a blank webhookUrl leaves the stored one, null removes it.",
   "response": "201 a ReportSchedule (typed under Response Objects); when a webhook is set, webhookSigningSecret: string is included once and never again. PUT 200 the ReportSchedule. DELETE 200 { ok: true }; the run history is kept. 400 with the reason | 404 \"That scheduled report no longer exists.\"",
   "responses": [
    {
     "status": 201,
     "text": "a ReportSchedule (typed under Response Objects); when a webhook is set, webhookSigningSecret: string is included once and never again. PUT 200 the ReportSchedule. DELETE 200 { ok: true }; the run history is kept. 400 with the reason"
    },
    {
     "status": 404,
     "text": "\"That scheduled report no longer exists.\""
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "\"That scheduled report no longer exists.\""
    }
   ],
   "guard": "Repeat: POST is not deduplicated, so sending it twice creates two schedules. PUT replaces the fields you send."
  },
  {
   "id": "POST /compliance/reports/schedules/:id/run",
   "group": "report-engine",
   "method": "POST",
   "path": "/compliance/reports/schedules/:id/run",
   "purpose": "Run a scheduled report now.",
   "auth": {
    "text": "Session + manage_compliance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "202 { runId: integer, status: 'running' }. The run continues in the background; poll GET /runs or /runs/:id/status. 404 when the schedule is gone | 409 \"Not started\" with the reason when a run of this schedule is already live.",
   "responses": [
    {
     "status": 202,
     "text": "{ runId: integer, status: 'running' }. The run continues in the background; poll GET /runs or /runs/:id/status. 404 when the schedule is gone"
    },
    {
     "status": 409,
     "text": "\"Not started\" with the reason when a run of this schedule is already live."
    }
   ],
   "errors": [
    {
     "status": 409,
     "text": "\"Not started\" with the reason when a run of this schedule is already live."
    }
   ],
   "guard": "Sends real email to the schedule's recipients. Repeat: refused with 409 while the first run is still going."
  },
  {
   "id": "GET /compliance/reports/runs",
   "group": "report-engine",
   "method": "GET",
   "path": "/compliance/reports/runs",
   "purpose": "The sealed run history (status at /runs/:id/status, the file at /runs/:id/artifact).",
   "auth": {
    "text": "Session + view_reporting (licensed)",
    "kind": "session",
    "permissions": [
     "view_reporting"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [
    "limit",
    "cursor"
   ],
   "query": [
    {
     "name": "limit",
     "type": "integer",
     "default": 50,
     "minimum": 1,
     "maximum": 200,
     "note": "Run history: 1 to 200. With vault: 1 to 100."
    },
    {
     "name": "scheduleId",
     "type": "integer"
    },
    {
     "name": "vault",
     "type": "string",
     "note": "Any value selects the evidence vault. The parameters below are read only with it."
    },
    {
     "name": "framework",
     "type": "string"
    },
    {
     "name": "trigger",
     "type": "string",
     "enum": [
      "scheduled",
      "manual"
     ]
    },
    {
     "name": "from",
     "type": "string"
    },
    {
     "name": "to",
     "type": "string"
    },
    {
     "name": "cursor",
     "type": "string",
     "note": "The nextCursor of the reply before."
    }
   ],
   "replyShapes": [
    "ReportRun[]",
    "{ runs: ReportRun[], nextCursor: string | null }"
   ],
   "request": "Query, run history: limit (default 50, 1 to 200), scheduleId. Query, evidence vault (?vault=1): framework, trigger (scheduled | manual), scheduleId, from, to (yyyy-mm-dd or ISO; to includes the whole day), limit (default 50, 1 to 100), cursor.",
   "response": "200 ReportRun[] for the run history: a bare array, newest first. Vault: { runs: ReportRun[], nextCursor: string | null }; pass nextCursor back as cursor, null on the last page. ReportRun is typed under Response Objects. Rows never carry file bytes: they carry hasPdf, hasCsv, hasXlsx, the hashes, legalHold, retainUntil, status, error and verifyUrl. Runs you may not open are left out. 400 for a bad scheduleId, date, trigger, framework or cursor.",
   "responses": [
    {
     "status": 200,
     "text": "ReportRun[] for the run history: a bare array, newest first. Vault: { runs: ReportRun[], nextCursor: string | null }; pass nextCursor back as cursor, null on the last page. ReportRun is typed under Response Objects. Rows never carry file bytes: they carry hasPdf, hasCsv, hasXlsx, the hashes, legalHold, retainUntil, status, error and verifyUrl. Runs you may not open are left out. 400 for a bad scheduleId, date, trigger, framework or cursor."
    }
   ],
   "errors": [],
   "guard": "/runs/:id/artifact takes ?format= pdf (default) | csv | xlsx and answers with headers X-Artifact-SHA256 and X-Artifact-Integrity; 404 when no file is stored, 409 when the tamper check fails."
  },
  {
   "id": "POST /compliance/reports/runs/:id/hold",
   "group": "report-engine",
   "method": "POST",
   "path": "/compliance/reports/runs/:id/hold",
   "purpose": "Put a run on hold, or release it.",
   "auth": {
    "text": "Session + manage_compliance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "A ReportRunHold: { legalHold: true | false }. Without the field the hold is flipped.",
   "response": "200 { id: integer, legalHold: boolean } | 200 { id, legalHold, unchanged: true } when it was already in that state | 400 \"legalHold must be true or false.\" | 404 \"That run no longer exists.\"",
   "responses": [
    {
     "status": 200,
     "text": "{ id: integer, legalHold: boolean }"
    },
    {
     "status": 200,
     "text": "{ id, legalHold, unchanged: true } when it was already in that state"
    },
    {
     "status": 400,
     "text": "\"legalHold must be true or false.\""
    },
    {
     "status": 404,
     "text": "\"That run no longer exists.\""
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "\"legalHold must be true or false.\""
    },
    {
     "status": 404,
     "text": "\"That run no longer exists.\""
    }
   ],
   "guard": "Repeat: safe when you send legalHold; a call with no body flips the hold each time."
  },
  {
   "id": "POST /compliance/reports/runs/:id/share",
   "group": "report-engine",
   "method": "POST",
   "path": "/compliance/reports/runs/:id/share",
   "purpose": "Create a share link to a sealed run (list with GET; revoke with DELETE /runs/share/:linkId).",
   "auth": {
    "text": "Session + manage_compliance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "A ReportShareCreate: { format? ('pdf' default | 'csv'), confirmCsv (must be true for csv), ttlHours? (default 168; 1 to 2160), recipientEmail?, recipientLabel? (up to 80 characters) }",
   "response": "201 a ReportShareLink (typed under Response Objects) plus token: string. The token is returned this once; lists show tokenHint only. GET 200 { links: ReportShareLink[] }, newest first; the GET list needs only view_reporting. DELETE 200 { ok: boolean }. 400 CSV_CONFIRM_REQUIRED, or when the run has no stored file in that format or is not sealed | 404 when the run or link does not exist.",
   "responses": [
    {
     "status": 201,
     "text": "a ReportShareLink (typed under Response Objects) plus token: string. The token is returned this once; lists show tokenHint only. GET 200 { links: ReportShareLink[] }, newest first; the GET list needs only view_reporting. DELETE 200 { ok: boolean }. 400 CSV_CONFIRM_REQUIRED, or when the run has no stored file in that format or is not sealed"
    },
    {
     "status": 404,
     "text": "when the run or link does not exist."
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "when the run or link does not exist."
    }
   ],
   "guard": "Repeat: each POST mints another link. Revoking an already revoked link answers { ok: true } again."
  },
  {
   "id": "GET /compliance/reports/runs/:id/share",
   "group": "report-engine",
   "method": "GET",
   "path": "/compliance/reports/runs/:id/share",
   "purpose": "List the share links of one sealed run.",
   "auth": {
    "text": "Session + view_reporting (licensed)",
    "kind": "session",
    "permissions": [
     "view_reporting"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { links: ReportShareLink[] } (typed under Response Objects); each carries tokenHint, never the token | 404 \"That evidence run does not exist.\"",
   "responses": [
    {
     "status": 200,
     "text": "{ links: ReportShareLink[] } (typed under Response Objects); each carries tokenHint, never the token"
    },
    {
     "status": 404,
     "text": "\"That evidence run does not exist.\""
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "\"That evidence run does not exist.\""
    }
   ],
   "guard": null
  },
  {
   "id": "DELETE /compliance/reports/runs/share/:linkId",
   "group": "report-engine",
   "method": "DELETE",
   "path": "/compliance/reports/runs/share/:linkId",
   "purpose": "Revoke one share link.",
   "auth": {
    "text": "Session + manage_compliance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "linkId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { ok: boolean } | 404 \"That share link does not exist.\"",
   "responses": [
    {
     "status": 200,
     "text": "{ ok: boolean }"
    },
    {
     "status": 404,
     "text": "\"That share link does not exist.\""
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "\"That share link does not exist.\""
    }
   ],
   "guard": "A revocation is audited as REPORT_SHARE_LINK_REVOKED. Repeat: safe; revoking a link that is already revoked answers 200 again."
  },
  {
   "id": "GET /compliance/reports/runs/share/:linkId/views",
   "group": "report-engine",
   "method": "GET",
   "path": "/compliance/reports/runs/share/:linkId/views",
   "purpose": "Recent public accesses of one share link, served and refused, read back from the audit log.",
   "auth": {
    "text": "Session + view_reporting (licensed)",
    "kind": "session",
    "permissions": [
     "view_reporting"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "linkId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { linkId, views[] } | 404 \"That share link does not exist.\"",
   "responses": [
    {
     "status": 200,
     "text": "{ linkId, views[] }"
    },
    {
     "status": 404,
     "text": "\"That share link does not exist.\""
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "\"That share link does not exist.\""
    }
   ],
   "guard": null
  },
  {
   "id": "GET /compliance/reports/saved",
   "group": "report-engine",
   "method": "GET",
   "path": "/compliance/reports/saved",
   "purpose": "Saved report definitions (POST to save one, DELETE /saved/:id).",
   "auth": {
    "text": "Session + view_reporting (licensed)",
    "kind": "session",
    "permissions": [
     "view_reporting"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "POST: { name (required, up to 120 characters), format? ('table' | 'pdf') } plus the same definition fields as /generate.",
   "response": "200 SavedReport[] (typed under Response Objects) for the GET: a bare array, most recently updated first, at most 200; your own, or everyone's with manage_compliance. POST 201 the SavedReport. DELETE 200 { ok: true } | 403 \"That saved report belongs to someone else.\" | 404.",
   "responses": [
    {
     "status": 200,
     "text": "SavedReport[] (typed under Response Objects) for the GET: a bare array, most recently updated first, at most 200; your own, or everyone's with manage_compliance. POST 201 the SavedReport. DELETE 200 { ok: true }"
    },
    {
     "status": 403,
     "text": "\"That saved report belongs to someone else.\""
    },
    {
     "status": 404,
     "text": "."
    }
   ],
   "errors": [
    {
     "status": 403,
     "text": "\"That saved report belongs to someone else.\""
    },
    {
     "status": 404,
     "text": "."
    }
   ],
   "guard": "Repeat: POST is not deduplicated."
  },
  {
   "id": "POST /compliance/reports/vault/archive",
   "group": "report-engine",
   "method": "POST",
   "path": "/compliance/reports/vault/archive",
   "purpose": "Archive a report into the vault now.",
   "auth": {
    "text": "Session + view_reporting (licensed)",
    "kind": "session",
    "permissions": [
     "view_reporting"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The same definition as /generate; name? defaults to 'ad-hoc evidence'.",
   "response": "201 { id: integer, framework: string | null, readinessScore: integer | null, artifactHash: string | null, coalesced?: true } | 400 with the reason when the report could not be generated.",
   "responses": [
    {
     "status": 201,
     "text": "{ id: integer, framework: string | null, readinessScore: integer | null, artifactHash: string | null, coalesced?: true }"
    },
    {
     "status": 400,
     "text": "with the reason when the report could not be generated."
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "with the reason when the report could not be generated."
    }
   ],
   "guard": "Nothing is emailed. Repeat: a double click seals once; the second answer carries the same run id and coalesced: true."
  },
  {
   "id": "GET /compliance/reports/compliance/policies",
   "group": "compliance-hub",
   "method": "GET",
   "path": "/compliance/reports/compliance/policies",
   "purpose": "The policy register.",
   "auth": {
    "text": "Session + view_reporting | manage_compliance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_reporting"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { policies: Policy[] } (typed under Response Objects), sorted by name, without the policy text. The register holds at most 200 policies.",
   "responses": [
    {
     "status": 200,
     "text": "{ policies: Policy[] } (typed under Response Objects), sorted by name, without the policy text. The register holds at most 200 policies."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /compliance/reports/compliance/policies",
   "group": "compliance-hub",
   "method": "POST",
   "path": "/compliance/reports/compliance/policies",
   "purpose": "Add a policy (PUT or DELETE /policies/:id).",
   "auth": {
    "text": "Session + manage_compliance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ name (required, up to 140 characters), version? (default '1.0', up to 20), owner? (up to 120), link? (http or https, up to 500), description? (up to 500), frameworks[]? (up to 8), requireAck?, audience?, signSections?, nextReviewDue? (yyyy-mm-dd), controlIds[]?, body? (the policy text, up to 40,000 characters) }",
   "response": "201 the Policy (typed under Response Objects), created as a draft | PUT 200 the Policy | DELETE 200 { deleted: true }. 400 \"A policy name is required.\", a link that is not a web address, or a full register | 404 \"Policy not found\".",
   "responses": [
    {
     "status": 201,
     "text": "the Policy (typed under Response Objects), created as a draft | PUT 200 the Policy | DELETE 200 { deleted: true }. 400 \"A policy name is required.\", a link that is not a web address, or a full register"
    },
    {
     "status": 404,
     "text": "\"Policy not found\"."
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "\"Policy not found\"."
    }
   ],
   "guard": "A new policy counts as evidence only after it is approved. Editing the name, version, text, link or mapped controls of an approved policy returns it to draft. Repeat: POST is not deduplicated."
  },
  {
   "id": "POST /compliance/reports/compliance/policies/:id/ack",
   "group": "compliance-hub",
   "method": "POST",
   "path": "/compliance/reports/compliance/policies/:id/ack",
   "purpose": "Sign a policy as yourself (your own list at /policies/ack/mine). See Policy Signing & People Evidence for the signing flow.",
   "auth": {
    "text": "Session (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "string",
     "basis": "unknown"
    }
   ],
   "paging": [],
   "query": [],
   "request": "{ signedName: string (2 characters or more), contentHash: string (the hash of the text you read), sections: string[] (the hash of every section, as confirmed) }",
   "response": "201 { policyId: string, name: string, version: string, contentHash: string, sections: integer, signedName: string, acknowledgedAt: timestamp, alreadyAcknowledged: boolean, renewal: boolean, kind?: 'first' | 'renewal' | 'text-changed', nameMatches?: boolean | null }. sections is the number of sections in the text you signed. kind and nameMatches are present only on a new signature; nameMatches is recorded, never enforced, and is null when no name is on file for you. 400 \"Type your full name to sign.\" | 400 \"Confirm every section before you sign ...\" | 401 \"No authenticated user.\" | 404 \"That policy is not available for signing.\" | 409 \"This policy changed while you were reading it. Reload and read the current text.\"",
   "responses": [
    {
     "status": 201,
     "text": "{ policyId: string, name: string, version: string, contentHash: string, sections: integer, signedName: string, acknowledgedAt: timestamp, alreadyAcknowledged: boolean, renewal: boolean, kind?: 'first' | 'renewal' | 'text-changed', nameMatches?: boolean | null }. sections is the number of sections in the text you signed. kind and nameMatches are present only on a new signature; nameMatches is recorded, never enforced, and is null when no name is on file for you. 400 \"Type your full name to sign.\""
    },
    {
     "status": 400,
     "text": "\"Confirm every section before you sign ...\""
    },
    {
     "status": 401,
     "text": "\"No authenticated user.\""
    },
    {
     "status": 404,
     "text": "\"That policy is not available for signing.\""
    },
    {
     "status": 409,
     "text": "\"This policy changed while you were reading it. Reload and read the current text.\""
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "\"Confirm every section before you sign ...\""
    },
    {
     "status": 401,
     "text": "\"No authenticated user.\""
    },
    {
     "status": 404,
     "text": "\"That policy is not available for signing.\""
    },
    {
     "status": 409,
     "text": "\"This policy changed while you were reading it. Reload and read the current text.\""
    }
   ],
   "guard": "Repeat: signing again while your signature is current answers 201 with alreadyAcknowledged: true and writes no new audit entry or email. A new signature is written to the audit log and confirmed to you by email."
  },
  {
   "id": "GET /compliance/reports/compliance/risks",
   "group": "compliance-hub",
   "method": "GET",
   "path": "/compliance/reports/compliance/risks",
   "purpose": "The risk register.",
   "auth": {
    "text": "Session + view_reporting | manage_compliance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_reporting"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { risks: Risk[] } (typed under Response Objects), highest score first, then most recently updated.",
   "responses": [
    {
     "status": 200,
     "text": "{ risks: Risk[] } (typed under Response Objects), highest score first, then most recently updated."
    }
   ],
   "errors": [],
   "guard": "Everything in one answer; no paging parameters are read."
  },
  {
   "id": "POST /compliance/reports/compliance/risks",
   "group": "compliance-hub",
   "method": "POST",
   "path": "/compliance/reports/compliance/risks",
   "purpose": "Add a risk (PUT or DELETE /risks/:id).",
   "auth": {
    "text": "Session + manage_compliance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ title (required, up to 140 characters), description? (up to 1,000), category? (default 'General', up to 60), likelihood?, impact? (1 to 5, default 3; out-of-range values are clamped), status? (open | treating | accepted | closed), treatment? (up to 1,000), owner? (up to 120), controlIds[]? (catalogue ids, at most 40) }",
   "response": "201 the Risk (typed under Response Objects), with score = likelihood x impact | PUT 200 the Risk | DELETE 200 { deleted: true }. 400 \"A risk title is required.\" or when the register already holds 500 risks | 404 \"Risk not found\".",
   "responses": [
    {
     "status": 201,
     "text": "the Risk (typed under Response Objects), with score = likelihood x impact | PUT 200 the Risk | DELETE 200 { deleted: true }. 400 \"A risk title is required.\" or when the register already holds 500 risks"
    },
    {
     "status": 404,
     "text": "\"Risk not found\"."
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "\"Risk not found\"."
    }
   ],
   "guard": "An owner that is an email address is told by email when first named. Repeat: POST is not deduplicated; PUT overwrites the fields you send."
  },
  {
   "id": "GET /compliance/reports/compliance/vendors",
   "group": "compliance-hub",
   "method": "GET",
   "path": "/compliance/reports/compliance/vendors",
   "purpose": "The vendor register.",
   "auth": {
    "text": "Session + view_reporting | manage_compliance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_reporting"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { vendors: Vendor[] } (typed under Response Objects), sorted by name. Each carries its review state (reviewStatus, reviewer, riskDecision, nextReviewDue, documents) and its reviews[] history, at most 20 entries.",
   "responses": [
    {
     "status": 200,
     "text": "{ vendors: Vendor[] } (typed under Response Objects), sorted by name. Each carries its review state (reviewStatus, reviewer, riskDecision, nextReviewDue, documents) and its reviews[] history, at most 20 entries."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /compliance/reports/compliance/vendors",
   "group": "compliance-hub",
   "method": "POST",
   "path": "/compliance/reports/compliance/vendors",
   "purpose": "Add a vendor (PUT or DELETE /vendors/:id; reviews at /vendors/:id/request-review and /complete-review).",
   "auth": {
    "text": "Session + manage_compliance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ name (required, up to 140 characters), category? (default 'SaaS'), tier? (low | medium | high | critical; default medium), owner? (up to 120), notes? (up to 500), soc2OnFile?, dataShared?, reviewedAt?, nextReviewDue? (yyyy-mm-dd; null clears) }. request-review: { reviewer?, dueDate? }. complete-review: { decision (approved | conditional | rejected, required), documents? { soc2, iso, pentest, dpa, questionnaire }, riskRating?, summary? (up to 1,000), nextReviewDue?, reviewer? }",
   "response": "201 the Vendor (typed under Response Objects) | PUT and both review calls 200 the Vendor | DELETE 200 { deleted: true }. 400 \"A vendor name is required.\", a missing decision, or a register that already holds 500 vendors | 404 \"Vendor not found\".",
   "responses": [
    {
     "status": 201,
     "text": "the Vendor (typed under Response Objects) | PUT and both review calls 200 the Vendor | DELETE 200 { deleted: true }. 400 \"A vendor name is required.\", a missing decision, or a register that already holds 500 vendors"
    },
    {
     "status": 404,
     "text": "\"Vendor not found\"."
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "\"Vendor not found\"."
    }
   ],
   "guard": "A PUT that carries an updatedAt different from the stored one keeps the stored review fields (reviewStatus, reviewedAt, reviewer, riskDecision, nextReviewDue, documents), so a stale edit cannot undo a completed review. Repeat: POST is not deduplicated; each review call appends a history entry."
  },
  {
   "id": "GET /compliance/reports/compliance/soa",
   "group": "compliance-hub",
   "method": "GET",
   "path": "/compliance/reports/compliance/soa",
   "purpose": "The Statement of Applicability.",
   "auth": {
    "text": "Session + view_reporting | manage_compliance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_reporting"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query ?framework= (required, a catalogue framework).",
   "response": "400 \"A valid framework is required.\" without it.",
   "responses": [
    {
     "status": 400,
     "text": "\"A valid framework is required.\" without it."
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "\"A valid framework is required.\" without it."
    }
   ],
   "guard": null
  },
  {
   "id": "GET /compliance/reports/compliance/type-two",
   "group": "compliance-hub",
   "method": "GET",
   "path": "/compliance/reports/compliance/type-two",
   "purpose": "How each control of one framework held over a period, counted from the automatic snapshots taken in that period.",
   "auth": {
    "text": "Session + view_reporting | manage_compliance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_reporting"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query ?framework= (required, a catalogue framework); ?days= (default 90, 1 to 400), or ?from= and ?to= together (YYYY-MM-DD), which take the place of ?days.",
   "response": "200 a TypeTwoReport (typed under Response Objects) | 400 \"A valid framework is required.\"",
   "responses": [
    {
     "status": 200,
     "text": "a TypeTwoReport (typed under Response Objects)"
    },
    {
     "status": 400,
     "text": "\"A valid framework is required.\""
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "\"A valid framework is required.\""
    }
   ],
   "guard": null
  },
  {
   "id": "GET /compliance/reports/compliance/environment",
   "group": "compliance-hub",
   "method": "GET",
   "path": "/compliance/reports/compliance/environment",
   "purpose": "The environment profile: the systems your organisation has declared it uses.",
   "auth": {
    "text": "Session + view_reporting | manage_compliance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_reporting"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 an EnvironmentProfile (typed under Response Objects); empty lists and null stamps before the first save.",
   "responses": [
    {
     "status": 200,
     "text": "an EnvironmentProfile (typed under Response Objects); empty lists and null stamps before the first save."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /compliance/reports/compliance/environment",
   "group": "compliance-hub",
   "method": "POST",
   "path": "/compliance/reports/compliance/environment",
   "purpose": "Replace the environment profile.",
   "auth": {
    "text": "Session + manage_compliance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ selected?: string[] (duplicates dropped, at most 300 kept), custom?: [{ name (required, cut to 80 characters), id? (defaults to a slug of the name), category? (default 'Other', cut to 40 characters) }] (at most 100 kept) }. A list left out is saved as empty.",
   "response": "200 the EnvironmentProfile as saved",
   "responses": [
    {
     "status": 200,
     "text": "the EnvironmentProfile as saved"
    }
   ],
   "errors": [],
   "guard": "Replaces the whole profile; safe to repeat. Audited as COMPLIANCE_ENVIRONMENT_UPDATED."
  },
  {
   "id": "POST /compliance/reports/compliance/soa/seal",
   "group": "compliance-hub",
   "method": "POST",
   "path": "/compliance/reports/compliance/soa/seal",
   "purpose": "Seal the current Statement of Applicability.",
   "auth": {
    "text": "Session + manage_compliance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ framework } (or ?framework=)",
   "response": "201 { runId, framework, sealed, generatedAt } | 400 \"A valid framework is required.\"",
   "responses": [
    {
     "status": 201,
     "text": "{ runId, framework, sealed, generatedAt }"
    },
    {
     "status": 400,
     "text": "\"A valid framework is required.\""
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "\"A valid framework is required.\""
    }
   ],
   "guard": "Download the sealed file through /compliance/reports/runs/:runId/artifact. Repeat: each call seals a new run."
  },
  {
   "id": "GET /compliance/reports/compliance/evidence",
   "group": "compliance-hub",
   "method": "GET",
   "path": "/compliance/reports/compliance/evidence",
   "purpose": "Evidence attached to controls.",
   "auth": {
    "text": "Session + view_reporting | manage_compliance | view_assigned_compliance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_assigned_compliance",
     "view_reporting"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query ?controlId= (without it the list is empty), framework?",
   "response": "200 { items: EvidenceItem[] } (typed under Response Objects), newest first, at most 100 | 403 \"You can only view evidence for a control assigned to you.\"",
   "responses": [
    {
     "status": 200,
     "text": "{ items: EvidenceItem[] } (typed under Response Objects), newest first, at most 100"
    },
    {
     "status": 403,
     "text": "\"You can only view evidence for a control assigned to you.\""
    }
   ],
   "errors": [
    {
     "status": 403,
     "text": "\"You can only view evidence for a control assigned to you.\""
    }
   ],
   "guard": null
  },
  {
   "id": "POST /compliance/reports/compliance/evidence",
   "group": "compliance-hub",
   "method": "POST",
   "path": "/compliance/reports/compliance/evidence",
   "purpose": "Attach evidence to a control (DELETE /evidence/:id removes it).",
   "auth": {
    "text": "Session + manage_compliance | view_assigned_compliance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_assigned_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "An EvidenceItemCreate: { controlId, url (both required), kind? ('file' for a file already uploaded to this server; anything else is a link, which must be http or https), label?, framework? }",
   "response": "201 the EvidenceItem (typed under Response Objects) | DELETE 200 { ok }. 400 for a missing control or url, a link that is not http(s), or a file this server did not store | 403 when the control is not assigned to you, or on DELETE when you did not attach it.",
   "responses": [
    {
     "status": 201,
     "text": "the EvidenceItem (typed under Response Objects) | DELETE 200 { ok }. 400 for a missing control or url, a link that is not http(s), or a file this server did not store"
    },
    {
     "status": 403,
     "text": "when the control is not assigned to you, or on DELETE when you did not attach it."
    }
   ],
   "errors": [
    {
     "status": 403,
     "text": "when the control is not assigned to you, or on DELETE when you did not attach it."
    }
   ],
   "guard": "A file's SHA-256 is computed by the server from the stored bytes; a value sent in the body is ignored. Repeat: POST is not deduplicated."
  },
  {
   "id": "GET /compliance/reports/compliance/trust-center",
   "group": "compliance-hub",
   "method": "GET",
   "path": "/compliance/reports/compliance/trust-center",
   "purpose": "Read the trust-center settings.",
   "auth": {
    "text": "Session + view_reporting | manage_compliance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_reporting"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 TrustCenterSettings (typed under Response Objects): { published: boolean, headline: string, intro: string, frameworks: string[], showReadiness: boolean, practices: [{ title: string, detail: string }], autoPractices: boolean, contactEmail: string, updatedAt: timestamp | null, updatedBy: string | null }",
   "responses": [
    {
     "status": 200,
     "text": "TrustCenterSettings (typed under Response Objects): { published: boolean, headline: string, intro: string, frameworks: string[], showReadiness: boolean, practices: [{ title: string, detail: string }], autoPractices: boolean, contactEmail: string, updatedAt: timestamp | null, updatedBy: string | null }"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /compliance/reports/compliance/trust-center",
   "group": "compliance-hub",
   "method": "POST",
   "path": "/compliance/reports/compliance/trust-center",
   "purpose": "Change the trust-center settings, including whether it is published.",
   "auth": {
    "text": "Session + manage_compliance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ published, headline (up to 140 characters), intro (up to 1,000), frameworks[] (catalogue names, up to 8), showReadiness, practices[{ title (up to 80), detail (up to 240) }] (up to 20), autoPractices, contactEmail (up to 120) }",
   "response": "200 the TrustCenterSettings as saved.",
   "responses": [
    {
     "status": 200,
     "text": "the TrustCenterSettings as saved."
    }
   ],
   "errors": [],
   "guard": "The body replaces the whole configuration: a field you leave out is saved as empty or false, and published is true only when sent as true. Repeat: safe."
  },
  {
   "id": "GET /compliance/reports/compliance/findings/:controlId/affected",
   "group": "compliance-hub",
   "method": "GET",
   "path": "/compliance/reports/compliance/findings/:controlId/affected",
   "purpose": "The records behind one failing control: which accounts or audit-log entries its automated checks flagged. Query: ?framework (required, a framework of the catalogue), ?q (search, the first 200 characters are used), ?limit (default 50, 1–200) and ?cursor (send back nextCursor from the reply before to get the next page).",
   "auth": {
    "text": "Session + view_reporting | manage_compliance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_reporting"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "controlId",
     "type": "string",
     "basis": "model"
    }
   ],
   "paging": [
    "limit",
    "cursor"
   ],
   "query": [
    {
     "name": "framework",
     "type": "string",
     "required": true
    },
    {
     "name": "q",
     "type": "string",
     "note": "A longer value is cut to 200 characters."
    },
    {
     "name": "limit",
     "type": "integer",
     "default": 50,
     "minimum": 1,
     "maximum": 200
    },
    {
     "name": "cursor",
     "type": "string",
     "note": "The nextCursor of the reply before."
    }
   ],
   "request": "Query string, not a JSON body: ?framework, ?q, ?limit, ?cursor.",
   "response": "200 { framework, controlId, control (its title), datasets:string[], rows[], total:integer (rows matching the search), limit:integer, nextCursor, exactCount:integer, populationRows:integer, truncated:boolean, issues, fixLink?, asOf?, note? } — a control with no record-level check answers rows:[] with a note saying so and without fixLink and asOf; note is also present when truncated is true | 400 { error } for an unknown framework or a control id that is not 1 to 64 letters, digits, dots, hyphens or underscores | 403 { error } naming the permission needed, when the control's checks read user accounts or audit-log entries your permissions do not cover | 404 { error } when the control is not part of that framework",
   "responses": [
    {
     "status": 200,
     "text": "{ framework, controlId, control (its title), datasets:string[], rows[], total:integer (rows matching the search), limit:integer, nextCursor, exactCount:integer, populationRows:integer, truncated:boolean, issues, fixLink?, asOf?, note? } — a control with no record-level check answers rows:[] with a note saying so and without fixLink and asOf; note is also present when truncated is true"
    },
    {
     "status": 400,
     "text": "{ error } for an unknown framework or a control id that is not 1 to 64 letters, digits, dots, hyphens or underscores"
    },
    {
     "status": 403,
     "text": "{ error } naming the permission needed, when the control's checks read user accounts or audit-log entries your permissions do not cover"
    },
    {
     "status": 404,
     "text": "{ error } when the control is not part of that framework"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "{ error } for an unknown framework or a control id that is not 1 to 64 letters, digits, dots, hyphens or underscores"
    },
    {
     "status": 403,
     "text": "{ error } naming the permission needed, when the control's checks read user accounts or audit-log entries your permissions do not cover"
    },
    {
     "status": 404,
     "text": "{ error } when the control is not part of that framework"
    }
   ],
   "guard": "read-only and safe to repeat; the list behind it is recomputed at most every 2 minutes per control, so two calls inside that window page the same list"
  },
  {
   "id": "GET /compliance/reports/compliance/findings/:controlId/affected.csv",
   "group": "compliance-hub",
   "method": "GET",
   "path": "/compliance/reports/compliance/findings/:controlId/affected.csv",
   "purpose": "The same list as a CSV file: every row that matches the search, not one page. Query: ?framework (required) and ?q.",
   "auth": {
    "text": "Session + view_reporting | manage_compliance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_reporting"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "controlId",
     "type": "string",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "Query string, not a JSON body: ?framework, ?q.",
   "response": "200 text/csv as an attachment, with the columns Identity, Type, Why it is affected, Detail, Severity, Status, When, Where to fix; a closing Note line is added when the list is a sample of a larger population | 400, 403 and 404 as the paged read",
   "responses": [
    {
     "status": 200,
     "text": "text/csv as an attachment, with the columns Identity, Type, Why it is affected, Detail, Severity, Status, When, Where to fix; a closing Note line is added when the list is a sample of a larger population"
    },
    {
     "status": 400,
     "text": ", 403 and 404 as the paged read"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": ", 403 and 404 as the paged read"
    }
   ],
   "guard": "not paged: every matching row in one file. Each download is written to the audit log as COMPLIANCE_FINDING_AFFECTED_EXPORTED"
  },
  {
   "id": "POST /compliance/reports/compliance/auditor-shares",
   "group": "compliance-hub",
   "method": "POST",
   "path": "/compliance/reports/compliance/auditor-shares",
   "purpose": "Create an auditor workspace link (list with GET; revoke with DELETE /auditor-shares/:id).",
   "auth": {
    "text": "Session + manage_compliance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ framework (required, a catalogue framework), ttlDays? (default 30; 1 to 180), label? (up to 80 characters) }",
   "response": "200 an AuditorShare (typed under Response Objects) plus token: string. The token is returned this once. GET 200 { shares: AuditorShare[] }, newest first; the GET list needs view_reporting or manage_compliance. DELETE 200 { revoked: true } | 404 \"Share not found\". 400 \"A valid framework is required.\"",
   "responses": [
    {
     "status": 200,
     "text": "an AuditorShare (typed under Response Objects) plus token: string. The token is returned this once. GET 200 { shares: AuditorShare[] }, newest first; the GET list needs view_reporting or manage_compliance. DELETE 200 { revoked: true }"
    },
    {
     "status": 404,
     "text": "\"Share not found\". 400 \"A valid framework is required.\""
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "\"Share not found\". 400 \"A valid framework is required.\""
    }
   ],
   "guard": "Repeat: each POST mints another link; revoking twice answers the same."
  },
  {
   "id": "GET /compliance/reports/compliance/auditor-requests",
   "group": "compliance-hub",
   "method": "GET",
   "path": "/compliance/reports/compliance/auditor-requests",
   "purpose": "Evidence requests auditors have raised (resolve at /auditor-requests/:id/resolve, manage_compliance).",
   "auth": {
    "text": "Session + view_reporting | manage_compliance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_reporting"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query status? (open | resolved), framework?. Resolve: { response? (up to 500 characters) }",
   "response": "200 { requests: AuditorRequest[] } (typed under Response Objects), newest first. Resolve 200 the AuditorRequest | 404 \"Request not found\" | 409 \"This request was already resolved.\" with respondedBy and respondedAt.",
   "responses": [
    {
     "status": 200,
     "text": "{ requests: AuditorRequest[] } (typed under Response Objects), newest first. Resolve 200 the AuditorRequest"
    },
    {
     "status": 404,
     "text": "\"Request not found\""
    },
    {
     "status": 409,
     "text": "\"This request was already resolved.\" with respondedBy and respondedAt."
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "\"Request not found\""
    },
    {
     "status": 409,
     "text": "\"This request was already resolved.\" with respondedBy and respondedAt."
    }
   ],
   "guard": "Repeat: the first answer stands; a second resolve changes nothing and answers 409. The list is not paged: every request matching the status and framework filters comes back in one answer."
  },
  {
   "id": "GET /shared/trust",
   "group": "shared-links",
   "method": "GET",
   "path": "/shared/trust",
   "purpose": "Your public trust center. Answers 404 until you publish it.",
   "auth": {
    "text": "Public",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 an HTML page, sent with Cache-Control: no-store | 404 \"No trust center is published.\"",
   "responses": [
    {
     "status": 200,
     "text": "an HTML page, sent with Cache-Control: no-store"
    },
    {
     "status": 404,
     "text": "\"No trust center is published.\""
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "\"No trust center is published.\""
    }
   ],
   "guard": null
  },
  {
   "id": "GET /shared/:token",
   "group": "shared-links",
   "method": "GET",
   "path": "/shared/:token",
   "purpose": "A sealed report, by share link.",
   "auth": {
    "text": "Share-link token",
    "kind": "other",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "token",
     "type": "string",
     "pattern": "^[a-f0-9]{64}$",
     "basis": "route"
    }
   ],
   "paging": [],
   "query": [
    {
     "name": "format",
     "type": "string",
     "enum": [
      "csv",
      "pdf"
     ],
     "default": "pdf",
     "note": "Any value other than csv answers the PDF."
    }
   ],
   "request": "No query is read. The link serves the one format chosen when it was created (pdf or csv).",
   "response": "200 the sealed file inline, with headers X-Artifact-SHA256 and X-Artifact-Integrity (verified | unsealed) | 404 \"This link is not valid.\" | 410 \"This link has expired or been revoked.\" | 409 when the stored file fails its integrity check | 503 when the link's creator cannot be checked right now.",
   "responses": [
    {
     "status": 200,
     "text": "the sealed file inline, with headers X-Artifact-SHA256 and X-Artifact-Integrity (verified | unsealed)"
    },
    {
     "status": 404,
     "text": "\"This link is not valid.\""
    },
    {
     "status": 410,
     "text": "\"This link has expired or been revoked.\""
    },
    {
     "status": 409,
     "text": "when the stored file fails its integrity check"
    },
    {
     "status": 503,
     "text": "when the link's creator cannot be checked right now."
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "\"This link is not valid.\""
    },
    {
     "status": 410,
     "text": "\"This link has expired or been revoked.\""
    },
    {
     "status": 409,
     "text": "when the stored file fails its integrity check"
    },
    {
     "status": 503,
     "text": "when the link's creator cannot be checked right now."
    }
   ],
   "guard": "The answer never says whether a link expired or was revoked. A link to a run that needs a permission stops working (410) once the person who created it no longer holds that permission."
  },
  {
   "id": "GET /shared/verify/:runId",
   "group": "shared-links",
   "method": "GET",
   "path": "/shared/verify/:runId",
   "purpose": "Verify that a sealed report is genuine.",
   "auth": {
    "text": "Public (rate-limited)",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "runId",
     "type": "integer",
     "minimum": 1,
     "basis": "route"
    }
   ],
   "paging": [],
   "query": [
    {
     "name": "sha256",
     "type": "string",
     "required": true,
     "pattern": "^[a-fA-F0-9]{64}$",
     "note": "The SHA-256 of the file, 64 hexadecimal characters."
    }
   ],
   "request": "Query ?sha256= (required: the file's SHA-256, 64 hex characters).",
   "response": "200 { match: true, runId: integer, framework: string | null, observationWindow: { from: timestamp | null, to: timestamp | null }, generatedAt: timestamp, signature?: object (PDF only) } | 200 { match: false, runId: integer, message: string } | 400 without a valid sha256 | 404 for a run id that is not a number.",
   "responses": [
    {
     "status": 200,
     "text": "{ match: true, runId: integer, framework: string | null, observationWindow: { from: timestamp | null, to: timestamp | null }, generatedAt: timestamp, signature?: object (PDF only) }"
    },
    {
     "status": 200,
     "text": "{ match: false, runId: integer, message: string }"
    },
    {
     "status": 400,
     "text": "without a valid sha256"
    },
    {
     "status": 404,
     "text": "for a run id that is not a number."
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "without a valid sha256"
    },
    {
     "status": 404,
     "text": "for a run id that is not a number."
    }
   ],
   "guard": "A wrong run id and an altered file give the same match: false answer."
  },
  {
   "id": "GET /shared/auditor/:token",
   "group": "shared-links",
   "method": "GET",
   "path": "/shared/auditor/:token",
   "purpose": "The auditor workspace, by link.",
   "auth": {
    "text": "Auditor-link token",
    "kind": "other",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "token",
     "type": "string",
     "pattern": "^[a-f0-9]{64}$",
     "basis": "route"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 a read-only HTML page for the link's one framework | 404 \"This link is not valid.\" | 410 \"This link has expired or been revoked.\"",
   "responses": [
    {
     "status": 200,
     "text": "a read-only HTML page for the link's one framework"
    },
    {
     "status": 404,
     "text": "\"This link is not valid.\""
    },
    {
     "status": 410,
     "text": "\"This link has expired or been revoked.\""
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "\"This link is not valid.\""
    },
    {
     "status": 410,
     "text": "\"This link has expired or been revoked.\""
    }
   ],
   "guard": null
  },
  {
   "id": "POST /shared/auditor/:token/requests",
   "group": "shared-links",
   "method": "POST",
   "path": "/shared/auditor/:token/requests",
   "purpose": "An auditor raises an evidence request.",
   "auth": {
    "text": "Auditor-link token",
    "kind": "other",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "token",
     "type": "string",
     "pattern": "^[a-f0-9]{64}$",
     "basis": "route"
    }
   ],
   "paging": [],
   "query": [],
   "request": "An AuditorEvidenceRequestCreate: { controlId (required, a control of the link's framework), note (required, up to 500 characters) }",
   "response": "201 { ok: true, id: string } | 400 \"Unknown control for this workspace.\" or \"A note is required.\" | 404 | 410 | 429 \"Too many open requests on this link.\"",
   "responses": [
    {
     "status": 201,
     "text": "{ ok: true, id: string }"
    },
    {
     "status": 400,
     "text": "\"Unknown control for this workspace.\" or \"A note is required.\""
    },
    {
     "status": 404,
     "text": ""
    },
    {
     "status": 410,
     "text": ""
    },
    {
     "status": 429,
     "text": "\"Too many open requests on this link.\""
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "\"Unknown control for this workspace.\" or \"A note is required.\""
    },
    {
     "status": 404,
     "text": ""
    },
    {
     "status": 410,
     "text": ""
    },
    {
     "status": 429,
     "text": "\"Too many open requests on this link.\""
    }
   ],
   "guard": "Repeat: not deduplicated; each call opens another request until the link's open-request limit answers 429."
  },
  {
   "id": "GET /compliance/reports/backups",
   "group": "backup-ops",
   "method": "GET",
   "path": "/compliance/reports/backups",
   "purpose": "List backups.",
   "auth": {
    "text": "Session + view_system",
    "kind": "session",
    "permissions": [
     "view_system"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query ?recheck=1 re-checks off-server copies instead of using the cached answer.",
   "response": "200 BackupPoint[] (typed under Response Objects): a bare array, newest first; no paging. fetch: true marks a dump that is copied back from your storage before it is restored.",
   "responses": [
    {
     "status": 200,
     "text": "BackupPoint[] (typed under Response Objects): a bare array, newest first; no paging. fetch: true marks a dump that is copied back from your storage before it is restored."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /compliance/reports/backup-audit",
   "group": "backup-ops",
   "method": "GET",
   "path": "/compliance/reports/backup-audit",
   "purpose": "The backup audit trail.",
   "auth": {
    "text": "Session + view_system",
    "kind": "session",
    "permissions": [
     "view_system"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 BackupAuditEntry[] (typed under Response Objects): a bare array of the newest 40 entries. No paging parameters are read.",
   "responses": [
    {
     "status": 200,
     "text": "BackupAuditEntry[] (typed under Response Objects): a bare array of the newest 40 entries. No paging parameters are read."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /compliance/reports/wal-archive",
   "group": "backup-ops",
   "method": "GET",
   "path": "/compliance/reports/wal-archive",
   "purpose": "Write-ahead-log archive status.",
   "auth": {
    "text": "Session + view_system",
    "kind": "session",
    "permissions": [
     "view_system"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [
    "limit",
    "offset"
   ],
   "query": [
    {
     "name": "list",
     "type": "string",
     "enum": [
      "1",
      "true"
     ],
     "note": "Answer the segments and not the summary."
    },
    {
     "name": "limit",
     "type": "integer",
     "default": 50,
     "maximum": 200,
     "note": "Read only with list."
    },
    {
     "name": "offset",
     "type": "integer",
     "default": 0,
     "note": "Read only with list."
    }
   ],
   "replyShapes": [
    "WalArchiveSummary",
    "{ count: integer, segments: WalSegment[] }"
   ],
   "request": "Query ?list=1 for the segments, with limit (default 50, maximum 200) and offset (default 0).",
   "response": "200 a WalArchiveSummary (typed under Response Objects) for the summary. With list=1: 200 { count: integer, segments: WalSegment[] }, newest upload first.",
   "responses": [
    {
     "status": 200,
     "text": "a WalArchiveSummary (typed under Response Objects) for the summary. With list=1: 200 { count: integer, segments: WalSegment[] }, newest upload first."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /compliance/reports/restore-request",
   "group": "backup-ops",
   "method": "POST",
   "path": "/compliance/reports/restore-request",
   "purpose": "Request a database restore. A different approver must approve it at /restore-approve.",
   "auth": {
    "text": "Session + approve_system_updates",
    "kind": "session",
    "permissions": [
     "approve_system_updates"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ target } required: a backup file name of the form zonitix_*.sql.gz, with no path. /restore-approve takes { target? } to name the request you are looking at.",
   "response": "202 { status: 'pending_approval', request { mode: 'restore', target, requestedBy, requestedAt } } | 400 for a bad file name or a backup taken on a different PostgreSQL major version | 409 \"A request is already awaiting approval.\" or \"An operation is already queued or in progress.\" | 503 when the request could not be queued. Approve: 200 { approved: true, request } | 403 when you are the requester | 404 \"No request is awaiting approval.\" | 409 when the waiting request is not a restore, or is for a different target.",
   "responses": [
    {
     "status": 202,
     "text": "{ status: 'pending_approval', request { mode: 'restore', target, requestedBy, requestedAt } }"
    },
    {
     "status": 400,
     "text": "for a bad file name or a backup taken on a different PostgreSQL major version"
    },
    {
     "status": 409,
     "text": "\"A request is already awaiting approval.\" or \"An operation is already queued or in progress.\""
    },
    {
     "status": 503,
     "text": "when the request could not be queued. Approve: 200 { approved: true, request }"
    },
    {
     "status": 403,
     "text": "when you are the requester"
    },
    {
     "status": 404,
     "text": "\"No request is awaiting approval.\""
    },
    {
     "status": 409,
     "text": "when the waiting request is not a restore, or is for a different target."
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "for a bad file name or a backup taken on a different PostgreSQL major version"
    },
    {
     "status": 409,
     "text": "\"A request is already awaiting approval.\" or \"An operation is already queued or in progress.\""
    },
    {
     "status": 503,
     "text": "when the request could not be queued. Approve: 200 { approved: true, request }"
    },
    {
     "status": 403,
     "text": "when you are the requester"
    },
    {
     "status": 404,
     "text": "\"No request is awaiting approval.\""
    },
    {
     "status": 409,
     "text": "when the waiting request is not a restore, or is for a different target."
    }
   ],
   "guard": "Repeat: a second request is refused with 409 until the first is approved, rejected or cancelled."
  },
  {
   "id": "POST /compliance/reports/delete-request",
   "group": "backup-ops",
   "method": "POST",
   "path": "/compliance/reports/delete-request",
   "purpose": "Request deletion of a backup. A different approver must approve it at /delete-approve.",
   "auth": {
    "text": "Session + approve_system_updates",
    "kind": "session",
    "permissions": [
     "approve_system_updates"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ target } required: zonitix_*.sql.gz or zonitix_*.tar.gz, with no path. /delete-approve takes { target? }.",
   "response": "202 { status: 'pending_approval', request { mode: 'delete-backup', target, requestedBy, requestedAt } } | 400 | 409 | 503 as for a restore request. Approve: 200 { approved: true, request, offbox { deletedObject, key, deleteFailed?, error? } } | 403 when you are the requester | 404 | 409.",
   "responses": [
    {
     "status": 202,
     "text": "{ status: 'pending_approval', request { mode: 'delete-backup', target, requestedBy, requestedAt } }"
    },
    {
     "status": 400,
     "text": ""
    },
    {
     "status": 409,
     "text": ""
    },
    {
     "status": 503,
     "text": "as for a restore request. Approve: 200 { approved: true, request, offbox { deletedObject, key, deleteFailed?, error? } }"
    },
    {
     "status": 403,
     "text": "when you are the requester"
    },
    {
     "status": 404,
     "text": ""
    },
    {
     "status": 409,
     "text": "."
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": ""
    },
    {
     "status": 409,
     "text": ""
    },
    {
     "status": 503,
     "text": "as for a restore request. Approve: 200 { approved: true, request, offbox { deletedObject, key, deleteFailed?, error? } }"
    },
    {
     "status": 403,
     "text": "when you are the requester"
    },
    {
     "status": 404,
     "text": ""
    },
    {
     "status": 409,
     "text": "."
    }
   ],
   "guard": "offbox.deleteFailed: true means the copy on your own storage is still there although the local file was removed. Repeat: refused with 409 while a request is waiting."
  },
  {
   "id": "POST /compliance/reports/restore-drill-schedule",
   "group": "backup-ops",
   "method": "POST",
   "path": "/compliance/reports/restore-drill-schedule",
   "purpose": "Set the restore-drill schedule.",
   "auth": {
    "text": "Session + approve_system_updates",
    "kind": "session",
    "permissions": [
     "approve_system_updates"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ enabled?, everyHours? }. A field you leave out keeps its stored value.",
   "response": "200 the saved { enabled, everyHours } together with the current restore-test status.",
   "responses": [
    {
     "status": 200,
     "text": "the saved { enabled, everyHours } together with the current restore-test status."
    }
   ],
   "errors": [],
   "guard": "Written to the audit log. Repeat: safe; the call sets values."
  },
  {
   "id": "POST /compliance/reports/update-cancel",
   "group": "backup-ops",
   "method": "POST",
   "path": "/compliance/reports/update-cancel",
   "purpose": "Cancel a pending update request.",
   "auth": {
    "text": "Session + request_system_updates | approve_system_updates",
    "kind": "session",
    "permissions": [
     "approve_system_updates",
     "request_system_updates"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { cancelled: true, request } | 403 \"Only the administrator who requested this can cancel it\" | 404 \"No request is awaiting approval.\"",
   "responses": [
    {
     "status": 200,
     "text": "{ cancelled: true, request }"
    },
    {
     "status": 403,
     "text": "\"Only the administrator who requested this can cancel it\""
    },
    {
     "status": 404,
     "text": "\"No request is awaiting approval.\""
    }
   ],
   "errors": [
    {
     "status": 403,
     "text": "\"Only the administrator who requested this can cancel it\""
    },
    {
     "status": 404,
     "text": "\"No request is awaiting approval.\""
    }
   ],
   "guard": "No body. Repeat: the second call answers 404, because nothing is waiting any more."
  },
  {
   "id": "GET /compliance/reports/release-channel",
   "group": "backup-ops",
   "method": "GET",
   "path": "/compliance/reports/release-channel",
   "purpose": "Read the release channel.",
   "auth": {
    "text": "Session + view_system",
    "kind": "session",
    "permissions": [
     "view_system"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { channel: 'stable' | 'main', channels: string[], default: string, ... }.",
   "responses": [
    {
     "status": 200,
     "text": "{ channel: 'stable' | 'main', channels: string[], default: string, ... }."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "PUT /compliance/reports/release-channel",
   "group": "backup-ops",
   "method": "PUT",
   "path": "/compliance/reports/release-channel",
   "purpose": "Change the release channel.",
   "auth": {
    "text": "Session + approve_system_updates + sudo",
    "kind": "session",
    "permissions": [
     "approve_system_updates"
    ],
    "stepUp": "required",
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ channel: 'stable' | 'main' }",
   "response": "200 the object the GET answers plus changed: boolean and previous: string | 400 \"Release channel must be one of: stable, main.\" | 503 when the update control directory is not writable.",
   "responses": [
    {
     "status": 200,
     "text": "the object the GET answers plus changed: boolean and previous: string"
    },
    {
     "status": 400,
     "text": "\"Release channel must be one of: stable, main.\""
    },
    {
     "status": 503,
     "text": "when the update control directory is not writable."
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "\"Release channel must be one of: stable, main.\""
    },
    {
     "status": 503,
     "text": "when the update control directory is not writable."
    }
   ],
   "guard": "Needs a fresh re-authentication. Repeat: safe; setting the channel it already has answers changed: false and writes no audit entry."
  },
  {
   "id": "GET /compliance/data-export/jobs",
   "group": "company-data-export",
   "method": "GET",
   "path": "/compliance/data-export/jobs",
   "purpose": "List the most recent export jobs, with the limits in force.",
   "auth": {
    "text": "Session + administrator role",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": true,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 { jobs: TenantExportJob[], limits: { maxPerDay: 3, artifactTtlHours: 24, linkTtlMinutes: 15 } } — TenantExportJob is typed under Response Objects; newest first, at most 20",
   "responses": [
    {
     "status": 200,
     "text": "{ jobs: TenantExportJob[], limits: { maxPerDay: 3, artifactTtlHours: 24, linkTtlMinutes: 15 } } — TenantExportJob is typed under Response Objects; newest first, at most 20"
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /compliance/data-export/jobs/:id",
   "group": "company-data-export",
   "method": "GET",
   "path": "/compliance/data-export/jobs/:id",
   "purpose": "One job, for polling its progress.",
   "auth": {
    "text": "Session + administrator role",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": true,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No query parameters. :id is a whole number.",
   "response": "200 a TenantExportJob | 404 { error:'Export not found', code:null }",
   "responses": [
    {
     "status": 200,
     "text": "a TenantExportJob"
    },
    {
     "status": 404,
     "text": "{ error:'Export not found', code:null }"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "{ error:'Export not found', code:null }"
    }
   ],
   "guard": null
  },
  {
   "id": "POST /compliance/data-export/jobs",
   "group": "company-data-export",
   "method": "POST",
   "path": "/compliance/data-export/jobs",
   "purpose": "Start an export. It runs in the background.",
   "auth": {
    "text": "Session + administrator role + sudo",
    "kind": "session",
    "permissions": [],
    "stepUp": "required",
    "administratorRole": true,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "202 a TenantExportJob, newly created with status queued | 409 code EXPORT_IN_PROGRESS when one is queued or running | 429 code EXPORT_RATE_LIMITED after 3 in 24 hours | 503 code NO_ENCRYPTION_KEY when the instance has no key to encrypt the file with",
   "responses": [
    {
     "status": 202,
     "text": "a TenantExportJob, newly created with status queued"
    },
    {
     "status": 409,
     "text": "code EXPORT_IN_PROGRESS when one is queued or running"
    },
    {
     "status": 429,
     "text": "code EXPORT_RATE_LIMITED after 3 in 24 hours"
    },
    {
     "status": 503,
     "text": "code NO_ENCRYPTION_KEY when the instance has no key to encrypt the file with"
    }
   ],
   "errors": [
    {
     "status": 409,
     "text": "code EXPORT_IN_PROGRESS when one is queued or running"
    },
    {
     "status": 429,
     "text": "code EXPORT_RATE_LIMITED after 3 in 24 hours"
    },
    {
     "status": 503,
     "text": "code NO_ENCRYPTION_KEY when the instance has no key to encrypt the file with"
    }
   ],
   "guard": "One at a time. Failed jobs do not count toward the 3 in 24 hours. Audited as TENANT_EXPORT_REQUESTED, then TENANT_EXPORT_READY or TENANT_EXPORT_FAILED. Not safe to repeat blindly: each accepted call builds another export."
  },
  {
   "id": "POST /compliance/data-export/jobs/:id/link",
   "group": "company-data-export",
   "method": "POST",
   "path": "/compliance/data-export/jobs/:id/link",
   "purpose": "Mint a single-use download link for a ready export.",
   "auth": {
    "text": "Session + administrator role + sudo",
    "kind": "session",
    "permissions": [],
    "stepUp": "required",
    "administratorRole": true,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { token: string (64 hex characters), linkExpiresAt: string (date-time), path: string (/compliance/data-export/download/<token>) } | 404 'Export not found' | 410 code EXPORT_EXPIRED when the job is not ready or its file has expired",
   "responses": [
    {
     "status": 200,
     "text": "{ token: string (64 hex characters), linkExpiresAt: string (date-time), path: string (/compliance/data-export/download/<token>) }"
    },
    {
     "status": 404,
     "text": "'Export not found'"
    },
    {
     "status": 410,
     "text": "code EXPORT_EXPIRED when the job is not ready or its file has expired"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'Export not found'"
    },
    {
     "status": 410,
     "text": "code EXPORT_EXPIRED when the job is not ready or its file has expired"
    }
   ],
   "guard": "The link lasts 15 minutes, or until the file expires if that is sooner, works once, and only for the administrator who minted it. Minting again replaces the previous link. Audited as TENANT_EXPORT_LINK_ISSUED."
  },
  {
   "id": "GET /compliance/data-export/download/:token",
   "group": "company-data-export",
   "method": "GET",
   "path": "/compliance/data-export/download/:token",
   "purpose": "Download the ZIP. The link is used up by the call.",
   "auth": {
    "text": "Session + administrator role",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": true,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "token",
     "type": "string",
     "basis": "unknown"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No query parameters. :token is the 64 hex characters from the link call.",
   "response": "200 the ZIP (application/zip, Content-Disposition attachment, Content-Length, X-Content-SHA256 = the SHA-256 of the ZIP, Cache-Control no-store) | 410 code LINK_USED_OR_EXPIRED when the link was already used, has expired or was minted by someone else | 410 code EXPORT_EXPIRED when the file is no longer on the server",
   "responses": [
    {
     "status": 200,
     "text": "the ZIP (application/zip, Content-Disposition attachment, Content-Length, X-Content-SHA256 = the SHA-256 of the ZIP, Cache-Control no-store)"
    },
    {
     "status": 410,
     "text": "code LINK_USED_OR_EXPIRED when the link was already used, has expired or was minted by someone else"
    },
    {
     "status": 410,
     "text": "code EXPORT_EXPIRED when the file is no longer on the server"
    }
   ],
   "errors": [
    {
     "status": 410,
     "text": "code LINK_USED_OR_EXPIRED when the link was already used, has expired or was minted by someone else"
    },
    {
     "status": 410,
     "text": "code EXPORT_EXPIRED when the file is no longer on the server"
    }
   ],
   "guard": "Single use: a second call with the same token answers 410. Audited as TENANT_EXPORT_DOWNLOADED; a refused call as TENANT_EXPORT_DOWNLOAD_DENIED. If the stream is cut short the download is incomplete: compare the file's SHA-256 with the header."
  },
  {
   "id": "GET /auth/security-lab/scan-schedule",
   "group": "security-lab-more",
   "method": "GET",
   "path": "/auth/security-lab/scan-schedule",
   "purpose": "Read the recurring scan schedule.",
   "auth": {
    "text": "Session + view_security_findings | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_security_findings"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 a ScanSchedule (typed under Response Objects). nextDueAt is null when the schedule is off, has never run, or is already due.",
   "responses": [
    {
     "status": 200,
     "text": "a ScanSchedule (typed under Response Objects). nextDueAt is null when the schedule is off, has never run, or is already due."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /auth/security-lab/scan-schedule",
   "group": "security-lab-more",
   "method": "POST",
   "path": "/auth/security-lab/scan-schedule",
   "purpose": "Set the recurring scan schedule.",
   "auth": {
    "text": "Session + manage_security_scopes | manage_compliance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "manage_security_scopes"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Any of { enabled, intervalHours (whole number 1 to 168), reportDeliver, reportRecipients (comma-separated), externalEnabled, externalIntervalHours (1 to 168), trivyImages, slaAlertEnabled, slaAlertPermission }; at least one is required.",
   "response": "200 the same shape as the GET | 400 \"Provide at least one schedule field to update.\" or an interval outside 1 to 168.",
   "responses": [
    {
     "status": 200,
     "text": "the same shape as the GET"
    },
    {
     "status": 400,
     "text": "\"Provide at least one schedule field to update.\" or an interval outside 1 to 168."
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "\"Provide at least one schedule field to update.\" or an interval outside 1 to 168."
    }
   ],
   "guard": "Audit-logged. Repeat: safe; only the fields you send are set."
  },
  {
   "id": "GET /auth/security-lab/probe-catalog",
   "group": "security-lab-more",
   "method": "GET",
   "path": "/auth/security-lab/probe-catalog",
   "purpose": "The probes the Lab can run.",
   "auth": {
    "text": "Session + view_security_findings | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_security_findings"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { probes[{ id, category, intensity, remoteOk, localOk, heavy, pathBound, endpoint, cwe[], budgetMs }] }",
   "responses": [
    {
     "status": 200,
     "text": "{ probes[{ id, category, intensity, remoteOk, localOk, heavy, pathBound, endpoint, cwe[], budgetMs }] }"
    }
   ],
   "errors": [],
   "guard": "The whole catalogue in one answer; no paging."
  },
  {
   "id": "GET /auth/security-lab/scans/active",
   "group": "security-lab-more",
   "method": "GET",
   "path": "/auth/security-lab/scans/active",
   "purpose": "The scan that is queued or running now, if any.",
   "auth": {
    "text": "Session + view_security_findings | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_security_findings"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { scans[] }: empty, or the one live scan with currentProbe and stopping.",
   "responses": [
    {
     "status": 200,
     "text": "{ scans[] }: empty, or the one live scan with currentProbe and stopping."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /auth/security-lab/scans/:id/verify",
   "group": "security-lab-more",
   "method": "GET",
   "path": "/auth/security-lab/scans/:id/verify",
   "purpose": "Re-check a finished scan's sealed report against its stored hash and audit-log entry.",
   "auth": {
    "text": "Session + view_security_findings | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_security_findings"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "string",
     "format": "uuid",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 the verification result when the scan has a sealed report | 404 with the same result object (sealed: false) when it has none.",
   "responses": [
    {
     "status": 200,
     "text": "the verification result when the scan has a sealed report"
    },
    {
     "status": 404,
     "text": "with the same result object (sealed: false) when it has none."
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "with the same result object (sealed: false) when it has none."
    }
   ],
   "guard": null
  },
  {
   "id": "GET /auth/security-lab/trends",
   "group": "security-lab-more",
   "method": "GET",
   "path": "/auth/security-lab/trends",
   "purpose": "Posture over time (the Trends view).",
   "auth": {
    "text": "Session + view_security_findings | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_security_findings"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { postureHistory[{ at, score, crit, high }], mttr { avgDays, medianDays, fixedCount }, openBySeverity, openTotal, slaOverdue }. postureHistory is the newest 120 completed scans, oldest first; no parameters are read.",
   "responses": [
    {
     "status": 200,
     "text": "{ postureHistory[{ at, score, crit, high }], mttr { avgDays, medianDays, fixedCount }, openBySeverity, openTotal, slaOverdue }. postureHistory is the newest 120 completed scans, oldest first; no parameters are read."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "PATCH /auth/security-lab/scopes/:id",
   "group": "security-lab-more",
   "method": "PATCH",
   "path": "/auth/security-lab/scopes/:id",
   "purpose": "Edit a scan scope; needs a fresh re-authentication (401 SUDO_REQUIRED), and changing the hosts clears its ownership proof. DELETE disables it; POST /scopes/:id/verify runs its verification.",
   "auth": {
    "text": "Session + manage_security_scopes | manage_compliance + sudo",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "manage_security_scopes"
    ],
    "stepUp": "required",
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "string",
     "format": "uuid",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "{ name?, description?, maxProfile?, ownerAttestation?, enabled?, allowedCidrs?, allowedHosts?, roe?, testEndpoints? }: only the fields sent are changed. roe with a scan-window start needs the end too, both as HH:MM (24-hour).",
   "response": "200 the scope | 401 { error: 'SUDO_REQUIRED', message } without a fresh step-up | 404 \"Scope not found\".",
   "responses": [
    {
     "status": 200,
     "text": "the scope"
    },
    {
     "status": 401,
     "text": "{ error: 'SUDO_REQUIRED', message } without a fresh step-up"
    },
    {
     "status": 404,
     "text": "\"Scope not found\"."
    }
   ],
   "errors": [
    {
     "status": 401,
     "text": "{ error: 'SUDO_REQUIRED', message } without a fresh step-up"
    },
    {
     "status": 404,
     "text": "\"Scope not found\"."
    }
   ],
   "guard": null
  },
  {
   "id": "DELETE /auth/security-lab/scopes/:id",
   "group": "security-lab-more",
   "method": "DELETE",
   "path": "/auth/security-lab/scopes/:id",
   "purpose": "Disable a scan scope.",
   "auth": {
    "text": "Session + manage_security_scopes | manage_compliance + sudo",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "manage_security_scopes"
    ],
    "stepUp": "required",
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "string",
     "format": "uuid",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 the scope | 401 { error: 'SUDO_REQUIRED', message } without a fresh step-up (this route passes the status through, so it stays 401) | 404 \"Scope not found\".",
   "responses": [
    {
     "status": 200,
     "text": "the scope"
    },
    {
     "status": 401,
     "text": "{ error: 'SUDO_REQUIRED', message } without a fresh step-up (this route passes the status through, so it stays 401)"
    },
    {
     "status": 404,
     "text": "\"Scope not found\"."
    }
   ],
   "errors": [
    {
     "status": 401,
     "text": "{ error: 'SUDO_REQUIRED', message } without a fresh step-up (this route passes the status through, so it stays 401)"
    },
    {
     "status": 404,
     "text": "\"Scope not found\"."
    }
   ],
   "guard": "Needs a fresh step-up; written to the audit log as SECURITY_SCOPE_DISABLED."
  },
  {
   "id": "POST /auth/security-lab/scopes/:id/verify",
   "group": "security-lab-more",
   "method": "POST",
   "path": "/auth/security-lab/scopes/:id/verify",
   "purpose": "Run the ownership check for a scope's hosts.",
   "auth": {
    "text": "Session + manage_security_scopes | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "manage_security_scopes"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "string",
     "format": "uuid",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { ok: true, verifiedHosts, unprovenHosts, ... } | 400 with the same result object when ok is false.",
   "responses": [
    {
     "status": 200,
     "text": "{ ok: true, verifiedHosts, unprovenHosts, ... }"
    },
    {
     "status": 400,
     "text": "with the same result object when ok is false."
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "with the same result object when ok is false."
    }
   ],
   "guard": null
  },
  {
   "id": "GET /auth/security-lab/issues",
   "group": "security-lab-more",
   "method": "GET",
   "path": "/auth/security-lab/issues",
   "purpose": "Findings grouped into issues (totals at /issues/summary, one at /issues/:id).",
   "auth": {
    "text": "Session + view_security_findings | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_security_findings"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [
    "limit",
    "offset"
   ],
   "query": [
    {
     "name": "severity",
     "type": "string"
    },
    {
     "name": "status",
     "type": "string"
    },
    {
     "name": "kev",
     "type": "string"
    },
    {
     "name": "target",
     "type": "string"
    },
    {
     "name": "search",
     "type": "string"
    },
    {
     "name": "includeArchived",
     "type": "string",
     "enum": [
      "1",
      "true"
     ]
    },
    {
     "name": "limit",
     "type": "integer",
     "default": 50,
     "maximum": 200
    },
    {
     "name": "offset",
     "type": "integer",
     "default": 0,
     "minimum": 0
    },
    {
     "name": "sort",
     "type": "string"
    },
    {
     "name": "dir",
     "type": "string",
     "note": "asc sorts ascending; any other value is descending."
    }
   ],
   "request": "Query: severity, status, kev, target ('remote' = everything not local), search (title, CVE id or package name; first 200 characters), includeArchived (1 | true), limit (default 50, maximum 200), offset (default 0), sort (severity | cvss | risk | epss | sla | age | status | title | target | owner), dir (asc; anything else is descending).",
   "response": "200 { issues: SecurityIssue[], total: integer, limit: integer, offset: integer } (typed under Response Objects). Default order: CVSS score highest first, then most recently seen. /issues/summary takes the same filters and answers { total: integer, kevOpen: integer, bySeverity: { [severity]: integer } }. /issues/:id answers { issue: SecurityIssue } | 404 \"Issue not found\".",
   "responses": [
    {
     "status": 200,
     "text": "{ issues: SecurityIssue[], total: integer, limit: integer, offset: integer } (typed under Response Objects). Default order: CVSS score highest first, then most recently seen. /issues/summary takes the same filters and answers { total: integer, kevOpen: integer, bySeverity: { [severity]: integer } }. /issues/:id answers { issue: SecurityIssue }"
    },
    {
     "status": 404,
     "text": "\"Issue not found\"."
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "\"Issue not found\"."
    }
   ],
   "guard": null
  },
  {
   "id": "GET /auth/security-lab/issues/summary",
   "group": "security-lab-more",
   "method": "GET",
   "path": "/auth/security-lab/issues/summary",
   "purpose": "Issue totals for the same filters as GET /auth/security-lab/issues.",
   "auth": {
    "text": "Session + view_security_findings | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_security_findings"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query: the filters of GET /auth/security-lab/issues (severity, status, kev, target, search, includeArchived).",
   "response": "200 { total: integer, kevOpen: integer, bySeverity: { [severity]: integer } }. One object, not a list.",
   "responses": [
    {
     "status": 200,
     "text": "{ total: integer, kevOpen: integer, bySeverity: { [severity]: integer } }. One object, not a list."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /auth/security-lab/issues/:id",
   "group": "security-lab-more",
   "method": "GET",
   "path": "/auth/security-lab/issues/:id",
   "purpose": "One issue.",
   "auth": {
    "text": "Session + view_security_findings | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_security_findings"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "string",
     "format": "uuid",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { issue: SecurityIssue } (typed under Response Objects) | 404 \"Issue not found\".",
   "responses": [
    {
     "status": 200,
     "text": "{ issue: SecurityIssue } (typed under Response Objects)"
    },
    {
     "status": 404,
     "text": "\"Issue not found\"."
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "\"Issue not found\"."
    }
   ],
   "guard": null
  },
  {
   "id": "GET /auth/security-lab/findings/:id/issues",
   "group": "security-lab-more",
   "method": "GET",
   "path": "/auth/security-lab/findings/:id/issues",
   "purpose": "The issues one finding was grouped into.",
   "auth": {
    "text": "Session + view_security_findings | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_security_findings"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "string",
     "format": "uuid",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { findingId, issues: SecurityIssue[], total: integer } | 404 \"Finding not found\". Everything in one answer; no paging parameters are read.",
   "responses": [
    {
     "status": 200,
     "text": "{ findingId, issues: SecurityIssue[], total: integer }"
    },
    {
     "status": 404,
     "text": "\"Finding not found\". Everything in one answer; no paging parameters are read."
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "\"Finding not found\". Everything in one answer; no paging parameters are read."
    }
   ],
   "guard": null
  },
  {
   "id": "PATCH /auth/security-lab/issues/:id",
   "group": "security-lab-more",
   "method": "PATCH",
   "path": "/auth/security-lab/issues/:id",
   "purpose": "Update an issue.",
   "auth": {
    "text": "Session + manage_security_findings | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "manage_security_findings"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "string",
     "format": "uuid",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "Any of { status (open | acknowledged | in_remediation | retest_pending | risk_accepted | false_positive), statusNote, owner, ticketRef, acceptedUntil }",
   "response": "200 the issue; when the change opened a risk exception, exception { id, status, acceptedUntil, requestedBy } is added | 400 \"Invalid status\" (verified_fixed is reached only by a clean re-scan) or JUSTIFICATION_REQUIRED when accepting risk or marking a false positive without a statusNote | 400 EXPIRY_REQUIRED when accepting risk without a future acceptedUntil | 404 \"Issue not found\" | 409 EXCEPTION_PENDING when a risk acceptance for it is already waiting for a second approver.",
   "responses": [
    {
     "status": 200,
     "text": "the issue; when the change opened a risk exception, exception { id, status, acceptedUntil, requestedBy } is added"
    },
    {
     "status": 400,
     "text": "\"Invalid status\" (verified_fixed is reached only by a clean re-scan) or JUSTIFICATION_REQUIRED when accepting risk or marking a false positive without a statusNote"
    },
    {
     "status": 400,
     "text": "EXPIRY_REQUIRED when accepting risk without a future acceptedUntil"
    },
    {
     "status": 404,
     "text": "\"Issue not found\""
    },
    {
     "status": 409,
     "text": "EXCEPTION_PENDING when a risk acceptance for it is already waiting for a second approver."
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "\"Invalid status\" (verified_fixed is reached only by a clean re-scan) or JUSTIFICATION_REQUIRED when accepting risk or marking a false positive without a statusNote"
    },
    {
     "status": 400,
     "text": "EXPIRY_REQUIRED when accepting risk without a future acceptedUntil"
    },
    {
     "status": 404,
     "text": "\"Issue not found\""
    },
    {
     "status": 409,
     "text": "EXCEPTION_PENDING when a risk acceptance for it is already waiting for a second approver."
    }
   ],
   "guard": "status: risk_accepted opens a risk exception under the same rules as POST /findings/:id/exception."
  },
  {
   "id": "POST /auth/security-lab/findings/:id/exception",
   "group": "security-lab-more",
   "method": "POST",
   "path": "/auth/security-lab/findings/:id/exception",
   "purpose": "Accept the risk of a finding, with an expiry. Body: justification, acceptedUntil (a future date). 201 with the exception: active at once for Low and Medium, pending for Critical and High. 400 EXPIRY_REQUIRED or JUSTIFICATION_REQUIRED; 409 EXCEPTION_EXISTS if one is already active or pending.",
   "auth": {
    "text": "Session + manage_security_findings | manage_compliance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "manage_security_findings"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "string",
     "format": "uuid",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "A SecurityExceptionRequest: { justification (required), acceptedUntil (required: a date in the future) }",
   "response": "201 { id: uuid, findingId: uuid | null, issueId: uuid | null, probe: string, target: string, severity: string, status: 'active' | 'pending', justification: string, requestedBy: string | null, approvedBy: string | null, acceptedAt: timestamp | null, acceptedUntil: timestamp, history: [{ at: timestamp, action: string, actor: string | null, from: string | null, to: string, note: string }] }. status is 'pending' and acceptedAt is null for a Critical or High finding until someone else approves it. Errors are { error: string, code?: string }: 400 JUSTIFICATION_REQUIRED | 400 EXPIRY_REQUIRED (missing, not a date, or not in the future) | 404 \"Finding not found.\" | 409 EXCEPTION_EXISTS. The record also carries expiredAt, revokedAt and revokedBy, which are unset on a new exception.",
   "responses": [
    {
     "status": 201,
     "text": "{ id: uuid, findingId: uuid | null, issueId: uuid | null, probe: string, target: string, severity: string, status: 'active' | 'pending', justification: string, requestedBy: string | null, approvedBy: string | null, acceptedAt: timestamp | null, acceptedUntil: timestamp, history: [{ at: timestamp, action: string, actor: string | null, from: string | null, to: string, note: string }] }. status is 'pending' and acceptedAt is null for a Critical or High finding until someone else approves it. Errors are { error: string, code?: string }: 400 JUSTIFICATION_REQUIRED"
    },
    {
     "status": 400,
     "text": "EXPIRY_REQUIRED (missing, not a date, or not in the future)"
    },
    {
     "status": 404,
     "text": "\"Finding not found.\""
    },
    {
     "status": 409,
     "text": "EXCEPTION_EXISTS. The record also carries expiredAt, revokedAt and revokedBy, which are unset on a new exception."
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "EXPIRY_REQUIRED (missing, not a date, or not in the future)"
    },
    {
     "status": 404,
     "text": "\"Finding not found.\""
    },
    {
     "status": 409,
     "text": "EXCEPTION_EXISTS. The record also carries expiredAt, revokedAt and revokedBy, which are unset on a new exception."
    }
   ],
   "guard": "Repeat: 409 code EXCEPTION_EXISTS; a finding with a pending or active exception cannot get a second one until that one is revoked"
  },
  {
   "id": "GET /auth/security-lab/exceptions",
   "group": "security-lab-more",
   "method": "GET",
   "path": "/auth/security-lab/exceptions",
   "purpose": "List risk exceptions (one finding's full timeline at /findings/:id/exceptions).",
   "auth": {
    "text": "Session + view_security_findings | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_security_findings"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query findingId?, issueId?, includeArchived? (1 | true: include revoked ones).",
   "response": "200 { exceptions[], ungoverned: { issues: integer, findings: integer } | null }. Each exception carries subject: { kind: 'issue', id, title, cveId, status } or { kind: 'finding', id }; title, cveId and status are null when the issue could not be loaded. ungoverned counts older risk acceptances that have no expiry and no approver, and is null when the count could not be made. No paging parameters are read.",
   "responses": [
    {
     "status": 200,
     "text": "{ exceptions[], ungoverned: { issues: integer, findings: integer } | null }. Each exception carries subject: { kind: 'issue', id, title, cveId, status } or { kind: 'finding', id }; title, cveId and status are null when the issue could not be loaded. ungoverned counts older risk acceptances that have no expiry and no approver, and is null when the count could not be made. No paging parameters are read."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /auth/security-lab/findings/:id/exceptions",
   "group": "security-lab-more",
   "method": "GET",
   "path": "/auth/security-lab/findings/:id/exceptions",
   "purpose": "Every risk exception ever opened for one finding (its timeline).",
   "auth": {
    "text": "Session + view_security_findings | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_security_findings"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "string",
     "format": "uuid",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { exceptions[] }. Everything in one answer; no paging parameters are read.",
   "responses": [
    {
     "status": 200,
     "text": "{ exceptions[] }. Everything in one answer; no paging parameters are read."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /auth/security-lab/exceptions/:id/approve",
   "group": "security-lab-more",
   "method": "POST",
   "path": "/auth/security-lab/exceptions/:id/approve",
   "purpose": "Approve a pending Critical or High exception; 403 SOD_SELF_APPROVAL if you requested it. Revoke at /exceptions/:id/revoke: the finding reopens.",
   "auth": {
    "text": "Session + manage_security_findings | manage_compliance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "manage_security_findings"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "string",
     "format": "uuid",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body. Revoke takes { note? }",
   "response": "200 the exception, for both calls. Errors are { error: string, code?: string }: 404 \"Exception not found.\" | 409 when the exception is not pending (approve) or not active or pending (revoke) | approve only: 403 SOD_SELF_APPROVAL | 409 SUBJECT_NOT_OPEN when the finding or issue is already verified fixed or marked a false positive.",
   "responses": [
    {
     "status": 200,
     "text": "the exception, for both calls. Errors are { error: string, code?: string }: 404 \"Exception not found.\""
    },
    {
     "status": 409,
     "text": "when the exception is not pending (approve) or not active or pending (revoke) | approve only: 403 SOD_SELF_APPROVAL"
    },
    {
     "status": 409,
     "text": "SUBJECT_NOT_OPEN when the finding or issue is already verified fixed or marked a false positive."
    }
   ],
   "errors": [
    {
     "status": 409,
     "text": "when the exception is not pending (approve) or not active or pending (revoke) | approve only: 403 SOD_SELF_APPROVAL"
    },
    {
     "status": 409,
     "text": "SUBJECT_NOT_OPEN when the finding or issue is already verified fixed or marked a false positive."
    }
   ],
   "guard": "Repeat: 409; once approved, the exception is no longer pending"
  },
  {
   "id": "POST /auth/security-lab/exceptions/:id/revoke",
   "group": "security-lab-more",
   "method": "POST",
   "path": "/auth/security-lab/exceptions/:id/revoke",
   "purpose": "Revoke a risk exception; the finding reopens.",
   "auth": {
    "text": "Session + manage_security_findings | manage_compliance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "manage_security_findings"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "string",
     "format": "uuid",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "A SecurityExceptionRevoke: { note? }",
   "response": "200 the exception | 404 \"Exception not found.\" | 409 when the exception is not active or pending. Errors are { error: string, code?: string }.",
   "responses": [
    {
     "status": 200,
     "text": "the exception"
    },
    {
     "status": 404,
     "text": "\"Exception not found.\""
    },
    {
     "status": 409,
     "text": "when the exception is not active or pending. Errors are { error: string, code?: string }."
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "\"Exception not found.\""
    },
    {
     "status": 409,
     "text": "when the exception is not active or pending. Errors are { error: string, code?: string }."
    }
   ],
   "guard": "Repeat: 409; a revoked exception is neither active nor pending"
  },
  {
   "id": "POST /auth/security-lab/findings/archive",
   "group": "security-lab-more",
   "method": "POST",
   "path": "/auth/security-lab/findings/archive",
   "purpose": "Archive findings and their issues; nothing is deleted. Optional scope: all (default), remote or local. Needs a fresh re-authentication (401 SUDO_REQUIRED). Audit-logged.",
   "auth": {
    "text": "Session + manage_security_findings | manage_compliance + sudo",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "manage_security_findings"
    ],
    "stepUp": "required",
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "A FindingsArchive: { scope? } (or ?scope=): all (default), remote or local",
   "response": "200 { archived, archivedIssues, scope } | 400 for any other scope value | 401 { error: 'SUDO_REQUIRED', message } without a fresh step-up.",
   "responses": [
    {
     "status": 200,
     "text": "{ archived, archivedIssues, scope }"
    },
    {
     "status": 400,
     "text": "for any other scope value"
    },
    {
     "status": 401,
     "text": "{ error: 'SUDO_REQUIRED', message } without a fresh step-up."
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "for any other scope value"
    },
    {
     "status": 401,
     "text": "{ error: 'SUDO_REQUIRED', message } without a fresh step-up."
    }
   ],
   "guard": null
  },
  {
   "id": "DELETE /auth/security-lab/findings",
   "group": "security-lab-more",
   "method": "DELETE",
   "path": "/auth/security-lab/findings",
   "purpose": "Retired: always answers 410 Gone. Use POST /findings/archive.",
   "auth": {
    "text": "Session + manage_security_findings | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "manage_security_findings"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "410 { error: \"Clearing findings was replaced by archiving (POST /security-lab/findings/archive) so lifecycle evidence is kept.\" }. Nothing is changed.",
   "responses": [
    {
     "status": 410,
     "text": "{ error: \"Clearing findings was replaced by archiving (POST /security-lab/findings/archive) so lifecycle evidence is kept.\" }. Nothing is changed."
    }
   ],
   "errors": [
    {
     "status": 410,
     "text": "{ error: \"Clearing findings was replaced by archiving (POST /security-lab/findings/archive) so lifecycle evidence is kept.\" }. Nothing is changed."
    }
   ],
   "guard": "Repeat: every call answers the same 410 and changes nothing"
  },
  {
   "id": "POST /auth/security-lab/host-scan",
   "group": "security-lab-more",
   "method": "POST",
   "path": "/auth/security-lab/host-scan",
   "purpose": "Match the software inventory of your enrolled devices against the CVE database. Writes host findings; audit-logged.",
   "auth": {
    "text": "Session + manage_security_findings | manage_compliance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "manage_security_findings"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 a summary: ok, devices, scanned, vulnerable, totalCves, kevHits.",
   "responses": [
    {
     "status": 200,
     "text": "a summary: ok, devices, scanned, vulnerable, totalCves, kevHits."
    }
   ],
   "errors": [],
   "guard": "Repeat: each call scans the fleet again and writes another SECURITY_HOST_SCAN audit entry"
  },
  {
   "id": "POST /auth/security-lab/sbom-scan",
   "group": "security-lab-more",
   "method": "POST",
   "path": "/auth/security-lab/sbom-scan",
   "purpose": "Scan an uploaded software bill of materials.",
   "auth": {
    "text": "Session + manage_security_findings | manage_compliance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "manage_security_findings"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ sbom } as a JSON string or object in CycloneDX or SPDX form, or the document itself as the whole body.",
   "response": "200 { label, kind, components, cveCount, severity, kevHits, ... } | 400 with the reason when no document was sent or it could not be read.",
   "responses": [
    {
     "status": 200,
     "text": "{ label, kind, components, cveCount, severity, kevHits, ... }"
    },
    {
     "status": 400,
     "text": "with the reason when no document was sent or it could not be read."
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "with the reason when no document was sent or it could not be read."
    }
   ],
   "guard": null
  },
  {
   "id": "GET /auth/security-lab/cve-intel",
   "group": "security-lab-more",
   "method": "GET",
   "path": "/auth/security-lab/cve-intel",
   "purpose": "Vulnerability intelligence status (refresh at /cve-intel/refresh).",
   "auth": {
    "text": "Session + view_security_findings | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_security_findings"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { total: integer, kevListed: integer, lastRefreshedAt: timestamp | null }. lastRefreshedAt is null when it has never been refreshed. POST /cve-intel/refresh (manage_security_findings | manage_compliance, licensed) answers 200 with a refresh summary.",
   "responses": [
    {
     "status": 200,
     "text": "{ total: integer, kevListed: integer, lastRefreshedAt: timestamp | null }. lastRefreshedAt is null when it has never been refreshed. POST /cve-intel/refresh (manage_security_findings | manage_compliance, licensed) answers 200 with a refresh summary."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /auth/security-lab/cve-intel/refresh",
   "group": "security-lab-more",
   "method": "POST",
   "path": "/auth/security-lab/cve-intel/refresh",
   "purpose": "Refresh the vulnerability intelligence now. The call waits for the refresh to finish.",
   "auth": {
    "text": "Session + manage_security_findings | manage_compliance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "manage_security_findings"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 a refresh summary object: ok, plus kev, epss and enrichment, one entry per source refreshed.",
   "responses": [
    {
     "status": 200,
     "text": "a refresh summary object: ok, plus kev, epss and enrichment, one entry per source refreshed."
    }
   ],
   "errors": [],
   "guard": "Audit-logged as SECURITY_CVE_INTEL_REFRESH, at warning level when the summary's ok is false."
  },
  {
   "id": "GET /auth/security-lab/intel-status",
   "group": "security-lab-more",
   "method": "GET",
   "path": "/auth/security-lab/intel-status",
   "purpose": "Freshness of the vulnerability intelligence (what the Intel fresh / Intel stale chip shows).",
   "auth": {
    "text": "Session + view_security_findings | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_security_findings"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { source: string, stampedAt: timestamp | null, importedAt: timestamp | null, ageHours: number | null, staleHours: integer, stale: boolean, total: integer, kevCount: integer, epssCount: integer, trivyDbVersion: string | null, trivyDbAt: timestamp | null, trivyDbAgeHours: number | null, dbUpdatedAt: timestamp | null, dbAgeDays: integer | null, kevUpdatedAt: timestamp | null, epssUpdatedAt: timestamp | null, fresh: boolean }. staleHours is 168 unless changed. stale is true when the intelligence is older than staleHours, or when its age is not known; fresh is true only when stale is false. source is 'none' when there is no intelligence at all. This read does not fail: when the status cannot be read it answers stale: true with zero counts.",
   "responses": [
    {
     "status": 200,
     "text": "{ source: string, stampedAt: timestamp | null, importedAt: timestamp | null, ageHours: number | null, staleHours: integer, stale: boolean, total: integer, kevCount: integer, epssCount: integer, trivyDbVersion: string | null, trivyDbAt: timestamp | null, trivyDbAgeHours: number | null, dbUpdatedAt: timestamp | null, dbAgeDays: integer | null, kevUpdatedAt: timestamp | null, epssUpdatedAt: timestamp | null, fresh: boolean }. staleHours is 168 unless changed. stale is true when the intelligence is older than staleHours, or when its age is not known; fresh is true only when stale is false. source is 'none' when there is no intelligence at all. This read does not fail: when the status cannot be read it answers stale: true with zero counts."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /auth/security-lab/intel-import",
   "group": "security-lab-more",
   "method": "POST",
   "path": "/auth/security-lab/intel-import",
   "purpose": "Load a vulnerability-intelligence bundle on an instance that cannot reach the feeds.",
   "auth": {
    "text": "Session + manage_security_findings | manage_compliance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "manage_security_findings"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ bundle }: the bundle as an object or string, or { bundle: { path } } naming a file on the server.",
   "response": "200 { kevImported, epssImported, asOf, signatureVerified, enrichment, ... } | 400 with the reason when no bundle was sent or its check failed.",
   "responses": [
    {
     "status": 200,
     "text": "{ kevImported, epssImported, asOf, signatureVerified, enrichment, ... }"
    },
    {
     "status": 400,
     "text": "with the reason when no bundle was sent or its check failed."
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "with the reason when no bundle was sent or its check failed."
    }
   ],
   "guard": "A bundle that fails validation, its checksum or its signature imports nothing; the rejection is written to the audit log."
  },
  {
   "id": "GET /auth/security-lab/analyst-summary",
   "group": "security-lab-more",
   "method": "GET",
   "path": "/auth/security-lab/analyst-summary",
   "purpose": "The AI analyst summary on the Findings view: figures computed by code, narrative drafted by the local model.",
   "auth": {
    "text": "Session + view_security_findings | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_security_findings"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { bundle: { generatedAt: timestamp, unit: 'issue' | 'finding', posture: number | null, totalCount: integer, openCount: integer, acceptedCount: integer, countsBySeverity: { [severity]: integer }, kevCount: integer, kev: [{ cveId: string | null, severity: string, target: string, pkgName: string | null, installedVersion: string | null, fixedVersion: string | null, epssScore: number | null }], slaBreachCount: integer, slaBreaches: [{ ref: string, severity: string, target: string, slaDueAt: timestamp, overdueDays: integer }], topRiskAssets: [{ target: string, issueCount: integer, kevCount: integer, maxSeverity: string, riskScore: integer }], toxicComboCount: integer, toxicCombos: [{ label: string, severity: string, hops: integer | null }] }, prose: string | null, provenance: { aiUsed: boolean, factCount: integer, model: string | null, stamp: string | null, reason: string | null } }. bundle is computed by code and is always present. prose is null when the AI assistant is switched off (reason 'ai_disabled'), when your instance requires use_ai and you do not hold it ('use_ai_required'), when the model returned nothing ('empty_response') or could not be reached ('ai_unavailable'). topRiskAssets holds at most 10. Each read is written to the audit log.",
   "responses": [
    {
     "status": 200,
     "text": "{ bundle: { generatedAt: timestamp, unit: 'issue' | 'finding', posture: number | null, totalCount: integer, openCount: integer, acceptedCount: integer, countsBySeverity: { [severity]: integer }, kevCount: integer, kev: [{ cveId: string | null, severity: string, target: string, pkgName: string | null, installedVersion: string | null, fixedVersion: string | null, epssScore: number | null }], slaBreachCount: integer, slaBreaches: [{ ref: string, severity: string, target: string, slaDueAt: timestamp, overdueDays: integer }], topRiskAssets: [{ target: string, issueCount: integer, kevCount: integer, maxSeverity: string, riskScore: integer }], toxicComboCount: integer, toxicCombos: [{ label: string, severity: string, hops: integer | null }] }, prose: string | null, provenance: { aiUsed: boolean, factCount: integer, model: string | null, stamp: string | null, reason: string | null } }. bundle is computed by code and is always present. prose is null when the AI assistant is switched off (reason 'ai_disabled'), when your instance requires use_ai and you do not hold it ('use_ai_required'), when the model returned nothing ('empty_response') or could not be reached ('ai_unavailable'). topRiskAssets holds at most 10. Each read is written to the audit log."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /auth/security-lab/compliance-evidence",
   "group": "security-lab-more",
   "method": "GET",
   "path": "/auth/security-lab/compliance-evidence",
   "purpose": "Scan-cadence evidence per scope, as passed to the Compliance Hub.",
   "auth": {
    "text": "Session + view_security_findings | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_security_findings"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { available: boolean, generatedAt: timestamp, pciWindowDays: 90, controls: object[], scopes: [{ scope: string, hosts: string[], verified: boolean, controls: string[], cadence: { withinWindow: boolean, daysSinceLastScan: integer | null, dueBy: timestamp | null, overdue: boolean, lastExternalScan: { scanId: string, timestamp: timestamp, status: string, kevOpen: integer, snapshotHash: string | null, snapshotSealed: boolean, controls: string[] } | null } }], summary: { verifiedScopes: integer, scopesScannedInWindow: integer, scopesOverdue: integer, totalExternalScans: integer } | null }. controls has two entries, CC7.1 and PCI-11.3, each with id, framework, title and note; CC7.1 adds satisfied: boolean and evidenceCount: integer, PCI-11.3 adds quarterlyCadenceMet: boolean, verifiedScopes, scannedInWindow and overdue. When the evidence store cannot be read the answer is still 200, with available: false, reason: 'evidence-store-unavailable', empty scopes and controls, and summary: null.",
   "responses": [
    {
     "status": 200,
     "text": "{ available: boolean, generatedAt: timestamp, pciWindowDays: 90, controls: object[], scopes: [{ scope: string, hosts: string[], verified: boolean, controls: string[], cadence: { withinWindow: boolean, daysSinceLastScan: integer | null, dueBy: timestamp | null, overdue: boolean, lastExternalScan: { scanId: string, timestamp: timestamp, status: string, kevOpen: integer, snapshotHash: string | null, snapshotSealed: boolean, controls: string[] } | null } }], summary: { verifiedScopes: integer, scopesScannedInWindow: integer, scopesOverdue: integer, totalExternalScans: integer } | null }. controls has two entries, CC7.1 and PCI-11.3, each with id, framework, title and note; CC7.1 adds satisfied: boolean and evidenceCount: integer, PCI-11.3 adds quarterlyCadenceMet: boolean, verifiedScopes, scannedInWindow and overdue. When the evidence store cannot be read the answer is still 200, with available: false, reason: 'evidence-store-unavailable', empty scopes and controls, and summary: null."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /auth/security-lab/kev-exposure",
   "group": "security-lab-more",
   "method": "GET",
   "path": "/auth/security-lab/kev-exposure",
   "purpose": "Exposure to known-exploited vulnerabilities.",
   "auth": {
    "text": "Session + view_security_findings | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_security_findings"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [
    "limit",
    "offset"
   ],
   "query": [
    {
     "name": "limit",
     "type": "integer",
     "default": 100,
     "maximum": 200
    },
    {
     "name": "offset",
     "type": "integer",
     "default": 0,
     "minimum": 0
    },
    {
     "name": "includeArchived",
     "type": "string",
     "enum": [
      "1",
      "true"
     ]
    },
    {
     "name": "verdict",
     "type": "string",
     "enum": [
      "exposed",
      "patched",
      "unconfirmed"
     ]
    }
   ],
   "request": "Query limit (default 100, maximum 200), offset (default 0), includeArchived (1 | true), verdict (exposed | patched | unconfirmed).",
   "response": "200 { items: [{ id: uuid, findingId: uuid, cveId: string | null, title: string | null, target: string, pkgName: string | null, installedVersion: string | null, fixedVersion: string | null, severity: string, cvssScore: number | null, epssScore: number | null, status: string, kevListed: true, confirmed: boolean, confirmationVerdict: 'exposed' | 'patched' | 'unconfirmed', confirmationMethod: 'version-evidence', confirmationLabel: string }], total: integer, limit: integer, offset: integer, counts: { total: integer, exposed: integer, patched: integer, unconfirmed: integer, confirmedExposed: integer } | null, method: 'version-evidence' }. total counts every known-exploited issue before the verdict filter. counts covers the whole set, not the page, and is null when it cannot be read. The verdict compares the installed version with the fixed version; nothing is exploited.",
   "responses": [
    {
     "status": 200,
     "text": "{ items: [{ id: uuid, findingId: uuid, cveId: string | null, title: string | null, target: string, pkgName: string | null, installedVersion: string | null, fixedVersion: string | null, severity: string, cvssScore: number | null, epssScore: number | null, status: string, kevListed: true, confirmed: boolean, confirmationVerdict: 'exposed' | 'patched' | 'unconfirmed', confirmationMethod: 'version-evidence', confirmationLabel: string }], total: integer, limit: integer, offset: integer, counts: { total: integer, exposed: integer, patched: integer, unconfirmed: integer, confirmedExposed: integer } | null, method: 'version-evidence' }. total counts every known-exploited issue before the verdict filter. counts covers the whole set, not the page, and is null when it cannot be read. The verdict compares the installed version with the fixed version; nothing is exploited."
    }
   ],
   "errors": [],
   "guard": "Ordered by CVSS score, highest first, then most recently seen. The verdict filter is applied after the page is read, so a filtered page can hold fewer rows than limit."
  },
  {
   "id": "GET /auth/security-lab/attack-paths",
   "group": "security-lab-more",
   "method": "GET",
   "path": "/auth/security-lab/attack-paths",
   "purpose": "Attack paths across your assets (one at /attack-paths/:id).",
   "auth": {
    "text": "Session + view_security_findings | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_security_findings"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query ?includeArchived=true",
   "response": "200 { items[], total, counts { toxic, inconclusive } }, highest CVSS first; no paging. /attack-paths/:id adds chain[], issues[] and mitigation | 404 \"Attack path not found\".",
   "responses": [
    {
     "status": 200,
     "text": "{ items[], total, counts { toxic, inconclusive } }, highest CVSS first; no paging. /attack-paths/:id adds chain[], issues[] and mitigation"
    },
    {
     "status": 404,
     "text": "\"Attack path not found\"."
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "\"Attack path not found\"."
    }
   ],
   "guard": null
  },
  {
   "id": "GET /auth/security-lab/attack-paths/:id",
   "group": "security-lab-more",
   "method": "GET",
   "path": "/auth/security-lab/attack-paths/:id",
   "purpose": "One attack path in full.",
   "auth": {
    "text": "Session + view_security_findings | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_security_findings"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "string",
     "basis": "unknown"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 the attack path with chain[], issues[] and mitigation | 404 \"Attack path not found\".",
   "responses": [
    {
     "status": 200,
     "text": "the attack path with chain[], issues[] and mitigation"
    },
    {
     "status": 404,
     "text": "\"Attack path not found\"."
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "\"Attack path not found\"."
    }
   ],
   "guard": null
  },
  {
   "id": "GET /auth/security-lab/unified-assets",
   "group": "security-lab-more",
   "method": "GET",
   "path": "/auth/security-lab/unified-assets",
   "purpose": "The combined asset inventory (one at /unified-assets/:id).",
   "auth": {
    "text": "Session + view_security_findings | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_security_findings"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { assets[], total }, ranked by composite risk; everything in one answer, no paging. /unified-assets/:id answers the asset | 404 \"Asset not found\".",
   "responses": [
    {
     "status": 200,
     "text": "{ assets[], total }, ranked by composite risk; everything in one answer, no paging. /unified-assets/:id answers the asset"
    },
    {
     "status": 404,
     "text": "\"Asset not found\"."
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "\"Asset not found\"."
    }
   ],
   "guard": null
  },
  {
   "id": "GET /auth/security-lab/unified-assets/:id",
   "group": "security-lab-more",
   "method": "GET",
   "path": "/auth/security-lab/unified-assets/:id",
   "purpose": "One asset from the combined inventory.",
   "auth": {
    "text": "Session + view_security_findings | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_security_findings"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "string",
     "basis": "unknown"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 the asset | 404 \"Asset not found\".",
   "responses": [
    {
     "status": 200,
     "text": "the asset"
    },
    {
     "status": 404,
     "text": "\"Asset not found\"."
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "\"Asset not found\"."
    }
   ],
   "guard": null
  },
  {
   "id": "POST /auth/security-lab/assets/discover",
   "group": "security-lab-more",
   "method": "POST",
   "path": "/auth/security-lab/assets/discover",
   "purpose": "Run hostname discovery under your verified domains now (the Discover now button). One discovered asset at GET /assets/:id.",
   "auth": {
    "text": "Session + manage_security_scopes | manage_compliance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "manage_security_scopes"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { ok: true, ... } with the counts of what the run found.",
   "responses": [
    {
     "status": 200,
     "text": "{ ok: true, ... } with the counts of what the run found."
    }
   ],
   "errors": [],
   "guard": "Discovery only adds to the inventory; it never authorises a scan. Repeat: each call runs discovery again"
  },
  {
   "id": "GET /auth/security-lab/assets/:id",
   "group": "security-lab-more",
   "method": "GET",
   "path": "/auth/security-lab/assets/:id",
   "purpose": "One discovered asset.",
   "auth": {
    "text": "Session + view_security_findings | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_security_findings"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "string",
     "format": "uuid",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 the asset | 404 \"Asset not found\".",
   "responses": [
    {
     "status": 200,
     "text": "the asset"
    },
    {
     "status": 404,
     "text": "\"Asset not found\"."
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "\"Asset not found\"."
    }
   ],
   "guard": null
  },
  {
   "id": "PATCH /auth/security-lab/assets/:id/criticality",
   "group": "security-lab-more",
   "method": "PATCH",
   "path": "/auth/security-lab/assets/:id/criticality",
   "purpose": "Set how critical an asset is; it feeds the risk score. Audit-logged.",
   "auth": {
    "text": "Session + manage_security_scopes | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "manage_security_scopes"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "string",
     "format": "uuid",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "An AssetCriticalityUpdate: { criticality: 'low' | 'medium' | 'high' | 'crit' } ('critical' is accepted and stored as 'crit')",
   "response": "200 { id, name, criticality } | 400 for any other value | 404 \"Asset not found\".",
   "responses": [
    {
     "status": 200,
     "text": "{ id, name, criticality }"
    },
    {
     "status": 400,
     "text": "for any other value"
    },
    {
     "status": 404,
     "text": "\"Asset not found\"."
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "for any other value"
    },
    {
     "status": 404,
     "text": "\"Asset not found\"."
    }
   ],
   "guard": "Repeat: safe; the call sets a value."
  },
  {
   "id": "GET /auth/security-lab/remediations",
   "group": "security-lab-more",
   "method": "GET",
   "path": "/auth/security-lab/remediations",
   "purpose": "Remediation work items.",
   "auth": {
    "text": "Session + view_security_findings | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_security_findings"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [
    "limit",
    "offset"
   ],
   "query": [
    {
     "name": "status",
     "type": "string"
    },
    {
     "name": "findingId",
     "type": "string"
    },
    {
     "name": "target",
     "type": "string"
    },
    {
     "name": "limit",
     "type": "integer",
     "default": 100,
     "maximum": 500
    },
    {
     "name": "offset",
     "type": "integer",
     "default": 0,
     "minimum": 0
    }
   ],
   "request": "Query status?, findingId?, target?, limit (default 100, maximum 500), offset (default 0).",
   "response": "200 { remediations: SecurityRemediation[], total: integer, limit: integer, offset: integer } (typed under Response Objects), newest first.",
   "responses": [
    {
     "status": 200,
     "text": "{ remediations: SecurityRemediation[], total: integer, limit: integer, offset: integer } (typed under Response Objects), newest first."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /auth/security-lab/findings/:id/remediation",
   "group": "security-lab-more",
   "method": "POST",
   "path": "/auth/security-lab/findings/:id/remediation",
   "purpose": "Open a remediation for a finding (hand off at /remediations/:id/handoff, retest at /remediations/retest).",
   "auth": {
    "text": "Session + manage_security_findings | manage_compliance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "manage_security_findings"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "string",
     "format": "uuid",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "Propose: none. Handoff: { handoffRef? (up to 255 characters) }",
   "response": "201 { proposed: true, reason: 'proposed', remediation } | 200 { proposed: false, reason, remediation? } when nothing new was opened | 404 \"Finding not found\". Handoff: 200 the remediation | 400 \"Cannot hand off\" with code | 404. Retest: 200 { ok: true, ... }.",
   "responses": [
    {
     "status": 201,
     "text": "{ proposed: true, reason: 'proposed', remediation }"
    },
    {
     "status": 200,
     "text": "{ proposed: false, reason, remediation? } when nothing new was opened"
    },
    {
     "status": 404,
     "text": "\"Finding not found\". Handoff: 200 the remediation"
    },
    {
     "status": 400,
     "text": "\"Cannot hand off\" with code"
    },
    {
     "status": 404,
     "text": ". Retest: 200 { ok: true, ... }."
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "\"Finding not found\". Handoff: 200 the remediation"
    },
    {
     "status": 400,
     "text": "\"Cannot hand off\" with code"
    },
    {
     "status": 404,
     "text": ". Retest: 200 { ok: true, ... }."
    }
   ],
   "guard": "Repeat: a finding that already has an open remediation gets that one back with 200 (reason 'already_open'), not a second one."
  },
  {
   "id": "POST /auth/security-lab/remediations/:id/handoff",
   "group": "security-lab-more",
   "method": "POST",
   "path": "/auth/security-lab/remediations/:id/handoff",
   "purpose": "Mark a remediation as handed off, with an optional reference to where the work is tracked.",
   "auth": {
    "text": "Session + manage_security_findings | manage_compliance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "manage_security_findings"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "string",
     "basis": "unknown"
    }
   ],
   "paging": [],
   "query": [],
   "request": "A RemediationHandoff: { handoffRef? (up to 255 characters) }",
   "response": "200 the SecurityRemediation | 400 \"Cannot hand off (<reason>).\" with the reason as code | 404 \"Remediation not found\".",
   "responses": [
    {
     "status": 200,
     "text": "the SecurityRemediation"
    },
    {
     "status": 400,
     "text": "\"Cannot hand off (<reason>).\" with the reason as code"
    },
    {
     "status": 404,
     "text": "\"Remediation not found\"."
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "\"Cannot hand off (<reason>).\" with the reason as code"
    },
    {
     "status": 404,
     "text": "\"Remediation not found\"."
    }
   ],
   "guard": null
  },
  {
   "id": "POST /auth/security-lab/remediations/retest",
   "group": "security-lab-more",
   "method": "POST",
   "path": "/auth/security-lab/remediations/retest",
   "purpose": "Re-test the remediations that are waiting for a re-test, now.",
   "auth": {
    "text": "Session + manage_security_findings | manage_compliance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "manage_security_findings"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "No body is read.",
   "response": "200 { ok: true, ... } with the counts of the sweep.",
   "responses": [
    {
     "status": 200,
     "text": "{ ok: true, ... } with the counts of the sweep."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /auth/security-lab/ticketing",
   "group": "security-lab-more",
   "method": "GET",
   "path": "/auth/security-lab/ticketing",
   "purpose": "Read the ticketing integration settings (POST to change them: manage_security_scopes | manage_compliance).",
   "auth": {
    "text": "Session + view_security_findings | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_security_findings"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "POST: { providerId?, projectKey? }. An empty providerId switches automatic ticketing off.",
   "response": "200 { providerId, projectKey, providers[{ id, name, type, isActive }] }. POST 200 { ok: true }.",
   "responses": [
    {
     "status": 200,
     "text": "{ providerId, projectKey, providers[{ id, name, type, isActive }] }. POST 200 { ok: true }."
    }
   ],
   "errors": [],
   "guard": "Repeat: safe; POST sets both values."
  },
  {
   "id": "POST /auth/security-lab/ticketing",
   "group": "security-lab-more",
   "method": "POST",
   "path": "/auth/security-lab/ticketing",
   "purpose": "Change the ticketing integration settings.",
   "auth": {
    "text": "Session + manage_security_scopes | manage_compliance",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "manage_security_scopes"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "A TicketingUpdate: { providerId?, projectKey? }. An empty providerId switches automatic ticketing off.",
   "response": "200 { ok: true }.",
   "responses": [
    {
     "status": 200,
     "text": "{ ok: true }."
    }
   ],
   "errors": [],
   "guard": "Repeat: safe; the call sets both values."
  },
  {
   "id": "GET /user-management/employees",
   "group": "hr-employee-files",
   "method": "GET",
   "path": "/user-management/employees",
   "purpose": "The directory of employee files (one file at /employees/:userId).",
   "auth": {
    "text": "Session + view_employee_records | manage_employee_records",
    "kind": "session",
    "permissions": [
     "manage_employee_records",
     "view_employee_records"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [
    "limit",
    "offset"
   ],
   "query": [
    {
     "name": "search",
     "type": "string"
    },
    {
     "name": "status",
     "type": "string"
    },
    {
     "name": "department",
     "type": "string"
    },
    {
     "name": "limit",
     "type": "integer",
     "default": 50,
     "minimum": 1,
     "maximum": 200
    },
    {
     "name": "offset",
     "type": "integer",
     "default": 0,
     "minimum": 0
    }
   ],
   "request": "Query: search (every word typed must be found in the first name, last name, email, job title or employee id; first 8 words), status, department, limit (default 50, 1 to 200), offset (default 0).",
   "response": "200 { total:integer (every match, not the page), employees:[ EmployeeListItem ] }, ordered by last name, then first name. One file (/employees/:userId): the EmployeeFile object { user:EmployeeFileUser, profile:EmployeeProfile, emergencyContacts:[ EmergencyContact ], history:[ EmploymentEvent ] (newest 200 job changes), reminders:[ EmployeeReminder ], authority:HrAuthority, people:object } | 400 \"Invalid employee id.\" | 404 \"Employee not found.\"",
   "responses": [
    {
     "status": 200,
     "text": "{ total:integer (every match, not the page), employees:[ EmployeeListItem ] }, ordered by last name, then first name. One file (/employees/:userId): the EmployeeFile object { user:EmployeeFileUser, profile:EmployeeProfile, emergencyContacts:[ EmergencyContact ], history:[ EmploymentEvent ] (newest 200 job changes), reminders:[ EmployeeReminder ], authority:HrAuthority, people:object }"
    },
    {
     "status": 400,
     "text": "\"Invalid employee id.\""
    },
    {
     "status": 404,
     "text": "\"Employee not found.\""
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "\"Invalid employee id.\""
    },
    {
     "status": 404,
     "text": "\"Employee not found.\""
    }
   ],
   "guard": "Date of birth and home address are never in a list or a file: only hasDateOfBirth and hasHomeAddress."
  },
  {
   "id": "PUT /user-management/employees/:userId",
   "group": "hr-employee-files",
   "method": "PUT",
   "path": "/user-management/employees/:userId",
   "purpose": "Edit a file's employment and personal details.",
   "auth": {
    "text": "Session + manage_employee_records",
    "kind": "session",
    "permissions": [
     "manage_employee_records"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "userId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The EmployeeProfileUpdate object. Any of: preferredName, legalName, nationality, costCenter, legalEntity, noticePeriod (text, up to 255 characters), rightToWorkStatus (verified | pending | expired | not_required), hireDate, originalHireDate, probationEndDate, contractEndDate, rightToWorkVerifiedAt, rightToWorkExpiresAt (YYYY-MM-DD; null clears), dateOfBirth (YYYY-MM-DD), homeAddress (up to 1,000 characters).",
   "response": "200 the EmployeeFile object | 400 \"Nothing to update.\", a date that is not a real calendar day, or an unknown rightToWorkStatus | 404 \"Employee not found.\"",
   "responses": [
    {
     "status": 200,
     "text": "the EmployeeFile object"
    },
    {
     "status": 400,
     "text": "\"Nothing to update.\", a date that is not a real calendar day, or an unknown rightToWorkStatus"
    },
    {
     "status": 404,
     "text": "\"Employee not found.\""
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "\"Nothing to update.\", a date that is not a real calendar day, or an unknown rightToWorkStatus"
    },
    {
     "status": 404,
     "text": "\"Employee not found.\""
    }
   ],
   "guard": "Job title, department, manager, level, employment type and work location are ignored here: they change only through a job change. A field owned by the connected HR system is refused. Repeat: safe; the call sets values."
  },
  {
   "id": "POST /user-management/employees/:userId/reveal",
   "group": "hr-employee-files",
   "method": "POST",
   "path": "/user-management/employees/:userId/reveal",
   "purpose": "Reveal one sealed value on a file, such as date of birth or home address. A POST because each reveal is written to the audit log.",
   "auth": {
    "text": "Session + view_employee_records | manage_employee_records",
    "kind": "session",
    "permissions": [
     "manage_employee_records",
     "view_employee_records"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "userId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The SensitiveFieldReveal object: { field: 'dateOfBirth' | 'homeAddress' }",
   "response": "200 { field:string, value:string|null } (value is null when nothing is stored) | 400 for any other field | 404 \"Employee not found.\"",
   "responses": [
    {
     "status": 200,
     "text": "{ field:string, value:string|null } (value is null when nothing is stored)"
    },
    {
     "status": 400,
     "text": "for any other field"
    },
    {
     "status": 404,
     "text": "\"Employee not found.\""
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "for any other field"
    },
    {
     "status": 404,
     "text": "\"Employee not found.\""
    }
   ],
   "guard": "Repeat: safe; it is a read, and every call is written to the audit log as EMPLOYEE_SENSITIVE_FIELD_VIEWED"
  },
  {
   "id": "PUT /user-management/employees/:userId/emergency-contacts",
   "group": "hr-employee-files",
   "method": "PUT",
   "path": "/user-management/employees/:userId/emergency-contacts",
   "purpose": "Replace a person's emergency contacts.",
   "auth": {
    "text": "Session + manage_employee_records",
    "kind": "session",
    "permissions": [
     "manage_employee_records"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "userId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "{ contacts:[ EmergencyContactInput ] }: each { name:string (required), relationship?:string, phone?:string (up to 40 characters) }, at most 5, kept in the order sent.",
   "response": "200 the EmployeeFile object | 400 \"contacts must be a list.\", \"At most 5 emergency contacts.\" or \"Each emergency contact needs a name.\"",
   "responses": [
    {
     "status": 200,
     "text": "the EmployeeFile object"
    },
    {
     "status": 400,
     "text": "\"contacts must be a list.\", \"At most 5 emergency contacts.\" or \"Each emergency contact needs a name.\""
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "\"contacts must be a list.\", \"At most 5 emergency contacts.\" or \"Each emergency contact needs a name.\""
    }
   ],
   "guard": "Repeat: safe; the list sent replaces the stored list."
  },
  {
   "id": "POST /user-management/employees/:userId/job-changes",
   "group": "hr-employee-files",
   "method": "POST",
   "path": "/user-management/employees/:userId/job-changes",
   "purpose": "Record a job change: title, department, manager, level or location (cancel one with DELETE /job-changes/:eventId).",
   "auth": {
    "text": "Session + manage_employee_records",
    "kind": "session",
    "permissions": [
     "manage_employee_records"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "userId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The JobChangeCreate object: { type (required: promotion | transfer | manager_change | title_change | other), effectiveDate? (YYYY-MM-DD; default today), changes (at least one of position, department, managerId, level, employmentType, workLocation), reason? (up to 1,000 characters) }",
   "response": "201 the EmployeeFile object | 400 for an unknown type, a manager that does not exist, a change that would make the reporting line circular, or \"Nothing changes — every field already has that value.\" DELETE /job-changes/:eventId: 200 the EmployeeFile object | 400 \"Only a scheduled change can be cancelled.\" | 404 \"Job change not found.\"",
   "responses": [
    {
     "status": 201,
     "text": "the EmployeeFile object"
    },
    {
     "status": 400,
     "text": "for an unknown type, a manager that does not exist, a change that would make the reporting line circular, or \"Nothing changes — every field already has that value.\" DELETE /job-changes/:eventId: 200 the EmployeeFile object"
    },
    {
     "status": 400,
     "text": "\"Only a scheduled change can be cancelled.\""
    },
    {
     "status": 404,
     "text": "\"Job change not found.\""
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "for an unknown type, a manager that does not exist, a change that would make the reporting line circular, or \"Nothing changes — every field already has that value.\" DELETE /job-changes/:eventId: 200 the EmployeeFile object"
    },
    {
     "status": 400,
     "text": "\"Only a scheduled change can be cancelled.\""
    },
    {
     "status": 404,
     "text": "\"Job change not found.\""
    }
   ],
   "guard": "A date in the future is stored as scheduled and applied on that day. Repeat: an immediate change sent twice is refused the second time (400, nothing changes); a scheduled change sent twice is stored twice."
  },
  {
   "id": "DELETE /user-management/employees/:userId/job-changes/:eventId",
   "group": "hr-employee-files",
   "method": "DELETE",
   "path": "/user-management/employees/:userId/job-changes/:eventId",
   "purpose": "Cancel a job change that is scheduled for a later day.",
   "auth": {
    "text": "Session + manage_employee_records",
    "kind": "session",
    "permissions": [
     "manage_employee_records"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "userId",
     "type": "integer",
     "basis": "model"
    },
    {
     "name": "eventId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body. eventId is the id of the scheduled change in the employee file.",
   "response": "200 the EmployeeFile object | 400 \"Only a scheduled change can be cancelled.\" | 404 \"Job change not found.\" or \"Employee not found.\"",
   "responses": [
    {
     "status": 200,
     "text": "the EmployeeFile object"
    },
    {
     "status": 400,
     "text": "\"Only a scheduled change can be cancelled.\""
    },
    {
     "status": 404,
     "text": "\"Job change not found.\" or \"Employee not found.\""
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "\"Only a scheduled change can be cancelled.\""
    },
    {
     "status": 404,
     "text": "\"Job change not found.\" or \"Employee not found.\""
    }
   ],
   "guard": "Repeat: a change that is already cancelled answers 400. The cancellation is written to the audit log."
  },
  {
   "id": "GET /user-management/employees/:userId/departure",
   "group": "hr-employee-files",
   "method": "GET",
   "path": "/user-management/employees/:userId/departure",
   "purpose": "The leaving record and HR's exit checklist (save with PUT, manage_employee_records).",
   "auth": {
    "text": "Session + view_employee_records | manage_employee_records",
    "kind": "session",
    "permissions": [
     "manage_employee_records",
     "view_employee_records"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "userId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "PUT: any of { lastDay (YYYY-MM-DD), departureType (voluntary | involuntary | end_of_contract | null), reasonCode (one of the keys in reasons[], or null), rehireEligible (true | false | null), checklist { <key>: true | false } }",
   "response": "200 { departure:DepartureRecord|null (null until one is recorded), directReports:integer, types:[{ key:string, label:string }], reasons:[{ key:string, label:string, type:string|null (the departure type the reason belongs to) }] }. PUT answers the same shape | 400 \"Set the last day first.\", \"Unknown departure type.\", \"Unknown reason.\"",
   "responses": [
    {
     "status": 200,
     "text": "{ departure:DepartureRecord|null (null until one is recorded), directReports:integer, types:[{ key:string, label:string }], reasons:[{ key:string, label:string, type:string|null (the departure type the reason belongs to) }] }. PUT answers the same shape"
    },
    {
     "status": 400,
     "text": "\"Set the last day first.\", \"Unknown departure type.\", \"Unknown reason.\""
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "\"Set the last day first.\", \"Unknown departure type.\", \"Unknown reason.\""
    }
   ],
   "guard": "Repeat: safe; a person has one open leaving record and PUT updates it in place."
  },
  {
   "id": "GET /user-management/employees/me",
   "group": "hr-employee-files",
   "method": "GET",
   "path": "/user-management/employees/me",
   "purpose": "Your own file (a summary at /me/summary).",
   "auth": {
    "text": "Session",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { user:{ id:integer, name:string, firstName:string|null, lastName:string|null, email:string, position:string|null, department:string|null, managerName:string|null, employeeId:string|null, phoneNumber:string|null, personalEmail:string|null }, profile:{ preferredName, legalName, nationality, hireDate, originalHireDate, employmentType, workLocation, level, probationEndDate, contractEndDate, noticePeriod (typed as on EmployeeProfile), dateOfBirth:string|null, homeAddress:string|null }, emergencyContacts:[ EmergencyContact ], history:[{ id:integer, type:string, effectiveDate:string (date), status:'applied', changes:object }] (applied changes among the newest 100), equipment:[{ id:integer, name, type, manufacturer, serialNumber, status, platform }]|null (null when the device list cannot be read), directReports:integer, connectedTo:string|null (the connected HR system's name) }. /me/summary answers { directReports:integer }.",
   "responses": [
    {
     "status": 200,
     "text": "{ user:{ id:integer, name:string, firstName:string|null, lastName:string|null, email:string, position:string|null, department:string|null, managerName:string|null, employeeId:string|null, phoneNumber:string|null, personalEmail:string|null }, profile:{ preferredName, legalName, nationality, hireDate, originalHireDate, employmentType, workLocation, level, probationEndDate, contractEndDate, noticePeriod (typed as on EmployeeProfile), dateOfBirth:string|null, homeAddress:string|null }, emergencyContacts:[ EmergencyContact ], history:[{ id:integer, type:string, effectiveDate:string (date), status:'applied', changes:object }] (applied changes among the newest 100), equipment:[{ id:integer, name, type, manufacturer, serialNumber, status, platform }]|null (null when the device list cannot be read), directReports:integer, connectedTo:string|null (the connected HR system's name) }. /me/summary answers { directReports:integer }."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "PUT /user-management/employees/me",
   "group": "hr-employee-files",
   "method": "PUT",
   "path": "/user-management/employees/me",
   "purpose": "Update your own personal details (your emergency contacts at PUT /me/emergency-contacts).",
   "auth": {
    "text": "Session",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The MyDetailsUpdate object: any of { phoneNumber?:string (up to 40 characters), personalEmail?:string (an email address other than your work email), preferredName?:string, homeAddress?:string (up to 1,000 characters) }; null clears a value, and a body with none of them answers 400. /me/emergency-contacts takes the same { contacts[] } body as the HR route.",
   "response": "200 your file, in the shape of GET /employees/me | 400 \"Nothing to update.\", \"Personal email is not a valid email address.\" or \"Personal email must be different from your work email.\"",
   "responses": [
    {
     "status": 200,
     "text": "your file, in the shape of GET /employees/me"
    },
    {
     "status": 400,
     "text": "\"Nothing to update.\", \"Personal email is not a valid email address.\" or \"Personal email must be different from your work email.\""
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "\"Nothing to update.\", \"Personal email is not a valid email address.\" or \"Personal email must be different from your work email.\""
    }
   ],
   "guard": "Any other field in the body is ignored. Repeat: safe."
  },
  {
   "id": "GET /user-management/employees/my-team",
   "group": "hr-employee-files",
   "method": "GET",
   "path": "/user-management/employees/my-team",
   "purpose": "The people below you in the reporting line (one person's reduced file at /my-team/:userId).",
   "auth": {
    "text": "Session",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { team:[{ id:integer, name:string, email:string, position:string|null, department:string|null, status:string|null, depth:integer (1 for a direct report), direct:boolean, reportsTo:string|null (null for a direct report), hireDate:string (date)|null, workLocation:string|null, reminders:[ EmployeeReminder ] }] }; everyone below you, no paging. /my-team/:userId answers { user:{ id:integer, name:string, email:string, position, department, status, managerName:string|null }, profile:{ hireDate, employmentType, workLocation, level, probationEndDate, contractEndDate (typed as on EmployeeProfile) }, history[] (as in GET /employees/me), reminders:[ EmployeeReminder ], equipment[] (as in GET /employees/me) } | 404 \"This person is not in your team.\"",
   "responses": [
    {
     "status": 200,
     "text": "{ team:[{ id:integer, name:string, email:string, position:string|null, department:string|null, status:string|null, depth:integer (1 for a direct report), direct:boolean, reportsTo:string|null (null for a direct report), hireDate:string (date)|null, workLocation:string|null, reminders:[ EmployeeReminder ] }] }; everyone below you, no paging. /my-team/:userId answers { user:{ id:integer, name:string, email:string, position, department, status, managerName:string|null }, profile:{ hireDate, employmentType, workLocation, level, probationEndDate, contractEndDate (typed as on EmployeeProfile) }, history[] (as in GET /employees/me), reminders:[ EmployeeReminder ], equipment[] (as in GET /employees/me) }"
    },
    {
     "status": 404,
     "text": "\"This person is not in your team.\""
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "\"This person is not in your team.\""
    }
   ],
   "guard": "Opening one person's file is written to the audit log."
  },
  {
   "id": "GET /user-management/employees/hr-settings",
   "group": "hr-employee-files",
   "method": "GET",
   "path": "/user-management/employees/hr-settings",
   "purpose": "Read HR Settings, including which system owns people data.",
   "auth": {
    "text": "Session + view_employee_records | manage_employee_records",
    "kind": "session",
    "permissions": [
     "manage_employee_records",
     "view_employee_records"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 the fields of the HrAuthority object, plus providers:[{ id:integer, name:string, type:string, isActive:boolean }] (the connected HR systems, by name), governedFields:[{ key:string, label:string, group:string }], retention:{ months:integer } (0 means off) and retentionLimits:{ min:6, max:240 }.",
   "responses": [
    {
     "status": 200,
     "text": "the fields of the HrAuthority object, plus providers:[{ id:integer, name:string, type:string, isActive:boolean }] (the connected HR systems, by name), governedFields:[{ key:string, label:string, group:string }], retention:{ months:integer } (0 means off) and retentionLimits:{ min:6, max:240 }."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "PUT /user-management/employees/hr-settings",
   "group": "hr-employee-files",
   "method": "PUT",
   "path": "/user-management/employees/hr-settings",
   "purpose": "Change HR Settings.",
   "auth": {
    "text": "Session + manage_employee_records",
    "kind": "session",
    "permissions": [
     "manage_employee_records"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The HrAuthorityWrite object: { mode (required: 'zonitix' | 'external'), providerId (required for external: a connected HR system), fields? { <field key>: 'zonitix' | 'external' } }",
   "response": "200 the same shape as the GET | 400 \"mode must be 'zonitix' or 'external'.\" or \"Choose a connected HRIS. Add one under Identity Hooks first.\"",
   "responses": [
    {
     "status": 200,
     "text": "the same shape as the GET"
    },
    {
     "status": 400,
     "text": "\"mode must be 'zonitix' or 'external'.\" or \"Choose a connected HRIS. Add one under Identity Hooks first.\""
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "\"mode must be 'zonitix' or 'external'.\" or \"Choose a connected HRIS. Add one under Identity Hooks first.\""
    }
   ],
   "guard": "Written to the audit log at warning level with the before and after. Repeat: safe."
  },
  {
   "id": "PUT /user-management/employees/hr-settings/retention",
   "group": "hr-employee-files",
   "method": "PUT",
   "path": "/user-management/employees/hr-settings/retention",
   "purpose": "Set how long the sensitive HR data of people who have left is kept.",
   "auth": {
    "text": "Session + manage_employee_records",
    "kind": "session",
    "permissions": [
     "manage_employee_records"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The HrRetentionWrite object: { months } required: 0 switches it off, otherwise a whole number from 6 to 240.",
   "response": "200 the same shape as GET /employees/hr-settings | 400 'Choose 0 to switch it off, or between 6 and 240 months.' for any other value",
   "responses": [
    {
     "status": 200,
     "text": "the same shape as GET /employees/hr-settings"
    },
    {
     "status": 400,
     "text": "'Choose 0 to switch it off, or between 6 and 240 months.' for any other value"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "'Choose 0 to switch it off, or between 6 and 240 months.' for any other value"
    }
   ],
   "guard": "The page offers 12, 24, 36, 60 and 84 months; removal is permanent and runs daily. Repeat: safe; the one stored setting is written again and another HR_RETENTION_CHANGED audit entry is recorded"
  },
  {
   "id": "GET /user-management/employees/:userId/compensation",
   "group": "hr-employee-files",
   "method": "GET",
   "path": "/user-management/employees/:userId/compensation",
   "purpose": "A person's compensation history. Amounts are not included; reveal one at a time.",
   "auth": {
    "text": "Session + view_compensation | manage_compensation (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compensation",
     "view_compensation"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "userId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { records:[ CompensationRecord ] }, newest effective date first, no paging. The object carries no amount.",
   "responses": [
    {
     "status": 200,
     "text": "{ records:[ CompensationRecord ] }, newest effective date first, no paging. The object carries no amount."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /user-management/employees/:userId/compensation/:recordId/reveal",
   "group": "hr-employee-files",
   "method": "POST",
   "path": "/user-management/employees/:userId/compensation/:recordId/reveal",
   "purpose": "Reveal the amount of one compensation record. Each reveal is written to the audit log.",
   "auth": {
    "text": "Session + view_compensation | manage_compensation (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compensation",
     "view_compensation"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "userId",
     "type": "integer",
     "basis": "model"
    },
    {
     "name": "recordId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { id:integer, amount:number|null (null when the stored value cannot be read) } | 404 \"Compensation record not found.\"",
   "responses": [
    {
     "status": 200,
     "text": "{ id:integer, amount:number|null (null when the stored value cannot be read) }"
    },
    {
     "status": 404,
     "text": "\"Compensation record not found.\""
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "\"Compensation record not found.\""
    }
   ],
   "guard": "Every call is written to the audit log as EMPLOYEE_COMPENSATION_VIEWED, without the amount. Repeat: safe; it is a read, and each call adds another audit entry"
  },
  {
   "id": "POST /user-management/employees/:userId/compensation",
   "group": "hr-employee-files",
   "method": "POST",
   "path": "/user-management/employees/:userId/compensation",
   "purpose": "Add a compensation record to a file.",
   "auth": {
    "text": "Session + manage_compensation (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compensation"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "userId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The CompensationCreate object: { amount:number (required, greater than 0, at most 10,000,000,000), currency:string (required, a 3-letter code), effectiveDate?:string (YYYY-MM-DD; default today), payPeriod? (annual | monthly | hourly; default annual), bonusPct?:number (0 to 999), reason?:string (up to 255 characters) }",
   "response": "201 { record:CompensationRecord } (no amount) | 400 with the field at fault | 404 \"Employee not found.\"",
   "responses": [
    {
     "status": 201,
     "text": "{ record:CompensationRecord } (no amount)"
    },
    {
     "status": 400,
     "text": "with the field at fault"
    },
    {
     "status": 404,
     "text": "\"Employee not found.\""
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "with the field at fault"
    },
    {
     "status": 404,
     "text": "\"Employee not found.\""
    }
   ],
   "guard": "The amount is never written to the audit log. Repeat: not deduplicated; a second call adds a second record, which you then void."
  },
  {
   "id": "POST /user-management/employees/:userId/compensation/:recordId/void",
   "group": "hr-employee-files",
   "method": "POST",
   "path": "/user-management/employees/:userId/compensation/:recordId/void",
   "purpose": "Void a mistyped record. It is kept, marked, and left out of the payroll hand-off; records are never edited or deleted.",
   "auth": {
    "text": "Session + manage_compensation (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compensation"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "userId",
     "type": "integer",
     "basis": "model"
    },
    {
     "name": "recordId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The CompensationVoid object: { reason:string } required, up to 255 characters.",
   "response": "200 { record:CompensationRecord } with voidedAt, voidedBy and voidReason set | 404 'Compensation record not found.' | 409 'This record was already voided.' | 400 when the reason is missing or the record came from a signed offer",
   "responses": [
    {
     "status": 200,
     "text": "{ record:CompensationRecord } with voidedAt, voidedBy and voidReason set"
    },
    {
     "status": 404,
     "text": "'Compensation record not found.'"
    },
    {
     "status": 409,
     "text": "'This record was already voided.'"
    },
    {
     "status": 400,
     "text": "when the reason is missing or the record came from a signed offer"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "'Compensation record not found.'"
    },
    {
     "status": 409,
     "text": "'This record was already voided.'"
    },
    {
     "status": 400,
     "text": "when the reason is missing or the record came from a signed offer"
    }
   ],
   "guard": "Only a record a person typed in can be voided. A void cannot be undone. Repeat: a record that is already voided answers 409"
  },
  {
   "id": "GET /user-management/employees/:userId/documents",
   "group": "hr-employee-files",
   "method": "GET",
   "path": "/user-management/employees/:userId/documents",
   "purpose": "The documents on a person's file (download one at /documents/:docId/download).",
   "auth": {
    "text": "Session + view_employee_records | manage_employee_records (licensed)",
    "kind": "session",
    "permissions": [
     "manage_employee_records",
     "view_employee_records"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "userId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { documents:[ EmployeeDocument ] (newest first; entries whose file was never uploaded are left out), categories:[{ key:string, label:string }], visibilities:[{ key:string, label:string }], maxBytes:integer (10485760) }. Download: the file as an attachment | 404 \"Document not found.\"",
   "responses": [
    {
     "status": 200,
     "text": "{ documents:[ EmployeeDocument ] (newest first; entries whose file was never uploaded are left out), categories:[{ key:string, label:string }], visibilities:[{ key:string, label:string }], maxBytes:integer (10485760) }. Download: the file as an attachment"
    },
    {
     "status": 404,
     "text": "\"Document not found.\""
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "\"Document not found.\""
    }
   ],
   "guard": "Every download is written to the audit log."
  },
  {
   "id": "POST /user-management/employees/:userId/documents",
   "group": "hr-employee-files",
   "method": "POST",
   "path": "/user-management/employees/:userId/documents",
   "purpose": "Create a document entry; its file is then sent with PUT /documents/:docId/content (change the entry with PUT /documents/:docId, remove it with DELETE).",
   "auth": {
    "text": "Session + manage_employee_records (licensed)",
    "kind": "session",
    "permissions": [
     "manage_employee_records"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "userId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The EmployeeDocumentWrite object: { name:string (required, up to 180 characters), category? (contract | offer | job_change | policy | certificate | review | letter | other; default other), visibility? (hr | employee | manager; default hr), expiresAt?:string (YYYY-MM-DD) }. A person's file holds at most 100 documents. PUT /documents/:docId/content takes the file itself as the raw request body.",
   "response": "201 { document:EmployeeDocument } | 400 \"A document needs a name.\", \"Unknown category.\", \"Unknown visibility.\" or when the file already holds 100 documents. Content: 200 { document:EmployeeDocument } | 400 \"The file is empty.\" | 409 \"This document already has its file.\" | 413 over 10 MB | 415 unless PDF, PNG, JPEG, Word (.docx) or Excel (.xlsx). PUT /documents/:docId: 200 { document:EmployeeDocument }. DELETE: 204.",
   "responses": [
    {
     "status": 201,
     "text": "{ document:EmployeeDocument }"
    },
    {
     "status": 400,
     "text": "\"A document needs a name.\", \"Unknown category.\", \"Unknown visibility.\" or when the file already holds 100 documents. Content: 200 { document:EmployeeDocument }"
    },
    {
     "status": 400,
     "text": "\"The file is empty.\""
    },
    {
     "status": 409,
     "text": "\"This document already has its file.\""
    },
    {
     "status": 413,
     "text": "over 10 MB"
    },
    {
     "status": 415,
     "text": "unless PDF, PNG, JPEG, Word (.docx) or Excel (.xlsx). PUT /documents/:docId: 200 { document:EmployeeDocument }. DELETE: 204."
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "\"A document needs a name.\", \"Unknown category.\", \"Unknown visibility.\" or when the file already holds 100 documents. Content: 200 { document:EmployeeDocument }"
    },
    {
     "status": 400,
     "text": "\"The file is empty.\""
    },
    {
     "status": 409,
     "text": "\"This document already has its file.\""
    },
    {
     "status": 413,
     "text": "over 10 MB"
    },
    {
     "status": 415,
     "text": "unless PDF, PNG, JPEG, Word (.docx) or Excel (.xlsx). PUT /documents/:docId: 200 { document:EmployeeDocument }. DELETE: 204."
    }
   ],
   "guard": "The file type is decided from the file's own bytes, not from its name. Repeat: POST is not deduplicated; a second content upload to the same entry is refused with 409."
  },
  {
   "id": "PUT /user-management/employees/:userId/documents/:docId/content",
   "group": "hr-employee-files",
   "method": "PUT",
   "path": "/user-management/employees/:userId/documents/:docId/content",
   "purpose": "Upload the file of a document entry made with POST /documents.",
   "auth": {
    "text": "Session + manage_employee_records (licensed)",
    "kind": "session",
    "permissions": [
     "manage_employee_records"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "userId",
     "type": "integer",
     "basis": "model"
    },
    {
     "name": "docId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The file itself as the raw request body, at most 10 MB: PDF, PNG, JPEG, Word (.docx) or Excel (.xlsx).",
   "response": "200 { document:EmployeeDocument } | 400 \"The file is empty.\" | 404 \"Document not found.\" | 409 \"This document already has its file. Upload a new document instead.\" | 413 over 10 MB | 415 for any other file type",
   "responses": [
    {
     "status": 200,
     "text": "{ document:EmployeeDocument }"
    },
    {
     "status": 400,
     "text": "\"The file is empty.\""
    },
    {
     "status": 404,
     "text": "\"Document not found.\""
    },
    {
     "status": 409,
     "text": "\"This document already has its file. Upload a new document instead.\""
    },
    {
     "status": 413,
     "text": "over 10 MB"
    },
    {
     "status": 415,
     "text": "for any other file type"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "\"The file is empty.\""
    },
    {
     "status": 404,
     "text": "\"Document not found.\""
    },
    {
     "status": 409,
     "text": "\"This document already has its file. Upload a new document instead.\""
    },
    {
     "status": 413,
     "text": "over 10 MB"
    },
    {
     "status": 415,
     "text": "for any other file type"
    }
   ],
   "guard": "The file type is decided from the file's own bytes. Repeat: a second upload to the same entry answers 409 and stores nothing."
  },
  {
   "id": "PUT /user-management/employees/:userId/documents/:docId",
   "group": "hr-employee-files",
   "method": "PUT",
   "path": "/user-management/employees/:userId/documents/:docId",
   "purpose": "Rename a stored document or change its category, visibility or expiry date.",
   "auth": {
    "text": "Session + manage_employee_records (licensed)",
    "kind": "session",
    "permissions": [
     "manage_employee_records"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "userId",
     "type": "integer",
     "basis": "model"
    },
    {
     "name": "docId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The EmployeeDocumentWrite object, all keys optional: { name?:string, category?, visibility?, expiresAt?:string (YYYY-MM-DD) or null }. Only the keys sent are changed.",
   "response": "200 { document:EmployeeDocument } | 400 \"Nothing to update.\", \"A document needs a name.\", \"Unknown category.\" or \"Unknown visibility.\" | 404 \"Document not found.\" (also for an entry whose file was never uploaded)",
   "responses": [
    {
     "status": 200,
     "text": "{ document:EmployeeDocument }"
    },
    {
     "status": 400,
     "text": "\"Nothing to update.\", \"A document needs a name.\", \"Unknown category.\" or \"Unknown visibility.\""
    },
    {
     "status": 404,
     "text": "\"Document not found.\" (also for an entry whose file was never uploaded)"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "\"Nothing to update.\", \"A document needs a name.\", \"Unknown category.\" or \"Unknown visibility.\""
    },
    {
     "status": 404,
     "text": "\"Document not found.\" (also for an entry whose file was never uploaded)"
    }
   ],
   "guard": "Repeat: safe; the call sets values. The change is written to the audit log."
  },
  {
   "id": "DELETE /user-management/employees/:userId/documents/:docId",
   "group": "hr-employee-files",
   "method": "DELETE",
   "path": "/user-management/employees/:userId/documents/:docId",
   "purpose": "Delete a document from an employee file.",
   "auth": {
    "text": "Session + manage_employee_records (licensed)",
    "kind": "session",
    "permissions": [
     "manage_employee_records"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "userId",
     "type": "integer",
     "basis": "model"
    },
    {
     "name": "docId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "204 with no body | 404 \"Document not found.\"",
   "responses": [
    {
     "status": 204,
     "text": "with no body"
    },
    {
     "status": 404,
     "text": "\"Document not found.\""
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "\"Document not found.\""
    }
   ],
   "guard": "Permanent. Written to the audit log at warning level. Repeat: a second call answers 404."
  },
  {
   "id": "GET /user-management/employees/:userId/documents/:docId/download",
   "group": "hr-employee-files",
   "method": "GET",
   "path": "/user-management/employees/:userId/documents/:docId/download",
   "purpose": "Download one document from an employee file.",
   "auth": {
    "text": "Session + view_employee_records | manage_employee_records (licensed)",
    "kind": "session",
    "permissions": [
     "manage_employee_records",
     "view_employee_records"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "userId",
     "type": "integer",
     "basis": "model"
    },
    {
     "name": "docId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No query parameters.",
   "response": "200 the file as an attachment | 404 \"Document not found.\"",
   "responses": [
    {
     "status": 200,
     "text": "the file as an attachment"
    },
    {
     "status": 404,
     "text": "\"Document not found.\""
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "\"Document not found.\""
    }
   ],
   "guard": "Every download is written to the audit log."
  },
  {
   "id": "GET /user-management/employees/me/documents",
   "group": "hr-employee-files",
   "method": "GET",
   "path": "/user-management/employees/me/documents",
   "purpose": "Your own documents that are shared with you (download at /me/documents/:docId/download).",
   "auth": {
    "text": "Session (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { documents:[ EmployeeDocument ] }: only entries whose visibility is employee or manager. Not paged: every such document in one reply, newest first (a file holds at most 100 documents).",
   "responses": [
    {
     "status": 200,
     "text": "{ documents:[ EmployeeDocument ] }: only entries whose visibility is employee or manager. Not paged: every such document in one reply, newest first (a file holds at most 100 documents)."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /user-management/employees/my-team/:userId/documents",
   "group": "hr-employee-files",
   "method": "GET",
   "path": "/user-management/employees/my-team/:userId/documents",
   "purpose": "A report's documents that are shared with their manager (download at /documents/:docId/download).",
   "auth": {
    "text": "Session (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "userId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { documents:[ EmployeeDocument ] }: only entries whose visibility is manager | 404 \"This person is not in your team.\" Not paged: every such document in one reply, newest first.",
   "responses": [
    {
     "status": 200,
     "text": "{ documents:[ EmployeeDocument ] }: only entries whose visibility is manager"
    },
    {
     "status": 404,
     "text": "\"This person is not in your team.\" Not paged: every such document in one reply, newest first."
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "\"This person is not in your team.\" Not paged: every such document in one reply, newest first."
    }
   ],
   "guard": null
  },
  {
   "id": "GET /user-management/employees/reports/summary",
   "group": "hr-employee-files",
   "method": "GET",
   "path": "/user-management/employees/reports/summary",
   "purpose": "People Reports: combined figures only.",
   "auth": {
    "text": "Session + view_employee_reports (licensed)",
    "kind": "session",
    "permissions": [
     "view_employee_reports"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query from, to (YYYY-MM-DD).",
   "response": "200 { from:string (date), to:string (date), headcount:integer, startHeadcount:integer, joiners:integer, leavers:integer, turnoverPct:number|null, averageTenureYears:number|null, missingStartDate:integer, byDepartment, byLocation, byEmploymentType, leaversByType, leaversByReason: each [{ label:string, count:integer }], tenureBands:[{ label:string, count:integer }] } | 400 \"Choose a valid date range.\"",
   "responses": [
    {
     "status": 200,
     "text": "{ from:string (date), to:string (date), headcount:integer, startHeadcount:integer, joiners:integer, leavers:integer, turnoverPct:number|null, averageTenureYears:number|null, missingStartDate:integer, byDepartment, byLocation, byEmploymentType, leaversByType, leaversByReason: each [{ label:string, count:integer }], tenureBands:[{ label:string, count:integer }] }"
    },
    {
     "status": 400,
     "text": "\"Choose a valid date range.\""
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "\"Choose a valid date range.\""
    }
   ],
   "guard": null
  },
  {
   "id": "GET /user-management/employees/payroll-export",
   "group": "hr-employee-files",
   "method": "GET",
   "path": "/user-management/employees/payroll-export",
   "purpose": "Build the payroll hand-off file for a period (the last one built at /payroll-export/last).",
   "auth": {
    "text": "Session + export_compensation (licensed)",
    "kind": "session",
    "permissions": [
     "export_compensation"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query from, to (YYYY-MM-DD, both required; at most a year apart).",
   "response": "200 { from:string (date), to:string (date), joiners:object[], leavers:object[], payChanges:object[], leave:object[], counts:{ joiners:integer, leavers:integer, payChanges:integer, leave:integer } }. Every row starts with employeeId:string, name:string, email:string. A joiners row adds startDate:string (date), jobTitle:string, department:string, location:string, employmentType:string, level:string, legalEntity:string, costCenter:string and the pay in force on the last day: amount:number|null, currency:string, payPeriod:string, bonusPct:number|null. A leavers row adds lastDay:string (date), departureType:string, reason:string, unusedLeave:{ type:string, days:number }[]. A payChanges row adds effectiveDate:string (date), previousAmount:number|null, previousCurrency:string, amount:number|null, currency:string, payPeriod:string, bonusPct:number|null, reason:string, isFirst:boolean. A leave row adds type:string, paid:boolean|null, startDate:string (date), endDate:string (date), days:number, hours:number|null, startTime:string|null | 400 \"Choose the first and last day of the pay period.\" or \"A hand-off period cannot be longer than a year.\" /payroll-export/last answers { last:object|null }: the stored summary of the last build, null before the first one. Not paged: each of the four lists holds every row of the period; no limit or offset is read.",
   "responses": [
    {
     "status": 200,
     "text": "{ from:string (date), to:string (date), joiners:object[], leavers:object[], payChanges:object[], leave:object[], counts:{ joiners:integer, leavers:integer, payChanges:integer, leave:integer } }. Every row starts with employeeId:string, name:string, email:string. A joiners row adds startDate:string (date), jobTitle:string, department:string, location:string, employmentType:string, level:string, legalEntity:string, costCenter:string and the pay in force on the last day: amount:number|null, currency:string, payPeriod:string, bonusPct:number|null. A leavers row adds lastDay:string (date), departureType:string, reason:string, unusedLeave:{ type:string, days:number }[]. A payChanges row adds effectiveDate:string (date), previousAmount:number|null, previousCurrency:string, amount:number|null, currency:string, payPeriod:string, bonusPct:number|null, reason:string, isFirst:boolean. A leave row adds type:string, paid:boolean|null, startDate:string (date), endDate:string (date), days:number, hours:number|null, startTime:string|null"
    },
    {
     "status": 400,
     "text": "\"Choose the first and last day of the pay period.\" or \"A hand-off period cannot be longer than a year.\" /payroll-export/last answers { last:object|null }: the stored summary of the last build, null before the first one. Not paged: each of the four lists holds every row of the period; no limit or offset is read."
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "\"Choose the first and last day of the pay period.\" or \"A hand-off period cannot be longer than a year.\" /payroll-export/last answers { last:object|null }: the stored summary of the last build, null before the first one. Not paged: each of the four lists holds every row of the period; no limit or offset is read."
    }
   ],
   "guard": "A GET that is recorded: every build is written to the audit log at warning level with the period and the counts, never an amount."
  },
  {
   "id": "GET /user-management/leave/me",
   "group": "hr-time-off",
   "method": "GET",
   "path": "/user-management/leave/me",
   "purpose": "Your balances by type and your requests.",
   "auth": {
    "text": "Session (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { balances:[ LeaveBalance ] (one per active type), requests:[ LeaveRequest ], canApprove:boolean, today:string (date), hoursPerDay:number, dayStart:string (HH:MM) }. requests is your newest 100 by start date; no paging parameters are read.",
   "responses": [
    {
     "status": 200,
     "text": "{ balances:[ LeaveBalance ] (one per active type), requests:[ LeaveRequest ], canApprove:boolean, today:string (date), hoursPerDay:number, dayStart:string (HH:MM) }. requests is your newest 100 by start date; no paging parameters are read."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /user-management/leave/preview",
   "group": "hr-time-off",
   "method": "GET",
   "path": "/user-management/leave/preview",
   "purpose": "How many working days a request would use, before it is sent.",
   "auth": {
    "text": "Session (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query leaveTypeId, startDate, endDate, startHalf, endHalf ('true'), minutes, startTime: the same fields as a request.",
   "response": "200 { startDate: 'YYYY-MM-DD', endDate: 'YYYY-MM-DD', startHalf: boolean, endHalf: boolean, minutes: integer | null, startTime: 'HH:MM' | null, calendar: string, hoursPerDay: number, dayStart: string, holidays: [{ date: 'YYYY-MM-DD', name: string }], days: number, cost: number, dayMinutes: integer | null, endTime: 'HH:MM' | null, clashes: LeaveRequest[], replaceable: boolean }. days is cost rounded to two places; they differ only for part of a day. dayMinutes and endTime are null for whole and half days. clashes are your own live requests the asked time collides with, in the shape of a request. A one-day request also carries sameDay: object[], nearby: object[], window: object and team: { teamSize: integer, awayCount: integer, away: object[] } | null (null when you have no manager on record). clashes, replaceable and the one-day fields are left out when they cannot be read. 400 with the reason, for example \"Choose the first and last day.\", \"The last day cannot be before the first day.\", \"A single request cannot be longer than a year.\" or \"Time off by the hour is for one day. For several days, request whole days.\"",
   "responses": [
    {
     "status": 200,
     "text": "{ startDate: 'YYYY-MM-DD', endDate: 'YYYY-MM-DD', startHalf: boolean, endHalf: boolean, minutes: integer | null, startTime: 'HH:MM' | null, calendar: string, hoursPerDay: number, dayStart: string, holidays: [{ date: 'YYYY-MM-DD', name: string }], days: number, cost: number, dayMinutes: integer | null, endTime: 'HH:MM' | null, clashes: LeaveRequest[], replaceable: boolean }. days is cost rounded to two places; they differ only for part of a day. dayMinutes and endTime are null for whole and half days. clashes are your own live requests the asked time collides with, in the shape of a request. A one-day request also carries sameDay: object[], nearby: object[], window: object and team: { teamSize: integer, awayCount: integer, away: object[] } | null (null when you have no manager on record). clashes, replaceable and the one-day fields are left out when they cannot be read. 400 with the reason, for example \"Choose the first and last day.\", \"The last day cannot be before the first day.\", \"A single request cannot be longer than a year.\" or \"Time off by the hour is for one day. For several days, request whole days.\""
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /user-management/leave/requests",
   "group": "hr-time-off",
   "method": "POST",
   "path": "/user-management/leave/requests",
   "purpose": "Request time off, in whole or half days or by the hour.",
   "auth": {
    "text": "Session (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The LeaveRequestCreate object: { leaveTypeId, startDate, endDate?, startHalf?, endHalf?, note? (up to 500 characters) } for days. For hours on one day add { minutes (whole number, at least 15), startTime 'HH:MM' }. Optional: repeatWeeks (2 to 12, one-day requests only), replaceIds[] (your own bookings by the hour that whole days replace, at most 20).",
   "response": "201 { request:LeaveRequest, requests:[ LeaveRequest ] (every request made, one per week of a repeat), skipped:string[] (the dates of a repeat that were left out because they are not working days), replaced:[ LeaveRequest ] (bookings by the hour the new request replaced) } | 400 with the reason in words (dates, limits of the type, balance) | 409 when it overlaps time off the person already has waiting or approved",
   "responses": [
    {
     "status": 201,
     "text": "{ request:LeaveRequest, requests:[ LeaveRequest ] (every request made, one per week of a repeat), skipped:string[] (the dates of a repeat that were left out because they are not working days), replaced:[ LeaveRequest ] (bookings by the hour the new request replaced) }"
    },
    {
     "status": 400,
     "text": "with the reason in words (dates, limits of the type, balance)"
    },
    {
     "status": 409,
     "text": "when it overlaps time off the person already has waiting or approved"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "with the reason in words (dates, limits of the type, balance)"
    },
    {
     "status": 409,
     "text": "when it overlaps time off the person already has waiting or approved"
    }
   ],
   "guard": "Hours must start and end on the same day. A day holds a whole or half day or hours, never both. A type with approval switched off is approved at once. Repeat: a second identical call answers 409, because it clashes with the request the first one made (pending or approved)"
  },
  {
   "id": "POST /user-management/leave/requests/:id/cancel",
   "group": "hr-time-off",
   "method": "POST",
   "path": "/user-management/leave/requests/:id/cancel",
   "purpose": "Cancel a request.",
   "auth": {
    "text": "Session (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { request:LeaveRequest } with status cancelled and no typeName",
   "responses": [
    {
     "status": 200,
     "text": "{ request:LeaveRequest } with status cancelled and no typeName"
    }
   ],
   "errors": [],
   "guard": "An approved request that is cancelled returns its days to the balance. When someone other than the person cancels it, the person is notified. Repeat: a second call answers 409 \"This request was already cancelled.\" and returns no days twice"
  },
  {
   "id": "GET /user-management/leave/approvals",
   "group": "hr-time-off",
   "method": "GET",
   "path": "/user-management/leave/approvals",
   "purpose": "The requests waiting for your decision.",
   "auth": {
    "text": "Session (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { approvals:[ LeaveRequest ] }, each with personName:string and available:number|null (what the person would have left of that type; null when the type tracks no balance); earliest start date first, at most 200; no paging parameters are read.",
   "responses": [
    {
     "status": 200,
     "text": "{ approvals:[ LeaveRequest ] }, each with personName:string and available:number|null (what the person would have left of that type; null when the type tracks no balance); earliest start date first, at most 200; no paging parameters are read."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /user-management/leave/requests/:id/decide",
   "group": "hr-time-off",
   "method": "POST",
   "path": "/user-management/leave/requests/:id/decide",
   "purpose": "Approve or decline one request. Nobody can decide their own.",
   "auth": {
    "text": "Session (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The LeaveDecision object: { decision: 'approve' | 'decline', note? } — note is required for a decline (up to 500 characters) and is shown to the person.",
   "response": "200 { request:LeaveRequest } | 400 bad decision or missing reason | 404 when the request does not exist or is not yours to decide | 409 when it was already decided",
   "responses": [
    {
     "status": 200,
     "text": "{ request:LeaveRequest }"
    },
    {
     "status": 400,
     "text": "bad decision or missing reason"
    },
    {
     "status": 404,
     "text": "when the request does not exist or is not yours to decide"
    },
    {
     "status": 409,
     "text": "when it was already decided"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "bad decision or missing reason"
    },
    {
     "status": 404,
     "text": "when the request does not exist or is not yours to decide"
    },
    {
     "status": 409,
     "text": "when it was already decided"
    }
   ],
   "guard": "The decider must be above the requester in the reporting line, an administrator, or hold manage_leave. Repeat: a request that is no longer pending answers 409"
  },
  {
   "id": "POST /user-management/leave/approvals/approve",
   "group": "hr-time-off",
   "method": "POST",
   "path": "/user-management/leave/approvals/approve",
   "purpose": "Approve several requests in one call. Each is decided exactly as a single decision would be.",
   "auth": {
    "text": "Session (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The LeaveApproveMany object: { ids: number[] } — 1 to 50 request ids.",
   "response": "200 { approved:[ LeaveRequest ], failed:[{ id:integer, error:string }] } | 400 for an empty list or more than 50",
   "responses": [
    {
     "status": 200,
     "text": "{ approved:[ LeaveRequest ], failed:[{ id:integer, error:string }] }"
    },
    {
     "status": 400,
     "text": "for an empty list or more than 50"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "for an empty list or more than 50"
    }
   ],
   "guard": "One that cannot be approved is reported in failed; the others still go through. Each writes its own audit entry and notifies the person. Repeat: a request that is no longer pending is not decided again: it is listed in failed with \"This request was already <its status>.\""
  },
  {
   "id": "POST /user-management/leave/approvals/decline",
   "group": "hr-time-off",
   "method": "POST",
   "path": "/user-management/leave/approvals/decline",
   "purpose": "Decline several requests in one call, each with its own reason. Each is decided exactly as a single decline would be.",
   "auth": {
    "text": "Session (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The LeaveDeclineMany object: { items:[ LeaveDeclineItem ] } with each item { id:integer, note:string } — 1 to 50 items; every note is required (up to 500 characters).",
   "response": "200 { declined:[ LeaveRequest ], failed:[{ id:integer, error:string }] } | 400 for an empty list, more than 50, or any missing reason (nothing is declined in that case)",
   "responses": [
    {
     "status": 200,
     "text": "{ declined:[ LeaveRequest ], failed:[{ id:integer, error:string }] }"
    },
    {
     "status": 400,
     "text": "for an empty list, more than 50, or any missing reason (nothing is declined in that case)"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "for an empty list, more than 50, or any missing reason (nothing is declined in that case)"
    }
   ],
   "guard": "The page copies one reason to every item when the approver gives a single reason for all. Repeat: a request that is no longer pending is not decided again: it is listed in failed with \"This request was already <its status>.\""
  },
  {
   "id": "GET /user-management/leave/calendar",
   "group": "hr-time-off",
   "method": "GET",
   "path": "/user-management/leave/calendar",
   "purpose": "Who is out.",
   "auth": {
    "text": "Session (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query from, to (YYYY-MM-DD, both required; a year or less).",
   "response": "200 { out:[{ id:integer, userId:integer, personName:string, startDate:string (date), endDate:string (date), startTime:string|null, minutes:integer|null, status:'pending'|'approved', typeName:string|null, mine:boolean, workingDays?:string[] }], today:string (date), workDays?:integer[], holidays?:[{ date:string (date), name:string }] }. out is the people you may see (yourself, the people who share your manager, and everyone below you), earliest start first. typeName is null, and a request still waiting is left out, for a person who is not you or below you, unless you hold manage_leave. workingDays, workDays and holidays are left out when the working calendar cannot be read | 400 \"Choose a date range.\" or \"Choose a range of a year or less.\" Not paged: every entry that overlaps the range comes back in one reply.",
   "responses": [
    {
     "status": 200,
     "text": "{ out:[{ id:integer, userId:integer, personName:string, startDate:string (date), endDate:string (date), startTime:string|null, minutes:integer|null, status:'pending'|'approved', typeName:string|null, mine:boolean, workingDays?:string[] }], today:string (date), workDays?:integer[], holidays?:[{ date:string (date), name:string }] }. out is the people you may see (yourself, the people who share your manager, and everyone below you), earliest start first. typeName is null, and a request still waiting is left out, for a person who is not you or below you, unless you hold manage_leave. workingDays, workDays and holidays are left out when the working calendar cannot be read"
    },
    {
     "status": 400,
     "text": "\"Choose a date range.\" or \"Choose a range of a year or less.\" Not paged: every entry that overlaps the range comes back in one reply."
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "\"Choose a date range.\" or \"Choose a range of a year or less.\" Not paged: every entry that overlaps the range comes back in one reply."
    }
   ],
   "guard": null
  },
  {
   "id": "GET /user-management/leave/setup",
   "group": "hr-time-off",
   "method": "GET",
   "path": "/user-management/leave/setup",
   "purpose": "The types of time off and working calendars.",
   "auth": {
    "text": "Session + manage_leave (licensed)",
    "kind": "session",
    "permissions": [
     "manage_leave"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { types:[ LeaveType ] (by sort order, then name), calendars:[ WorkingCalendar ] (the default first, then by name) }. Not paged: every type and every calendar.",
   "responses": [
    {
     "status": 200,
     "text": "{ types:[ LeaveType ] (by sort order, then name), calendars:[ WorkingCalendar ] (the default first, then by name) }. Not paged: every type and every calendar."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /user-management/leave/types",
   "group": "hr-time-off",
   "method": "POST",
   "path": "/user-management/leave/types",
   "purpose": "Add a type of time off (change with PUT /types/:id, retire with DELETE /types/:id).",
   "auth": {
    "text": "Session + manage_leave (licensed)",
    "kind": "session",
    "permissions": [
     "manage_leave"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The LeaveTypeWrite object: { name:string (required, up to 60 characters), tracksBalance?:boolean (default true), annualDays?:number (0 to 366), accrual? (upfront | monthly; default upfront), carryOverMaxDays?:number (0 to 366), paid?:boolean (default true), requiresApproval?:boolean (default true), allowHours?:boolean (default true), allowNegative?:boolean, minPartMinutes?:integer, maxPartMinutes?:integer (whole minutes, 15 to 1440), isActive?:boolean (default true), sortOrder?:integer, accrueFrom?:string (YYYY-MM-DD, create only) }",
   "response": "POST 201, PUT 200: { types:[ LeaveType ] }, the whole list. DELETE 200 { types:[ LeaveType ], deleted:boolean }: deleted is false when the type has been used, in which case it is retired and kept. 400 \"A type of time off needs a name.\" or \"There is already a type of time off called ...\" | 404 \"Type of time off not found.\"",
   "responses": [
    {
     "status": null,
     "text": "POST 201, PUT 200: { types:[ LeaveType ] }, the whole list. DELETE 200 { types:[ LeaveType ], deleted:boolean }: deleted is false when the type has been used, in which case it is retired and kept. 400 \"A type of time off needs a name.\" or \"There is already a type of time off called ...\""
    },
    {
     "status": 404,
     "text": "\"Type of time off not found.\""
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "\"Type of time off not found.\""
    }
   ],
   "guard": "Repeat: a second POST with the same name is refused with 400."
  },
  {
   "id": "POST /user-management/leave/calendars",
   "group": "hr-time-off",
   "method": "POST",
   "path": "/user-management/leave/calendars",
   "purpose": "Add a working calendar (change with PUT /calendars/:id, remove with DELETE /calendars/:id).",
   "auth": {
    "text": "Session + manage_leave (licensed)",
    "kind": "session",
    "permissions": [
     "manage_leave"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The HolidayCalendarWrite object: { name:string (required), workDays?:integer[] (0 for Sunday to 6 for Saturday; default 1 to 5), holidays?:[{ date:string (YYYY-MM-DD), name:string }] (at most 400, no date twice), hoursPerDay?:number (1 to 24), dayStart?:string ('HH:MM'), isDefault?:boolean, location?:string }",
   "response": "POST, PUT and DELETE all answer 200 { calendars:[ WorkingCalendar ] }, the whole list | 400 with the reason, including \"Make another calendar the default before deleting this one.\" | 404 \"Calendar not found.\"",
   "responses": [
    {
     "status": null,
     "text": "POST, PUT and DELETE all answer 200 { calendars:[ WorkingCalendar ] }, the whole list"
    },
    {
     "status": 400,
     "text": "with the reason, including \"Make another calendar the default before deleting this one.\""
    },
    {
     "status": 404,
     "text": "\"Calendar not found.\""
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "with the reason, including \"Make another calendar the default before deleting this one.\""
    },
    {
     "status": 404,
     "text": "\"Calendar not found.\""
    }
   ],
   "guard": "Repeat: not deduplicated: every call creates another calendar, also with the same name"
  },
  {
   "id": "GET /user-management/leave/people/:userId",
   "group": "hr-time-off",
   "method": "GET",
   "path": "/user-management/leave/people/:userId",
   "purpose": "One person's balances, requests and balance history.",
   "auth": {
    "text": "Session + manage_leave | view_employee_records | manage_employee_records (licensed)",
    "kind": "session",
    "permissions": [
     "manage_employee_records",
     "manage_leave",
     "view_employee_records"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "userId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { balances:[ LeaveBalance ], requests:[ LeaveRequest ], hoursPerDay:number, dayStart:string (HH:MM) }; balances include retired types, requests is the newest 100 by start date.",
   "responses": [
    {
     "status": 200,
     "text": "{ balances:[ LeaveBalance ], requests:[ LeaveRequest ], hoursPerDay:number, dayStart:string (HH:MM) }; balances include retired types, requests is the newest 100 by start date."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /user-management/leave/people/:userId/adjust",
   "group": "hr-time-off",
   "method": "POST",
   "path": "/user-management/leave/people/:userId/adjust",
   "purpose": "Adjust a person's balance, with a reason.",
   "auth": {
    "text": "Session + manage_leave (licensed)",
    "kind": "session",
    "permissions": [
     "manage_leave"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "userId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The LeaveBalanceAdjust object: { leaveTypeId:integer, days:number (not 0, between -366 and 366; negative removes), note:string }, all required.",
   "response": "201 { balances:[ LeaveBalance ] } (retired types included) | 400 for a type with no allowance, a bad number of days or a missing reason",
   "responses": [
    {
     "status": 201,
     "text": "{ balances:[ LeaveBalance ] } (retired types included)"
    },
    {
     "status": 400,
     "text": "for a type with no allowance, a bad number of days or a missing reason"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "for a type with no allowance, a bad number of days or a missing reason"
    }
   ],
   "guard": "Added to the balance history with the adjuster's email and the reason, and written to the audit log. Repeat: not deduplicated: every call adds another entry to the balance history"
  },
  {
   "id": "GET /user-management/goals/mine",
   "group": "hr-goals-reviews-surveys",
   "method": "GET",
   "path": "/user-management/goals/mine",
   "purpose": "Your own goals (another person's at /goals/people/:userId, if you are above them in the reporting line or hold an employee-records permission).",
   "auth": {
    "text": "Session (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { person:{ id:integer, name:string }, canEdit:boolean, goals:[ Goal ] }; everything, no paging (a person has at most 50 goals) | 404 \"Not found.\" for a person whose goals you may not read.",
   "responses": [
    {
     "status": 200,
     "text": "{ person:{ id:integer, name:string }, canEdit:boolean, goals:[ Goal ] }; everything, no paging (a person has at most 50 goals)"
    },
    {
     "status": 404,
     "text": "\"Not found.\" for a person whose goals you may not read."
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "\"Not found.\" for a person whose goals you may not read."
    }
   ],
   "guard": null
  },
  {
   "id": "POST /user-management/goals",
   "group": "hr-goals-reviews-surveys",
   "method": "POST",
   "path": "/user-management/goals",
   "purpose": "Add a goal (change with PUT /goals/:id, remove with DELETE /goals/:id).",
   "auth": {
    "text": "Session (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The GoalWrite object: { title:string (required), userId?:integer (default: yourself), description?:string (up to 2,000 characters), period?:string (up to 40), dueDate?:string (YYYY-MM-DD), status? (on_track | at_risk | done | dropped), progress?:integer (0 to 100), weight?:integer|null (0 to 100) }. PUT takes any subset. A person can have at most 50 goals.",
   "response": "201 { goal:Goal } | PUT 200 { goal:Goal } | DELETE 204. 400 \"A goal needs a title.\", \"Nothing to update.\" or when the person already has 50 goals | 404 \"Not found.\" when the goal does not exist or is not yours to change.",
   "responses": [
    {
     "status": 201,
     "text": "{ goal:Goal } | PUT 200 { goal:Goal } | DELETE 204. 400 \"A goal needs a title.\", \"Nothing to update.\" or when the person already has 50 goals"
    },
    {
     "status": 404,
     "text": "\"Not found.\" when the goal does not exist or is not yours to change."
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "\"Not found.\" when the goal does not exist or is not yours to change."
    }
   ],
   "guard": "Repeat: POST is not deduplicated."
  },
  {
   "id": "GET /user-management/goals/templates",
   "group": "hr-goals-reviews-surveys",
   "method": "GET",
   "path": "/user-management/goals/templates",
   "purpose": "The example goals. Adding, changing or deleting one (POST, PUT /templates/:id, DELETE /templates/:id) is refused with 403 without manage_employee_records.",
   "auth": {
    "text": "Session (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Write: { title (required), description? (up to 2,000 characters), isActive? (default true), sortOrder? }",
   "response": "200 { canManage:boolean, templates:[ GoalTemplate ] } (only the active ones unless you may manage them). POST 201, PUT 200: { template:GoalTemplate }. DELETE 204. 400 \"An example needs a title.\" or when there are already 30 | 403 \"Only HR can do this.\" | 404 \"Example not found.\" The GET is not paged: every example in one reply (at most 30 can exist).",
   "responses": [
    {
     "status": 200,
     "text": "{ canManage:boolean, templates:[ GoalTemplate ] } (only the active ones unless you may manage them). POST 201, PUT 200: { template:GoalTemplate }. DELETE 204. 400 \"An example needs a title.\" or when there are already 30"
    },
    {
     "status": 403,
     "text": "\"Only HR can do this.\""
    },
    {
     "status": 404,
     "text": "\"Example not found.\" The GET is not paged: every example in one reply (at most 30 can exist)."
    }
   ],
   "errors": [
    {
     "status": 403,
     "text": "\"Only HR can do this.\""
    },
    {
     "status": 404,
     "text": "\"Example not found.\" The GET is not paged: every example in one reply (at most 30 can exist)."
    }
   ],
   "guard": null
  },
  {
   "id": "GET /user-management/reviews/mine",
   "group": "hr-goals-reviews-surveys",
   "method": "GET",
   "path": "/user-management/reviews/mine",
   "purpose": "Your reviews, the reviews you have to write, and feedback asked of you.",
   "auth": {
    "text": "Session (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { mine:object[], toReview:object[], toGive:object[] }; everything in one answer, no paging. Each entry of mine: { id:integer, cycle:ReviewCycle, state:'with_person'|'with_reviewer'|'ready_to_share'|'shared', reviewer:string|null, selfAnswers:object|null, selfSubmittedAt:string (date-time)|null, shared:boolean, sharedAt:string (date-time)|null, managerAnswers:object|null and rating:integer|null (both null until the review is shared), selfGoalRatings, managerGoalRatings } plus colleague-feedback and goal fields that are not typed here. Each entry of toReview: { id:integer, cycle:ReviewCycle, state, userId:integer, person:string, selfSubmitted:boolean, selfAnswers:object|null (null until the person submits), managerAnswers:object|null, rating:integer|null, managerSubmittedAt, sharedAt, calibration, selfGoalRatings, managerGoalRatings }. Answers are { <question key>: string }. toGive is not typed here.",
   "responses": [
    {
     "status": 200,
     "text": "{ mine:object[], toReview:object[], toGive:object[] }; everything in one answer, no paging. Each entry of mine: { id:integer, cycle:ReviewCycle, state:'with_person'|'with_reviewer'|'ready_to_share'|'shared', reviewer:string|null, selfAnswers:object|null, selfSubmittedAt:string (date-time)|null, shared:boolean, sharedAt:string (date-time)|null, managerAnswers:object|null and rating:integer|null (both null until the review is shared), selfGoalRatings, managerGoalRatings } plus colleague-feedback and goal fields that are not typed here. Each entry of toReview: { id:integer, cycle:ReviewCycle, state, userId:integer, person:string, selfSubmitted:boolean, selfAnswers:object|null (null until the person submits), managerAnswers:object|null, rating:integer|null, managerSubmittedAt, sharedAt, calibration, selfGoalRatings, managerGoalRatings }. Answers are { <question key>: string }. toGive is not typed here."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "PUT /user-management/reviews/:reviewId/self",
   "group": "hr-goals-reviews-surveys",
   "method": "PUT",
   "path": "/user-management/reviews/:reviewId/self",
   "purpose": "Save or submit your self review (the reviewer's half at PUT /:reviewId/manager).",
   "auth": {
    "text": "Session (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "reviewId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "{ answers, submit?, goalRatings? }. The reviewer's half also takes rating (a whole number 1 to 5, or null).",
   "response": "200 { ok:true, submitted:boolean } | 400 \"Answer every question before submitting.\", \"Choose a rating before submitting.\" | 404 \"Review not found.\" | 409 when your half is already submitted, the review is already shared, it is with HR for calibration, or the cycle is closed.",
   "responses": [
    {
     "status": 200,
     "text": "{ ok:true, submitted:boolean }"
    },
    {
     "status": 400,
     "text": "\"Answer every question before submitting.\", \"Choose a rating before submitting.\""
    },
    {
     "status": 404,
     "text": "\"Review not found.\""
    },
    {
     "status": 409,
     "text": "when your half is already submitted, the review is already shared, it is with HR for calibration, or the cycle is closed."
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "\"Answer every question before submitting.\", \"Choose a rating before submitting.\""
    },
    {
     "status": 404,
     "text": "\"Review not found.\""
    },
    {
     "status": 409,
     "text": "when your half is already submitted, the review is already shared, it is with HR for calibration, or the cycle is closed."
    }
   ],
   "guard": "Without submit the answers are saved as a draft and can be sent again. Repeat: after submit, a second call is refused with 409."
  },
  {
   "id": "POST /user-management/reviews/:reviewId/share",
   "group": "hr-goals-reviews-surveys",
   "method": "POST",
   "path": "/user-management/reviews/:reviewId/share",
   "purpose": "The reviewer shares the review with its person.",
   "auth": {
    "text": "Session (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "reviewId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { ok: true } | 400 \"Submit your review before sharing it.\" | 404 \"Review not found.\" | 409 when HR has not released the ratings for the cycle, or the cycle is closed.",
   "responses": [
    {
     "status": 200,
     "text": "{ ok: true }"
    },
    {
     "status": 400,
     "text": "\"Submit your review before sharing it.\""
    },
    {
     "status": 404,
     "text": "\"Review not found.\""
    },
    {
     "status": 409,
     "text": "when HR has not released the ratings for the cycle, or the cycle is closed."
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "\"Submit your review before sharing it.\""
    },
    {
     "status": 404,
     "text": "\"Review not found.\""
    },
    {
     "status": 409,
     "text": "when HR has not released the ratings for the cycle, or the cycle is closed."
    }
   ],
   "guard": "Repeat: safe; sharing a review that is already shared changes nothing and notifies nobody again."
  },
  {
   "id": "POST /user-management/reviews/:reviewId/feedback",
   "group": "hr-goals-reviews-surveys",
   "method": "POST",
   "path": "/user-management/reviews/:reviewId/feedback",
   "purpose": "The reviewer asks colleagues for feedback (read with GET; a colleague writes at PUT /reviews/feedback/:feedbackId or declines at POST /feedback/:feedbackId/decline; the reviewer withdraws a request with DELETE).",
   "auth": {
    "text": "Session (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "reviewId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The FeedbackRequest object: { userIds[] }: active colleagues, not the person reviewed and not the reviewer; at most 8 per review. A colleague's answer: PUT /reviews/feedback/:feedbackId { answers, submit? }.",
   "response": "200 { added:integer } | 400 \"Choose at least one colleague.\" and the other rules above | 404 \"Review not found.\" | 409 when the cycle is closed, does not use colleague feedback, or the review is already shared. Answer: 200 { ok:true, submitted:boolean } | 409 when already submitted or declined. Decline: 200 { ok: true }. Withdraw (DELETE): 204 | 409 \"Feedback that has been submitted cannot be removed.\"",
   "responses": [
    {
     "status": 200,
     "text": "{ added:integer }"
    },
    {
     "status": 400,
     "text": "\"Choose at least one colleague.\" and the other rules above"
    },
    {
     "status": 404,
     "text": "\"Review not found.\""
    },
    {
     "status": 409,
     "text": "when the cycle is closed, does not use colleague feedback, or the review is already shared. Answer: 200 { ok:true, submitted:boolean }"
    },
    {
     "status": 409,
     "text": "when already submitted or declined. Decline: 200 { ok: true }. Withdraw (DELETE): 204"
    },
    {
     "status": 409,
     "text": "\"Feedback that has been submitted cannot be removed.\""
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "\"Choose at least one colleague.\" and the other rules above"
    },
    {
     "status": 404,
     "text": "\"Review not found.\""
    },
    {
     "status": 409,
     "text": "when the cycle is closed, does not use colleague feedback, or the review is already shared. Answer: 200 { ok:true, submitted:boolean }"
    },
    {
     "status": 409,
     "text": "when already submitted or declined. Decline: 200 { ok: true }. Withdraw (DELETE): 204"
    },
    {
     "status": 409,
     "text": "\"Feedback that has been submitted cannot be removed.\""
    }
   ],
   "guard": "Repeat: asking the same colleague again adds nothing; added counts only new requests."
  },
  {
   "id": "GET /user-management/reviews/cycles",
   "group": "hr-goals-reviews-surveys",
   "method": "GET",
   "path": "/user-management/reviews/cycles",
   "purpose": "List review cycles.",
   "auth": {
    "text": "Session + manage_employee_reviews (licensed)",
    "kind": "session",
    "permissions": [
     "manage_employee_reviews"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { cycles:[ ReviewCycle ], each with progress:{ people:integer, selfDone:integer, managerDone:integer, shared:integer }, defaultQuestions:object[] (the starting questions, in the shape of ReviewCycle.questions) }, newest first; no paging parameters are read.",
   "responses": [
    {
     "status": 200,
     "text": "{ cycles:[ ReviewCycle ], each with progress:{ people:integer, selfDone:integer, managerDone:integer, shared:integer }, defaultQuestions:object[] (the starting questions, in the shape of ReviewCycle.questions) }, newest first; no paging parameters are read."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /user-management/reviews/cycles",
   "group": "hr-goals-reviews-surveys",
   "method": "POST",
   "path": "/user-management/reviews/cycles",
   "purpose": "Create a review cycle (change with PUT /cycles/:id; DELETE /cycles/:id removes a draft only).",
   "auth": {
    "text": "Session + manage_employee_reviews (licensed)",
    "kind": "session",
    "permissions": [
     "manage_employee_reviews"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The ReviewCycleWrite object: { name:string (required), periodLabel?:string (up to 60 characters), selfDueDate?, managerDueDate? (YYYY-MM-DD; managerDueDate not before selfDueDate), department?:string, questions?:[{ label:string, key?:string, help?:string }] (1 to 12; the default questions when left out), includeRating?:boolean (default true), calibration?:boolean, goalRatings?:boolean, peerFeedback? (off | reviewer | person; default off) }",
   "response": "POST 201, PUT 200: { cycle:ReviewCycle }. DELETE 204. 400 \"A review cycle needs a name.\", \"A review needs at least one question.\" | 404 \"Review cycle not found.\" | 409 \"A closed cycle cannot be changed.\", \"Only a draft can be deleted. Close a launched cycle instead.\"",
   "responses": [
    {
     "status": null,
     "text": "POST 201, PUT 200: { cycle:ReviewCycle }. DELETE 204. 400 \"A review cycle needs a name.\", \"A review needs at least one question.\""
    },
    {
     "status": 404,
     "text": "\"Review cycle not found.\""
    },
    {
     "status": 409,
     "text": "\"A closed cycle cannot be changed.\", \"Only a draft can be deleted. Close a launched cycle instead.\""
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "\"Review cycle not found.\""
    },
    {
     "status": 409,
     "text": "\"A closed cycle cannot be changed.\", \"Only a draft can be deleted. Close a launched cycle instead.\""
    }
   ],
   "guard": "Repeat: POST is not deduplicated."
  },
  {
   "id": "POST /user-management/reviews/cycles/:id/launch",
   "group": "hr-goals-reviews-surveys",
   "method": "POST",
   "path": "/user-management/reviews/cycles/:id/launch",
   "purpose": "Launch a cycle (close it at /cycles/:id/close).",
   "auth": {
    "text": "Session + manage_employee_reviews (licensed)",
    "kind": "session",
    "permissions": [
     "manage_employee_reviews"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body, for the launch and for the close.",
   "response": "200 { cycle:ReviewCycle, added:integer (reviews created by this call), total:integer, withoutReviewer:integer } | 400 when nobody active is in scope | 404 | 409 \"A closed cycle cannot be launched again.\" Close: 200 { cycle:ReviewCycle } | 409 \"Only an open cycle can be closed.\"",
   "responses": [
    {
     "status": 200,
     "text": "{ cycle:ReviewCycle, added:integer (reviews created by this call), total:integer, withoutReviewer:integer }"
    },
    {
     "status": 400,
     "text": "when nobody active is in scope"
    },
    {
     "status": 404,
     "text": ""
    },
    {
     "status": 409,
     "text": "\"A closed cycle cannot be launched again.\" Close: 200 { cycle:ReviewCycle }"
    },
    {
     "status": 409,
     "text": "\"Only an open cycle can be closed.\""
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "when nobody active is in scope"
    },
    {
     "status": 404,
     "text": ""
    },
    {
     "status": 409,
     "text": "\"A closed cycle cannot be launched again.\" Close: 200 { cycle:ReviewCycle }"
    },
    {
     "status": 409,
     "text": "\"Only an open cycle can be closed.\""
    }
   ],
   "guard": "Repeat: closing twice is refused with 409."
  },
  {
   "id": "GET /user-management/reviews/cycles/:id/progress",
   "group": "hr-goals-reviews-surveys",
   "method": "GET",
   "path": "/user-management/reviews/cycles/:id/progress",
   "purpose": "Who is where in a cycle.",
   "auth": {
    "text": "Session + manage_employee_reviews (licensed)",
    "kind": "session",
    "permissions": [
     "manage_employee_reviews"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { cycle: ReviewCycle, ratings: [{ rating: 1..5, count: integer }], reviews: [{ id: integer, userId: integer, person: string, reviewerId: integer | null, reviewer: string | null, state: 'with_person' | 'with_reviewer' | 'ready_to_share' | 'shared', selfSubmittedAt: timestamp | null, managerSubmittedAt: timestamp | null, sharedAt: timestamp | null }] }. reviews is sorted by person name. ratings counts reviews the reviewer has submitted and leaves out your own review. 404 \"Review cycle not found.\" Not paged: one entry per review in the cycle.",
   "responses": [
    {
     "status": 200,
     "text": "{ cycle: ReviewCycle, ratings: [{ rating: 1..5, count: integer }], reviews: [{ id: integer, userId: integer, person: string, reviewerId: integer | null, reviewer: string | null, state: 'with_person' | 'with_reviewer' | 'ready_to_share' | 'shared', selfSubmittedAt: timestamp | null, managerSubmittedAt: timestamp | null, sharedAt: timestamp | null }] }. reviews is sorted by person name. ratings counts reviews the reviewer has submitted and leaves out your own review. 404 \"Review cycle not found.\" Not paged: one entry per review in the cycle."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /user-management/reviews/cycles/:id/calibration",
   "group": "hr-goals-reviews-surveys",
   "method": "GET",
   "path": "/user-management/reviews/cycles/:id/calibration",
   "purpose": "The calibration view (change one rating with PUT /reviews/:reviewId/calibration; release with POST /cycles/:id/release-ratings).",
   "auth": {
    "text": "Session + manage_employee_reviews (licensed)",
    "kind": "session",
    "permissions": [
     "manage_employee_reviews"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "PUT /reviews/:reviewId/calibration: { rating (a whole number 1 to 5), note (required: the reviewer reads it) }",
   "response": "GET 200 { cycle:{ id:integer, name:string, status:string, ratingsReleasedAt:string (date-time)|null }, waiting:integer (reviews the reviewer has not submitted), distribution, originalDistribution, average, byReviewer:[{ reviewerId:integer, reviewer:string, reviews:integer, average, originalAverage }] (highest average first), fit, reviews:[{ id:integer, userId:integer, person:string, department:string|null, reviewer:string|null, submitted:boolean, shared:boolean, rating:integer|null (null until the reviewer submits), original:integer|null, changed:boolean, note:string|null, calibratedByEmail:string|null }] }. reviews is sorted highest rating first, then by name, leaves out your own review and is not paged; distribution, originalDistribution, average and fit are not typed here. Each read is written to the audit log. PUT 200 { ok: true } | 400 \"That is already the rating.\" or a missing reason | 403 \"You cannot calibrate your own review.\" | 409 when the reviewer has not submitted, the review is already shared, the cycle is closed, or it does not use calibration. Release: 200 { ok:true, already:boolean, waiting:integer }.",
   "responses": [
    {
     "status": null,
     "text": "GET 200 { cycle:{ id:integer, name:string, status:string, ratingsReleasedAt:string (date-time)|null }, waiting:integer (reviews the reviewer has not submitted), distribution, originalDistribution, average, byReviewer:[{ reviewerId:integer, reviewer:string, reviews:integer, average, originalAverage }] (highest average first), fit, reviews:[{ id:integer, userId:integer, person:string, department:string|null, reviewer:string|null, submitted:boolean, shared:boolean, rating:integer|null (null until the reviewer submits), original:integer|null, changed:boolean, note:string|null, calibratedByEmail:string|null }] }. reviews is sorted highest rating first, then by name, leaves out your own review and is not paged; distribution, originalDistribution, average and fit are not typed here. Each read is written to the audit log. PUT 200 { ok: true }"
    },
    {
     "status": 400,
     "text": "\"That is already the rating.\" or a missing reason"
    },
    {
     "status": 403,
     "text": "\"You cannot calibrate your own review.\""
    },
    {
     "status": 409,
     "text": "when the reviewer has not submitted, the review is already shared, the cycle is closed, or it does not use calibration. Release: 200 { ok:true, already:boolean, waiting:integer }."
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "\"That is already the rating.\" or a missing reason"
    },
    {
     "status": 403,
     "text": "\"You cannot calibrate your own review.\""
    },
    {
     "status": 409,
     "text": "when the reviewer has not submitted, the review is already shared, the cycle is closed, or it does not use calibration. Release: 200 { ok:true, already:boolean, waiting:integer }."
    }
   ],
   "guard": "Repeat: releasing twice is safe; the second answer carries already: true and notifies nobody."
  },
  {
   "id": "PUT /user-management/reviews/:reviewId/reviewer",
   "group": "hr-goals-reviews-surveys",
   "method": "PUT",
   "path": "/user-management/reviews/:reviewId/reviewer",
   "purpose": "Assign or reassign the reviewer (reopen a submitted half at POST /:reviewId/reopen).",
   "auth": {
    "text": "Session + manage_employee_reviews (licensed)",
    "kind": "session",
    "permissions": [
     "manage_employee_reviews"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "reviewId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The ReviewerReassign object: { reviewerId }. Reopen: { part: 'self' | 'manager' }",
   "response": "200 { ok: true } | 400 \"Choose a reviewer.\", \"A person cannot review themselves.\", \"That reviewer is not an active person.\", \"part must be 'self' or 'manager'.\" | 404 \"Review not found.\" | 409 \"This review has already been shared.\"",
   "responses": [
    {
     "status": 200,
     "text": "{ ok: true }"
    },
    {
     "status": 400,
     "text": "\"Choose a reviewer.\", \"A person cannot review themselves.\", \"That reviewer is not an active person.\", \"part must be 'self' or 'manager'.\""
    },
    {
     "status": 404,
     "text": "\"Review not found.\""
    },
    {
     "status": 409,
     "text": "\"This review has already been shared.\""
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "\"Choose a reviewer.\", \"A person cannot review themselves.\", \"That reviewer is not an active person.\", \"part must be 'self' or 'manager'.\""
    },
    {
     "status": 404,
     "text": "\"Review not found.\""
    },
    {
     "status": 409,
     "text": "\"This review has already been shared.\""
    }
   ],
   "guard": "Repeat: not a no-op: naming the same reviewer again answers 200, clears anything that reviewer has written so far, and notifies them again"
  },
  {
   "id": "GET /user-management/reviews/:reviewId",
   "group": "hr-goals-reviews-surveys",
   "method": "GET",
   "path": "/user-management/reviews/:reviewId",
   "purpose": "Read a review in full as HR. Each read is written to the audit log.",
   "auth": {
    "text": "Session + manage_employee_reviews (licensed)",
    "kind": "session",
    "permissions": [
     "manage_employee_reviews"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "reviewId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { review } | 403 \"Your own review is not shared with you yet.\" | 404 \"Review not found.\"",
   "responses": [
    {
     "status": 200,
     "text": "{ review }"
    },
    {
     "status": 403,
     "text": "\"Your own review is not shared with you yet.\""
    },
    {
     "status": 404,
     "text": "\"Review not found.\""
    }
   ],
   "errors": [
    {
     "status": 403,
     "text": "\"Your own review is not shared with you yet.\""
    },
    {
     "status": 404,
     "text": "\"Review not found.\""
    }
   ],
   "guard": null
  },
  {
   "id": "GET /user-management/surveys/mine",
   "group": "hr-goals-reviews-surveys",
   "method": "GET",
   "path": "/user-management/surveys/mine",
   "purpose": "The open surveys you are invited to.",
   "auth": {
    "text": "Session (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { surveys:[ Survey ] }, each with responded:boolean; only open surveys you were invited to, most recently opened first; no paging.",
   "responses": [
    {
     "status": 200,
     "text": "{ surveys:[ Survey ] }, each with responded:boolean; only open surveys you were invited to, most recently opened first; no paging."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /user-management/surveys/:id/respond",
   "group": "hr-goals-reviews-surveys",
   "method": "POST",
   "path": "/user-management/surveys/:id/respond",
   "purpose": "Answer a survey, once. The answer sheet is stored without your identity.",
   "auth": {
    "text": "Session (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "{ answers }: a whole number 1 to 5 for a scale question, 0 to 10 for an enps question, text for a text question.",
   "response": "200 { ok: true } | 400 \"Answer at least one question.\" or a number outside its range | 404 \"Survey not found.\" (also when you were not invited) | 409 \"You have already answered this survey.\" or \"This survey is closed.\"",
   "responses": [
    {
     "status": 200,
     "text": "{ ok: true }"
    },
    {
     "status": 400,
     "text": "\"Answer at least one question.\" or a number outside its range"
    },
    {
     "status": 404,
     "text": "\"Survey not found.\" (also when you were not invited)"
    },
    {
     "status": 409,
     "text": "\"You have already answered this survey.\" or \"This survey is closed.\""
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "\"Answer at least one question.\" or a number outside its range"
    },
    {
     "status": 404,
     "text": "\"Survey not found.\" (also when you were not invited)"
    },
    {
     "status": 409,
     "text": "\"You have already answered this survey.\" or \"This survey is closed.\""
    }
   ],
   "guard": "Repeat: refused with 409; a person answers once."
  },
  {
   "id": "GET /user-management/surveys",
   "group": "hr-goals-reviews-surveys",
   "method": "GET",
   "path": "/user-management/surveys",
   "purpose": "All surveys (the same question compared across closed surveys at /surveys/trends).",
   "auth": {
    "text": "Session + manage_employee_surveys (licensed)",
    "kind": "session",
    "permissions": [
     "manage_employee_surveys"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { surveys:[ Survey ], each with invited:integer and responses:integer, defaultQuestions:object[] (in the shape of Survey.questions), minResponses:5 }, newest first; no paging parameters are read.",
   "responses": [
    {
     "status": 200,
     "text": "{ surveys:[ Survey ], each with invited:integer and responses:integer, defaultQuestions:object[] (in the shape of Survey.questions), minResponses:5 }, newest first; no paging parameters are read."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /user-management/surveys",
   "group": "hr-goals-reviews-surveys",
   "method": "POST",
   "path": "/user-management/surveys",
   "purpose": "Create a survey (change a draft with PUT /surveys/:id; DELETE /surveys/:id removes a draft; POST /:id/duplicate makes a new draft with the same questions).",
   "auth": {
    "text": "Session + manage_employee_surveys (licensed)",
    "kind": "session",
    "permissions": [
     "manage_employee_surveys"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The SurveyWrite object: { title:string (required), intro?:string (up to 2,000 characters), department?:string, closesAt?:string (YYYY-MM-DD, not in the past), questions?:[{ label:string (up to 300 characters), type: scale | enps | text, key?:string }] (1 to 20; the default questions when left out) }",
   "response": "POST 201, PUT 200: { survey:Survey }. DELETE 204. Duplicate: 201 { survey:Survey }. 400 \"A survey needs a title.\", \"A survey needs at least one question.\", a closing date in the past | 404 \"Survey not found.\" | 409 \"A closed survey cannot be changed.\", \"Only a draft can be deleted. Close an open survey instead.\"",
   "responses": [
    {
     "status": null,
     "text": "POST 201, PUT 200: { survey:Survey }. DELETE 204. Duplicate: 201 { survey:Survey }. 400 \"A survey needs a title.\", \"A survey needs at least one question.\", a closing date in the past"
    },
    {
     "status": 404,
     "text": "\"Survey not found.\""
    },
    {
     "status": 409,
     "text": "\"A closed survey cannot be changed.\", \"Only a draft can be deleted. Close an open survey instead.\""
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "\"Survey not found.\""
    },
    {
     "status": 409,
     "text": "\"A closed survey cannot be changed.\", \"Only a draft can be deleted. Close an open survey instead.\""
    }
   ],
   "guard": "Repeat: POST and duplicate each create another draft."
  },
  {
   "id": "POST /user-management/surveys/:id/open",
   "group": "hr-goals-reviews-surveys",
   "method": "POST",
   "path": "/user-management/surveys/:id/open",
   "purpose": "Open a survey to its audience (close it at /surveys/:id/close).",
   "auth": {
    "text": "Session + manage_employee_surveys (licensed)",
    "kind": "session",
    "permissions": [
     "manage_employee_surveys"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body, for the open and for the close.",
   "response": "200 { survey:Survey, invited:integer } | 400 when fewer than 5 people would be asked, or the closing date has passed | 404 | 409 \"Only a draft survey can be opened.\" Close: 200 { survey:Survey } | 409 \"Only an open survey can be closed.\"",
   "responses": [
    {
     "status": 200,
     "text": "{ survey:Survey, invited:integer }"
    },
    {
     "status": 400,
     "text": "when fewer than 5 people would be asked, or the closing date has passed"
    },
    {
     "status": 404,
     "text": ""
    },
    {
     "status": 409,
     "text": "\"Only a draft survey can be opened.\" Close: 200 { survey:Survey }"
    },
    {
     "status": 409,
     "text": "\"Only an open survey can be closed.\""
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "when fewer than 5 people would be asked, or the closing date has passed"
    },
    {
     "status": 404,
     "text": ""
    },
    {
     "status": 409,
     "text": "\"Only a draft survey can be opened.\" Close: 200 { survey:Survey }"
    },
    {
     "status": 409,
     "text": "\"Only an open survey can be closed.\""
    }
   ],
   "guard": "Repeat: refused with 409; a survey opens once and closes once."
  },
  {
   "id": "GET /user-management/surveys/:id/results",
   "group": "hr-goals-reviews-surveys",
   "method": "GET",
   "path": "/user-management/surveys/:id/results",
   "purpose": "The combined results of a closed survey. Never individual answers.",
   "auth": {
    "text": "Session + manage_employee_surveys (licensed)",
    "kind": "session",
    "permissions": [
     "manage_employee_surveys"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { survey: Survey, results: { responses: integer, invited: integer, responseRate: integer | null, minResponses: 5, withheld: boolean, reason?: 'open' | 'too_few', questions: object[] } }. responseRate is a whole percentage, null when nobody was invited. While the survey is open, or with fewer than 5 responses, withheld is true, reason says which, and questions is empty. Otherwise each number question is { key: string, label: string, type: string, answered: integer, average: number | null, distribution: [{ value: integer, count: integer }], enps?: integer } and each written question is { key, label, type: 'text', answered: integer, comments: string[] } with the comments in shuffled order. Each read is written to the audit log. 404 \"Survey not found.\"",
   "responses": [
    {
     "status": 200,
     "text": "{ survey: Survey, results: { responses: integer, invited: integer, responseRate: integer | null, minResponses: 5, withheld: boolean, reason?: 'open' | 'too_few', questions: object[] } }. responseRate is a whole percentage, null when nobody was invited. While the survey is open, or with fewer than 5 responses, withheld is true, reason says which, and questions is empty. Otherwise each number question is { key: string, label: string, type: string, answered: integer, average: number | null, distribution: [{ value: integer, count: integer }], enps?: integer } and each written question is { key, label, type: 'text', answered: integer, comments: string[] } with the comments in shuffled order. Each read is written to the audit log. 404 \"Survey not found.\""
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /user-management/training/mine",
   "group": "training",
   "method": "GET",
   "path": "/user-management/training/mine",
   "purpose": "Your own training, to do and done.",
   "auth": {
    "text": "Session (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { assignments:[ TrainingAssignment ] }; no paging. Open items on a retired course are left out.",
   "responses": [
    {
     "status": 200,
     "text": "{ assignments:[ TrainingAssignment ] }; no paging. Open items on a retired course are left out."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /user-management/training/assignments/:assignmentId/complete",
   "group": "training",
   "method": "POST",
   "path": "/user-management/training/assignments/:assignmentId/complete",
   "purpose": "Mark an assignment done. It must be your own, unless you hold manage_employee_training; a manager cannot complete training for a report.",
   "auth": {
    "text": "Session (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "assignmentId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The TrainingComplete object: { acknowledged: true } for an item that asks you to confirm you have read it; { reason } when HR marks an item with a security topic done for someone else.",
   "response": "200 { assignment:TrainingAssignment } | 400 \"Confirm that you have read and understood it.\", \"Upload your certificate first.\", \"Answer the questions to finish this.\" | 404 \"Assignment not found.\"",
   "responses": [
    {
     "status": 200,
     "text": "{ assignment:TrainingAssignment }"
    },
    {
     "status": 400,
     "text": "\"Confirm that you have read and understood it.\", \"Upload your certificate first.\", \"Answer the questions to finish this.\""
    },
    {
     "status": 404,
     "text": "\"Assignment not found.\""
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "\"Confirm that you have read and understood it.\", \"Upload your certificate first.\", \"Answer the questions to finish this.\""
    },
    {
     "status": 404,
     "text": "\"Assignment not found.\""
    }
   ],
   "guard": "Repeat: safe; completing an item that is already done returns it and records nothing new."
  },
  {
   "id": "POST /user-management/training/assignments/:assignmentId/quiz",
   "group": "training",
   "method": "POST",
   "path": "/user-management/training/assignments/:assignmentId/quiz",
   "purpose": "Answer the knowledge check of your own assignment.",
   "auth": {
    "text": "Session (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "assignmentId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "{ answers, draw? }",
   "response": "200 { passed:true, score:integer, assignment:TrainingAssignment } | 200 { passed:false, score:integer, passMark:integer, triesLeft:integer|null (null when tries are not limited), wrongCount:integer, quiz:TrainingQuiz (the next try's questions) } | 200 { passed:false, stale:true, quiz:TrainingQuiz } when the questions changed while you answered | 400 \"This training item has no knowledge check.\" | 404 | 429 when today's tries are used up.",
   "responses": [
    {
     "status": 200,
     "text": "{ passed:true, score:integer, assignment:TrainingAssignment }"
    },
    {
     "status": 200,
     "text": "{ passed:false, score:integer, passMark:integer, triesLeft:integer|null (null when tries are not limited), wrongCount:integer, quiz:TrainingQuiz (the next try's questions) }"
    },
    {
     "status": 200,
     "text": "{ passed:false, stale:true, quiz:TrainingQuiz } when the questions changed while you answered"
    },
    {
     "status": 400,
     "text": "\"This training item has no knowledge check.\""
    },
    {
     "status": 404,
     "text": ""
    },
    {
     "status": 429,
     "text": "when today's tries are used up."
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "\"This training item has no knowledge check.\""
    },
    {
     "status": 404,
     "text": ""
    },
    {
     "status": 429,
     "text": "when today's tries are used up."
    }
   ],
   "guard": "Repeat: after a pass, another call answers { passed: true, assignment } and records nothing."
  },
  {
   "id": "PUT /user-management/training/assignments/:assignmentId/certificate",
   "group": "training",
   "method": "PUT",
   "path": "/user-management/training/assignments/:assignmentId/certificate",
   "purpose": "Upload a certificate for training that asks for one (download with GET). Your own, or as HR.",
   "auth": {
    "text": "Session (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "assignmentId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The file itself as the raw request body: PDF, PNG or JPEG.",
   "response": "201 { assignment:TrainingAssignment } | 400 \"This training does not ask for a certificate.\" | 404 \"Assignment not found.\" | 409 \"This training is already marked done.\" | 415 for any other file type. GET: the file | 404 \"No certificate has been uploaded.\"",
   "responses": [
    {
     "status": 201,
     "text": "{ assignment:TrainingAssignment }"
    },
    {
     "status": 400,
     "text": "\"This training does not ask for a certificate.\""
    },
    {
     "status": 404,
     "text": "\"Assignment not found.\""
    },
    {
     "status": 409,
     "text": "\"This training is already marked done.\""
    },
    {
     "status": 415,
     "text": "for any other file type. GET: the file"
    },
    {
     "status": 404,
     "text": "\"No certificate has been uploaded.\""
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "\"This training does not ask for a certificate.\""
    },
    {
     "status": 404,
     "text": "\"Assignment not found.\""
    },
    {
     "status": 409,
     "text": "\"This training is already marked done.\""
    },
    {
     "status": 415,
     "text": "for any other file type. GET: the file"
    },
    {
     "status": 404,
     "text": "\"No certificate has been uploaded.\""
    }
   ],
   "guard": "Repeat: a second upload answers 201 and stores a second file; the assignment points at the newest and the earlier one stays in the document vault. Once the training is marked done the answer is 409"
  },
  {
   "id": "GET /user-management/training/people/:userId",
   "group": "training",
   "method": "GET",
   "path": "/user-management/training/people/:userId",
   "purpose": "One person's training, if you are above them in the reporting line or hold manage_employee_training, view_employee_records or manage_employee_records.",
   "auth": {
    "text": "Session (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "userId",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { person: { id: integer, name: string }, assignments: [ TrainingAssignment ] }. exemptUntil is present only on an open item the person is excused from, and overdue is then false. Open items on a retired course are left out. Reading someone else's record is written to the audit log. 404 \"Not found.\" when the person is neither below you nor readable with one of the three permissions. Not paged: every assignment of the person in one reply.",
   "responses": [
    {
     "status": 200,
     "text": "{ person: { id: integer, name: string }, assignments: [ TrainingAssignment ] }. exemptUntil is present only on an open item the person is excused from, and overdue is then false. Open items on a retired course are left out. Reading someone else's record is written to the audit log. 404 \"Not found.\" when the person is neither below you nor readable with one of the three permissions. Not paged: every assignment of the person in one reply."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /user-management/training/team",
   "group": "training",
   "method": "GET",
   "path": "/user-management/training/team",
   "purpose": "What is open and overdue for the people below you in the reporting line.",
   "auth": {
    "text": "Session (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { people: [{ userId: integer, name: string, department: string | null, direct: boolean, open: integer, overdue: integer, items: [{ assignmentId: integer, course: string, dueDate: 'YYYY-MM-DD' | null, overdue: boolean, renewal: boolean }] }], summary: { people: integer, open: integer, overdue: integer } }. direct is true for someone who reports to you directly. People are sorted most overdue first; items a person is excused from, and items on a retired course, are left out. With nobody below you: people is empty and every summary figure is 0. Not paged: everyone below you in one reply.",
   "responses": [
    {
     "status": 200,
     "text": "{ people: [{ userId: integer, name: string, department: string | null, direct: boolean, open: integer, overdue: integer, items: [{ assignmentId: integer, course: string, dueDate: 'YYYY-MM-DD' | null, overdue: boolean, renewal: boolean }] }], summary: { people: integer, open: integer, overdue: integer } }. direct is true for someone who reports to you directly. People are sorted most overdue first; items a person is excused from, and items on a retired course, are left out. With nobody below you: people is empty and every summary figure is 0. Not paged: everyone below you in one reply."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /user-management/training/courses",
   "group": "training",
   "method": "GET",
   "path": "/user-management/training/courses",
   "purpose": "List courses (one course's knowledge check with its answers at /courses/:id/quiz).",
   "auth": {
    "text": "Session + manage_employee_training (licensed)",
    "kind": "session",
    "permissions": [
     "manage_employee_training"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { courses:[ TrainingCourse ] }, each with the counts assigned:integer, completed:integer and overdue:integer; courses in use first, then by title; everything, no paging.",
   "responses": [
    {
     "status": 200,
     "text": "{ courses:[ TrainingCourse ] }, each with the counts assigned:integer, completed:integer and overdue:integer; courses in use first, then by title; everything, no paging."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /user-management/training/courses",
   "group": "training",
   "method": "POST",
   "path": "/user-management/training/courses",
   "purpose": "Create a course (change with PUT /courses/:id).",
   "auth": {
    "text": "Session + manage_employee_training (licensed)",
    "kind": "session",
    "permissions": [
     "manage_employee_training"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The TrainingCourseWrite object: { title:string (required), description?:string (up to 2,000 characters), url?:string (http or https), content?:string (up to 20,000 characters), proof? (none | acknowledge | certificate | quiz; default none), quiz?:object (at least one question with proof quiz), renewEveryMonths?:integer (1 to 120), dueDays?:integer (1 to 365; default 30), topic?:string, autoAssign?:boolean, isActive?:boolean, videoUrl?:string, videoRequired?:boolean, audience?:object }",
   "response": "POST 201, PUT 200: { course:TrainingCourse } | 400 with the reason | 404 \"Training item not found.\"",
   "responses": [
    {
     "status": null,
     "text": "POST 201, PUT 200: { course:TrainingCourse }"
    },
    {
     "status": 400,
     "text": "with the reason"
    },
    {
     "status": 404,
     "text": "\"Training item not found.\""
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "with the reason"
    },
    {
     "status": 404,
     "text": "\"Training item not found.\""
    }
   ],
   "guard": "A course with a security topic must be redone at least every 12 months and cannot use proof 'none'. Repeat: POST is not deduplicated."
  },
  {
   "id": "GET /user-management/training/library",
   "group": "training",
   "method": "GET",
   "path": "/user-management/training/library",
   "purpose": "The ready-made course library.",
   "auth": {
    "text": "Session + manage_employee_training (licensed)",
    "kind": "session",
    "permissions": [
     "manage_employee_training"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { videos: [{ id: string, title: string, seconds: integer, url: string }], courses: [{ key: string, title: string, description: string, minutes: integer, sections: integer, questions: integer, passMark: integer, securityAwareness: boolean, topic: string, feeds: integer, fills: array, hasVideo: boolean, added: boolean, guided: boolean }] }. added is true when you already have an item with that title; guided is true when that item already has a knowledge check. Not paged: the whole library in one reply.",
   "responses": [
    {
     "status": 200,
     "text": "{ videos: [{ id: string, title: string, seconds: integer, url: string }], courses: [{ key: string, title: string, description: string, minutes: integer, sections: integer, questions: integer, passMark: integer, securityAwareness: boolean, topic: string, feeds: integer, fills: array, hasVideo: boolean, added: boolean, guided: boolean }] }. added is true when you already have an item with that title; guided is true when that item already has a knowledge check. Not paged: the whole library in one reply."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /user-management/training/library/:key/adopt",
   "group": "training",
   "method": "POST",
   "path": "/user-management/training/library/:key/adopt",
   "purpose": "Add a library course to your own list as an ordinary course: editable and not yet assigned.",
   "auth": {
    "text": "Session + manage_employee_training (licensed)",
    "kind": "session",
    "permissions": [
     "manage_employee_training"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "key",
     "type": "string",
     "basis": "unknown"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The LibraryAdopt object: { replace?: true }",
   "response": "201 { course:TrainingCourse } | 404 \"That course is not in the library.\" | 409 when you already have a training item with that title and replace is not true",
   "responses": [
    {
     "status": 201,
     "text": "{ course:TrainingCourse }"
    },
    {
     "status": 404,
     "text": "\"That course is not in the library.\""
    },
    {
     "status": 409,
     "text": "when you already have a training item with that title and replace is not true"
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "\"That course is not in the library.\""
    },
    {
     "status": 409,
     "text": "when you already have a training item with that title and replace is not true"
    }
   ],
   "guard": "Repeat: without replace a second call answers 409, because the item now exists; with replace: true it writes the library text and knowledge check again"
  },
  {
   "id": "POST /user-management/training/courses/:id/assign",
   "group": "training",
   "method": "POST",
   "path": "/user-management/training/courses/:id/assign",
   "purpose": "Assign a course to people (the choices an audience can pick from at GET /training/audience-options).",
   "auth": {
    "text": "Session + manage_employee_training (licensed)",
    "kind": "session",
    "permissions": [
     "manage_employee_training"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The TrainingAssign object: { audience (required: 'everyone' | 'department' | 'people'), department (with 'department'), userIds[] (with 'people'; at most 2,000), dueDate? (YYYY-MM-DD; default: the course's days to finish, counted from today) }",
   "response": "200 { added:integer (new assignments), matched:integer (active people the request matched), delivered:integer (people a notice reached) } | 400 \"Choose a department.\", \"Choose at least one person.\", \"Nobody active matches that.\" | 404 \"Training item not found.\" (also for a retired course)",
   "responses": [
    {
     "status": 200,
     "text": "{ added:integer (new assignments), matched:integer (active people the request matched), delivered:integer (people a notice reached) }"
    },
    {
     "status": 400,
     "text": "\"Choose a department.\", \"Choose at least one person.\", \"Nobody active matches that.\""
    },
    {
     "status": 404,
     "text": "\"Training item not found.\" (also for a retired course)"
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "\"Choose a department.\", \"Choose at least one person.\", \"Nobody active matches that.\""
    },
    {
     "status": 404,
     "text": "\"Training item not found.\" (also for a retired course)"
    }
   ],
   "guard": "Repeat: safe. A person who already has the course is left exactly as they are; added counts only new assignments."
  },
  {
   "id": "GET /user-management/training/courses/:id/status",
   "group": "training",
   "method": "GET",
   "path": "/user-management/training/courses/:id/status",
   "purpose": "Who has and has not done one course.",
   "auth": {
    "text": "Session + manage_employee_training (licensed)",
    "kind": "session",
    "permissions": [
     "manage_employee_training"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { course: TrainingCourse, left: integer, assignments: TrainingAssignment[] }. Each assignment has course null and adds person: string and, for someone excused, exemptUntil: date and exemptReason: string (overdue is then false). left counts assignments of people who are no longer active; they are not listed. Sorted overdue first, then by name. 404 \"Training item not found.\" Not paged: every assignment of the course in one reply.",
   "responses": [
    {
     "status": 200,
     "text": "{ course: TrainingCourse, left: integer, assignments: TrainingAssignment[] }. Each assignment has course null and adds person: string and, for someone excused, exemptUntil: date and exemptReason: string (overdue is then false). left counts assignments of people who are no longer active; they are not listed. Sorted overdue first, then by name. 404 \"Training item not found.\" Not paged: every assignment of the course in one reply."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /user-management/training/roster",
   "group": "training",
   "method": "GET",
   "path": "/user-management/training/roster",
   "purpose": "Every assignment across all courses. Add ?format=csv to download the same rows.",
   "auth": {
    "text": "Session + manage_employee_training (licensed)",
    "kind": "session",
    "permissions": [
     "manage_employee_training"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query courseId?, department?, status?, format=csv.",
   "response": "200 { rows:[{ assignmentId:integer, courseId:integer, course:string, securityAwareness:boolean, active:boolean, userId:integer, person:string, email:string, department:string|null, manager:string|null, state:'overdue'|'open'|'exempt'|'completed', exemptUntil:string (date)|null, exemptReason:string|null, dueDate:string (date)|null, completedAt:string (date-time)|null, lastCompletedAt:string (date-time)|null, expiresAt:string (date)|null, timesCompleted:integer, markedByHr:boolean, hasCertificate:boolean }], truncated:boolean, summary:{ total, overdue, open, exempt, completed } (integers), departments:string[], dashboard:object (the figures behind the page's charts; not typed here) }; sorted overdue first, then open, exempt and completed; up to 20,000 assignments; truncated is true beyond that. No paging parameters are read. With format=csv: the same rows as a file, with headers X-Export-Row-Count and X-Export-Truncated.",
   "responses": [
    {
     "status": 200,
     "text": "{ rows:[{ assignmentId:integer, courseId:integer, course:string, securityAwareness:boolean, active:boolean, userId:integer, person:string, email:string, department:string|null, manager:string|null, state:'overdue'|'open'|'exempt'|'completed', exemptUntil:string (date)|null, exemptReason:string|null, dueDate:string (date)|null, completedAt:string (date-time)|null, lastCompletedAt:string (date-time)|null, expiresAt:string (date)|null, timesCompleted:integer, markedByHr:boolean, hasCertificate:boolean }], truncated:boolean, summary:{ total, overdue, open, exempt, completed } (integers), departments:string[], dashboard:object (the figures behind the page's charts; not typed here) }; sorted overdue first, then open, exempt and completed; up to 20,000 assignments; truncated is true beyond that. No paging parameters are read. With format=csv: the same rows as a file, with headers X-Export-Row-Count and X-Export-Truncated."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /user-management/training/assignments/batch",
   "group": "training",
   "method": "POST",
   "path": "/user-management/training/assignments/batch",
   "purpose": "Change the due date of, remind or remove a selection of assignments in one call (remove one with DELETE /assignments/:assignmentId).",
   "auth": {
    "text": "Session + manage_employee_training (licensed)",
    "kind": "session",
    "permissions": [
     "manage_employee_training"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The TrainingBatchAction object: { action (required: 'remove' | 'due' | 'remind' | 'complete'), ids[] (at most 2,000) or filter { courseId, department, status }, dueDate (with 'due'), reason? }. DELETE /assignments/:assignmentId takes reason in the body or as ?reason=.",
   "response": "200 { action:string, done:integer, skipped:[{ id:integer, reason:string }] } | 400 \"Choose at least one assignment.\", \"Too many in one go.\", \"Choose the new due date.\" DELETE: 204 | 404 \"Assignment not found.\"",
   "responses": [
    {
     "status": 200,
     "text": "{ action:string, done:integer, skipped:[{ id:integer, reason:string }] }"
    },
    {
     "status": 400,
     "text": "\"Choose at least one assignment.\", \"Too many in one go.\", \"Choose the new due date.\" DELETE: 204"
    },
    {
     "status": 404,
     "text": "\"Assignment not found.\""
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "\"Choose at least one assignment.\", \"Too many in one go.\", \"Choose the new due date.\" DELETE: 204"
    },
    {
     "status": 404,
     "text": "\"Assignment not found.\""
    }
   ],
   "guard": "One that cannot be acted on is listed in skipped with its reason; the others still go through. Repeat: for remove, an assignment that is already gone is listed in skipped with \"Assignment not found.\"; for due and complete, one that is already done is listed in skipped with \"Already done.\""
  },
  {
   "id": "POST /user-management/training/courses/:id/import-completions",
   "group": "training",
   "method": "POST",
   "path": "/user-management/training/courses/:id/import-completions",
   "purpose": "Record completions of one course that happened in another system (a file covering several courses at POST /training/import-completions).",
   "auth": {
    "text": "Session + manage_employee_training (licensed)",
    "kind": "session",
    "permissions": [
     "manage_employee_training"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The CompletionsImport object: { source (required, at least 3 characters: where the completions come from), rows:[ CompletionRow ] (at most 5,000; each { email, completedAt (YYYY-MM-DD) }) }. The file route (/training/import-completions) takes { source, rows[], dateOrder? }.",
   "response": "200 { imported:integer, skipped:[{ email:string, reason:string }] }; the file route answers { imported, items[], skipped, ambiguous } (not typed here) | 400 \"There is nothing to import.\" or more than 5,000 rows | 404 \"Training item not found.\"",
   "responses": [
    {
     "status": 200,
     "text": "{ imported:integer, skipped:[{ email:string, reason:string }] }; the file route answers { imported, items[], skipped, ambiguous } (not typed here)"
    },
    {
     "status": 400,
     "text": "\"There is nothing to import.\" or more than 5,000 rows"
    },
    {
     "status": 404,
     "text": "\"Training item not found.\""
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "\"There is nothing to import.\" or more than 5,000 rows"
    },
    {
     "status": 404,
     "text": "\"Training item not found.\""
    }
   ],
   "guard": "Repeat: safe; a row whose person already has a completion on or after that date is listed in skipped and nothing is recorded twice"
  },
  {
   "id": "GET /user-management/training/settings",
   "group": "training",
   "method": "GET",
   "path": "/user-management/training/settings",
   "purpose": "Read the reminder settings (save with PUT).",
   "auth": {
    "text": "Session + manage_employee_training (licensed)",
    "kind": "session",
    "permissions": [
     "manage_employee_training"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { daysBefore: integer, renewGraceDays: integer, sendHourUtc: integer | null }. Defaults when nothing is saved: 7, 30 and null (null means the daily run is not held to one hour). PUT takes the same three fields (daysBefore 1 to 30, renewGraceDays 7 to 90, sendHourUtc 0 to 23 or empty), answers 200 with the saved values and writes an audit entry; 400 names the field that is out of range.",
   "responses": [
    {
     "status": 200,
     "text": "{ daysBefore: integer, renewGraceDays: integer, sendHourUtc: integer | null }. Defaults when nothing is saved: 7, 30 and null (null means the daily run is not held to one hour). PUT takes the same three fields (daysBefore 1 to 30, renewGraceDays 7 to 90, sendHourUtc 0 to 23 or empty), answers 200 with the saved values and writes an audit entry; 400 names the field that is out of range."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /user-management/training/exemption-suggestions",
   "group": "training",
   "method": "GET",
   "path": "/user-management/training/exemption-suggestions",
   "purpose": "People on long approved leave who have open training.",
   "auth": {
    "text": "Session + manage_employee_training (licensed)",
    "kind": "session",
    "permissions": [
     "manage_employee_training"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { suggestions: [{ userId: integer, person: string, email: string, courseId: integer, course: string, dueDate: 'YYYY-MM-DD' | null, leaveUntil: 'YYYY-MM-DD' }] }, sorted by person. One row per open item of an active person whose approved leave is 28 days or longer and is running now or starts within 7 days. Items the person is already excused from are left out. Nothing is recorded by this read. Not paged: every suggestion in one reply.",
   "responses": [
    {
     "status": 200,
     "text": "{ suggestions: [{ userId: integer, person: string, email: string, courseId: integer, course: string, dueDate: 'YYYY-MM-DD' | null, leaveUntil: 'YYYY-MM-DD' }] }, sorted by person. One row per open item of an active person whose approved leave is 28 days or longer and is running now or starts within 7 days. Items the person is already excused from are left out. Nothing is recorded by this read. Not paged: every suggestion in one reply."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /user-management/training/courses/:id/video",
   "group": "training",
   "method": "POST",
   "path": "/user-management/training/courses/:id/video",
   "purpose": "Upload a video file for a course, .mp4 or .webm (remove with DELETE).",
   "auth": {
    "text": "Session + manage_employee_training (licensed)",
    "kind": "session",
    "permissions": [
     "manage_employee_training"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The video file itself as the raw request body: an .mp4 or a .webm file. No JSON body. DELETE: no body.",
   "response": "201 { course:TrainingCourse, sizeBytes:integer } | 400 \"The file is empty.\" or an interrupted upload | 413 when the video is over the size limit | 415 for a file that is not an .mp4 or a .webm video. DELETE: 200 { course:TrainingCourse }.",
   "responses": [
    {
     "status": 201,
     "text": "{ course:TrainingCourse, sizeBytes:integer }"
    },
    {
     "status": 400,
     "text": "\"The file is empty.\" or an interrupted upload"
    },
    {
     "status": 413,
     "text": "when the video is over the size limit"
    },
    {
     "status": 415,
     "text": "for a file that is not an .mp4 or a .webm video. DELETE: 200 { course:TrainingCourse }."
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "\"The file is empty.\" or an interrupted upload"
    },
    {
     "status": 413,
     "text": "when the video is over the size limit"
    },
    {
     "status": 415,
     "text": "for a file that is not an .mp4 or a .webm video. DELETE: 200 { course:TrainingCourse }."
    }
   ],
   "guard": "Repeat: a second upload replaces the first"
  },
  {
   "id": "GET /user-management/training/courses/:id/video/file",
   "group": "training",
   "method": "GET",
   "path": "/user-management/training/courses/:id/video/file",
   "purpose": "Watch a course's uploaded video, as HR or as someone the course is assigned to.",
   "auth": {
    "text": "Session (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 the whole file, or 206 one part when a Range header is sent, as the stored content type with Content-Disposition: inline, Accept-Ranges: bytes and Cache-Control: private, no-store, no-transform. 416 with Content-Range: bytes */<size> when the range starts past the end. 404 \"Training item not found.\" when the item does not exist or is not assigned to you; 404 \"This item has no uploaded video.\"",
   "responses": [
    {
     "status": 200,
     "text": "the whole file, or 206 one part when a Range header is sent, as the stored content type with Content-Disposition: inline, Accept-Ranges: bytes and Cache-Control: private, no-store, no-transform. 416 with Content-Range: bytes */<size> when the range starts past the end. 404 \"Training item not found.\" when the item does not exist or is not assigned to you; 404 \"This item has no uploaded video.\""
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /user-management/training/courses/:id/scorm",
   "group": "training",
   "method": "POST",
   "path": "/user-management/training/courses/:id/scorm",
   "purpose": "Import a SCORM 1.2 package of one lesson (remove with DELETE).",
   "auth": {
    "text": "Session + manage_employee_training (licensed)",
    "kind": "session",
    "permissions": [
     "manage_employee_training"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The SCORM package itself, a .zip file of at most 50 MB, as the raw request body. No JSON body. DELETE: no body.",
   "response": "201 { course:TrainingCourse } | 400 \"The file is empty.\", or a package that cannot be read | 413 when the .zip is over 50 MB. DELETE: 200 { course:TrainingCourse }.",
   "responses": [
    {
     "status": 201,
     "text": "{ course:TrainingCourse }"
    },
    {
     "status": 400,
     "text": "\"The file is empty.\", or a package that cannot be read"
    },
    {
     "status": 413,
     "text": "when the .zip is over 50 MB. DELETE: 200 { course:TrainingCourse }."
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "\"The file is empty.\", or a package that cannot be read"
    },
    {
     "status": 413,
     "text": "when the .zip is over 50 MB. DELETE: 200 { course:TrainingCourse }."
    }
   ],
   "guard": "Repeat: a second import replaces the earlier package"
  },
  {
   "id": "GET /user-management/training/courses/:id/scorm/launch",
   "group": "training",
   "method": "GET",
   "path": "/user-management/training/courses/:id/scorm/launch",
   "purpose": "Get a short-lived pass to open a course's SCORM lesson, as HR or as someone it is assigned to.",
   "auth": {
    "text": "Session (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { token: string, channel: string, expiresAt: timestamp, launch: string, package: { title: string | null, launch: string, files: integer, bytes: integer, importedAt: timestamp } }. token is the pass for /training-content/:pass/*; it runs out 4 hours after it is issued. launch is the path of the page to open first, inside the package. 404 \"Training item not found.\" when the item does not exist or is not assigned to you; 404 \"This item has no SCORM package.\"",
   "responses": [
    {
     "status": 200,
     "text": "{ token: string, channel: string, expiresAt: timestamp, launch: string, package: { title: string | null, launch: string, files: integer, bytes: integer, importedAt: timestamp } }. token is the pass for /training-content/:pass/*; it runs out 4 hours after it is issued. launch is the path of the page to open first, inside the package. 404 \"Training item not found.\" when the item does not exist or is not assigned to you; 404 \"This item has no SCORM package.\""
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /training-content/:pass/*",
   "group": "training",
   "method": "GET",
   "path": "/training-content/:pass/*",
   "purpose": "The files of a SCORM lesson, shown in a sandboxed frame. The pass in the path is the credential.",
   "auth": {
    "text": "Launch pass (rate-limited)",
    "kind": "other",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "pass",
     "type": "string",
     "basis": "unknown"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 the file as its stored content type (206 for a Range request, 416 when the range starts past the end), with a sandboxing Content-Security-Policy, Referrer-Policy: no-referrer and X-Content-Type-Options: nosniff. Every refusal is the same 404 (\"Not found\" or \"Not found.\"): a malformed or expired pass, a person who is no longer assigned, a file that is not in the package. 405 { error: \"Method not allowed\" } for anything but GET and HEAD.",
   "responses": [
    {
     "status": 200,
     "text": "the file as its stored content type (206 for a Range request, 416 when the range starts past the end), with a sandboxing Content-Security-Policy, Referrer-Policy: no-referrer and X-Content-Type-Options: nosniff. Every refusal is the same 404 (\"Not found\" or \"Not found.\"): a malformed or expired pass, a person who is no longer assigned, a file that is not in the package. 405 { error: \"Method not allowed\" } for anything but GET and HEAD."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /user-management/training/paths",
   "group": "training",
   "method": "GET",
   "path": "/user-management/training/paths",
   "purpose": "List learning paths.",
   "auth": {
    "text": "Session + manage_employee_training (licensed)",
    "kind": "session",
    "permissions": [
     "manage_employee_training"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { paths:[ LearningPath ] } with people, finished and percent on each; paths in use first, then by title; everything, no paging.",
   "responses": [
    {
     "status": 200,
     "text": "{ paths:[ LearningPath ] } with people, finished and percent on each; paths in use first, then by title; everything, no paging."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /user-management/training/paths",
   "group": "training",
   "method": "POST",
   "path": "/user-management/training/paths",
   "purpose": "Create a learning path (change with PUT /paths/:id, remove with DELETE /paths/:id).",
   "auth": {
    "text": "Session + manage_employee_training (licensed)",
    "kind": "session",
    "permissions": [
     "manage_employee_training"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The LearningPathWrite object: { title:string (required), courseIds:integer[] (required: 2 to 20 courses in order, each once, none retired), description?:string (up to 2,000 characters), isActive?:boolean (default true), autoAssign?:boolean }",
   "response": "POST 201, PUT 200: { path:LearningPath }. DELETE 204. 400 with the reason | 404 \"Learning path not found.\"",
   "responses": [
    {
     "status": null,
     "text": "POST 201, PUT 200: { path:LearningPath }. DELETE 204. 400 with the reason"
    },
    {
     "status": 404,
     "text": "\"Learning path not found.\""
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "\"Learning path not found.\""
    }
   ],
   "guard": "Repeat: POST is not deduplicated."
  },
  {
   "id": "POST /user-management/training/paths/:id/members",
   "group": "training",
   "method": "POST",
   "path": "/user-management/training/paths/:id/members",
   "purpose": "Add one person or a selection to a path.",
   "auth": {
    "text": "Session + manage_employee_training (licensed)",
    "kind": "session",
    "permissions": [
     "manage_employee_training"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "The PathMembersAdd object: { userIds[] } or { userId }; at most 500 people in one call.",
   "response": "200 { added:integer, already:integer, notFound:integer (ids that matched no active person), assigned:integer, finished:integer, failed:integer } | 400 \"Choose at least one person.\", \"This path is paused. Switch it back on before adding people.\", \"Nobody active matches that.\" | 404",
   "responses": [
    {
     "status": 200,
     "text": "{ added:integer, already:integer, notFound:integer (ids that matched no active person), assigned:integer, finished:integer, failed:integer }"
    },
    {
     "status": 400,
     "text": "\"Choose at least one person.\", \"This path is paused. Switch it back on before adding people.\", \"Nobody active matches that.\""
    },
    {
     "status": 404,
     "text": ""
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "\"Choose at least one person.\", \"This path is paused. Switch it back on before adding people.\", \"Nobody active matches that.\""
    },
    {
     "status": 404,
     "text": ""
    }
   ],
   "guard": "Repeat: safe; people already on the path are counted in already and not added again."
  },
  {
   "id": "GET /user-management/training/paths/:id/progress",
   "group": "training",
   "method": "GET",
   "path": "/user-management/training/paths/:id/progress",
   "purpose": "Per person: the step they are on, how many are done, when they started and finished.",
   "auth": {
    "text": "Session + manage_employee_training (licensed)",
    "kind": "session",
    "permissions": [
     "manage_employee_training"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { path: LearningPath, left: integer, rows: [{ userId: integer, person: string, department: string | null, step: integer | null, course: string | null, dueDate: 'YYYY-MM-DD' | null, overdue: boolean, done: integer, total: integer, startedAt: timestamp | null, completedAt: timestamp | null }], summary: { people: integer, finished: integer, overdue: integer } }. step and course are null once the person has finished the path. left counts members who are no longer active; they are not listed. Each read is written to the audit log. 404 \"Learning path not found.\" Not paged: one row per active member of the path.",
   "responses": [
    {
     "status": 200,
     "text": "{ path: LearningPath, left: integer, rows: [{ userId: integer, person: string, department: string | null, step: integer | null, course: string | null, dueDate: 'YYYY-MM-DD' | null, overdue: boolean, done: integer, total: integer, startedAt: timestamp | null, completedAt: timestamp | null }], summary: { people: integer, finished: integer, overdue: integer } }. step and course are null once the person has finished the path. left counts members who are no longer active; they are not listed. Each read is written to the audit log. 404 \"Learning path not found.\" Not paged: one row per active member of the path."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /user-management/training/phishing/campaigns",
   "group": "phishing-simulation",
   "method": "GET",
   "path": "/user-management/training/phishing/campaigns",
   "purpose": "List simulations (one at /campaigns/:id; the people in one at /campaigns/:id/people).",
   "auth": {
    "text": "Session + manage_phishing_simulation (licensed)",
    "kind": "session",
    "permissions": [
     "manage_phishing_simulation"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { campaigns: [ PhishingCampaign ], kpis: { campaignsSent: integer, emailsSent: integer, clickRate: integer | null, reportRate: integer | null }, me: { id: integer, isAdmin: boolean } }. The rates are whole percentages of emails sent, null when none was sent. GET /campaigns/:id/people answers 200 { people: [{ id: integer, firstName: string, lastName: string, email: string, name: string }] }.",
   "responses": [
    {
     "status": 200,
     "text": "{ campaigns: [ PhishingCampaign ], kpis: { campaignsSent: integer, emailsSent: integer, clickRate: integer | null, reportRate: integer | null }, me: { id: integer, isAdmin: boolean } }. The rates are whole percentages of emails sent, null when none was sent. GET /campaigns/:id/people answers 200 { people: [{ id: integer, firstName: string, lastName: string, email: string, name: string }] }."
    }
   ],
   "errors": [],
   "guard": "The list is the newest 200 simulations; no paging parameters are read. One that does not exist answers 404 \"That simulation does not exist.\""
  },
  {
   "id": "POST /user-management/training/phishing/campaigns",
   "group": "phishing-simulation",
   "method": "POST",
   "path": "/user-management/training/phishing/campaigns",
   "purpose": "Create a simulation (change with PUT /campaigns/:id, remove with DELETE /campaigns/:id).",
   "auth": {
    "text": "Session + manage_phishing_simulation (licensed)",
    "kind": "session",
    "permissions": [
     "manage_phishing_simulation"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The PhishingCampaignWrite object: { templateKey:string (required: one of the built-in templates), userIds:integer[] (required: 1 to 2,000; people who are not active users are left out and counted in skipped), title?:string (up to 160 characters; defaults to the template's name), courseId?:integer (a course in use), sendAt?:string (a date and time) }",
   "response": "POST 201, PUT 200: { campaign:PhishingCampaign, recipients:integer, skipped:integer (ids sent that are not active people) } | DELETE 204. 400 \"Give the simulation a name.\", \"Pick one of the built-in templates.\", \"Pick at least one person.\" | 404 | 409 \"Only a draft can be changed. Send it back to draft first.\" or \"Only a draft can be deleted.\"",
   "responses": [
    {
     "status": null,
     "text": "POST 201, PUT 200: { campaign:PhishingCampaign, recipients:integer, skipped:integer (ids sent that are not active people) } | DELETE 204. 400 \"Give the simulation a name.\", \"Pick one of the built-in templates.\", \"Pick at least one person.\""
    },
    {
     "status": 404,
     "text": ""
    },
    {
     "status": 409,
     "text": "\"Only a draft can be changed. Send it back to draft first.\" or \"Only a draft can be deleted.\""
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": ""
    },
    {
     "status": 409,
     "text": "\"Only a draft can be changed. Send it back to draft first.\" or \"Only a draft can be deleted.\""
    }
   ],
   "guard": "Repeat: POST is not deduplicated."
  },
  {
   "id": "GET /user-management/training/phishing/templates",
   "group": "phishing-simulation",
   "method": "GET",
   "path": "/user-management/training/phishing/templates",
   "purpose": "The practice email templates (the courses a simulation can point to at /phishing/courses).",
   "auth": {
    "text": "Session + manage_phishing_simulation (licensed)",
    "kind": "session",
    "permissions": [
     "manage_phishing_simulation"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { senderName: string, linkReady: boolean, mailReady: boolean, templates: [{ key: string, name: string, difficulty: string, subject: string, tells: string[], previewHtml: string }] }. linkReady is true when practice links would start with an https address; mailReady is true when outgoing mail is set up. GET /phishing/courses answers 200 { courses: [{ id: integer, title: string }] }: the active training items, by title. Neither list is paged.",
   "responses": [
    {
     "status": 200,
     "text": "{ senderName: string, linkReady: boolean, mailReady: boolean, templates: [{ key: string, name: string, difficulty: string, subject: string, tells: string[], previewHtml: string }] }. linkReady is true when practice links would start with an https address; mailReady is true when outgoing mail is set up. GET /phishing/courses answers 200 { courses: [{ id: integer, title: string }] }: the active training items, by title. Neither list is paged."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /user-management/training/phishing/campaigns/:id/submit",
   "group": "phishing-simulation",
   "method": "POST",
   "path": "/user-management/training/phishing/campaigns/:id/submit",
   "purpose": "Send a simulation for approval.",
   "auth": {
    "text": "Session + manage_phishing_simulation (licensed)",
    "kind": "session",
    "permissions": [
     "manage_phishing_simulation"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body.",
   "response": "200 { campaign:PhishingCampaign }, now waiting for approval | 400 \"Pick at least one person before submitting.\" | 409 \"Only a draft can be submitted for approval.\"",
   "responses": [
    {
     "status": 200,
     "text": "{ campaign:PhishingCampaign }, now waiting for approval"
    },
    {
     "status": 400,
     "text": "\"Pick at least one person before submitting.\""
    },
    {
     "status": 409,
     "text": "\"Only a draft can be submitted for approval.\""
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "\"Pick at least one person before submitting.\""
    },
    {
     "status": 409,
     "text": "\"Only a draft can be submitted for approval.\""
    }
   ],
   "guard": "Repeat: refused with 409 once it is waiting for approval."
  },
  {
   "id": "POST /user-management/training/phishing/campaigns/:id/approve",
   "group": "phishing-simulation",
   "method": "POST",
   "path": "/user-management/training/phishing/campaigns/:id/approve",
   "purpose": "Approve a simulation. The person who prepared it is refused, unless they are an administrator (return it to its preparer at /campaigns/:id/send-back).",
   "auth": {
    "text": "Session + manage_phishing_simulation (licensed)",
    "kind": "session",
    "permissions": [
     "manage_phishing_simulation"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body for the approve. Send back: { reason?:string }.",
   "response": "200 { campaign:PhishingCampaign }, now approved | 403 with sod: true when you prepared it | 409 \"Only a simulation waiting for approval can be approved.\" Send back: 200 { campaign:PhishingCampaign } | 409 unless it is waiting for approval, or approved and not yet sent.",
   "responses": [
    {
     "status": 200,
     "text": "{ campaign:PhishingCampaign }, now approved"
    },
    {
     "status": 403,
     "text": "with sod: true when you prepared it"
    },
    {
     "status": 409,
     "text": "\"Only a simulation waiting for approval can be approved.\" Send back: 200 { campaign:PhishingCampaign }"
    },
    {
     "status": 409,
     "text": "unless it is waiting for approval, or approved and not yet sent."
    }
   ],
   "errors": [
    {
     "status": 403,
     "text": "with sod: true when you prepared it"
    },
    {
     "status": 409,
     "text": "\"Only a simulation waiting for approval can be approved.\" Send back: 200 { campaign:PhishingCampaign }"
    },
    {
     "status": 409,
     "text": "unless it is waiting for approval, or approved and not yet sent."
    }
   ],
   "guard": "Repeat: a second approve is refused with 409."
  },
  {
   "id": "POST /user-management/training/phishing/campaigns/:id/send",
   "group": "phishing-simulation",
   "method": "POST",
   "path": "/user-management/training/phishing/campaigns/:id/send",
   "purpose": "Send an approved simulation now (stop one at /campaigns/:id/cancel).",
   "auth": {
    "text": "Session + manage_phishing_simulation (licensed)",
    "kind": "session",
    "permissions": [
     "manage_phishing_simulation"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": "No body, for the send and for the cancel.",
   "response": "202 { campaign:PhishingCampaign }; sending continues in the background | 409 \"Only an approved simulation can be sent.\" or \"This simulation is already being sent.\" Cancel: 200 { campaign:PhishingCampaign } | 409 when it is already sent or already cancelled.",
   "responses": [
    {
     "status": 202,
     "text": "{ campaign:PhishingCampaign }; sending continues in the background"
    },
    {
     "status": 409,
     "text": "\"Only an approved simulation can be sent.\" or \"This simulation is already being sent.\" Cancel: 200 { campaign:PhishingCampaign }"
    },
    {
     "status": 409,
     "text": "when it is already sent or already cancelled."
    }
   ],
   "errors": [
    {
     "status": 409,
     "text": "\"Only an approved simulation can be sent.\" or \"This simulation is already being sent.\" Cancel: 200 { campaign:PhishingCampaign }"
    },
    {
     "status": 409,
     "text": "when it is already sent or already cancelled."
    }
   ],
   "guard": "Repeat: refused with 409 while it is being sent and after it was sent."
  },
  {
   "id": "GET /user-management/training/phishing/campaigns/:id/results",
   "group": "phishing-simulation",
   "method": "GET",
   "path": "/user-management/training/phishing/campaigns/:id/results",
   "purpose": "Per-person results. Each read is written to the audit log.",
   "auth": {
    "text": "Session + manage_phishing_simulation (licensed)",
    "kind": "session",
    "permissions": [
     "manage_phishing_simulation"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "integer",
     "basis": "model"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { campaign: PhishingCampaign, rows: [{ userId: integer, person: string, email: string | null, department: string | null, sentAt: timestamp | null, clickedAt: timestamp | null, reportedAt: timestamp | null, sendError: string | null, outcome: 'not_sent' | 'no_action' | 'clicked' | 'reported' | 'clicked_then_reported' }], pruned: boolean }. Once the per-person results have passed their retention period, rows is empty and pruned is true; the totals on campaign are kept. Someone whose account is gone shows as \"Former user #<id>\" with email null. 404 \"That simulation does not exist.\" Not paged: one row per person the simulation went to (a simulation goes to at most 2,000 people).",
   "responses": [
    {
     "status": 200,
     "text": "{ campaign: PhishingCampaign, rows: [{ userId: integer, person: string, email: string | null, department: string | null, sentAt: timestamp | null, clickedAt: timestamp | null, reportedAt: timestamp | null, sendError: string | null, outcome: 'not_sent' | 'no_action' | 'clicked' | 'reported' | 'clicked_then_reported' }], pruned: boolean }. Once the per-person results have passed their retention period, rows is empty and pruned is true; the totals on campaign are kept. Someone whose account is gone shows as \"Former user #<id>\" with email null. 404 \"That simulation does not exist.\" Not paged: one row per person the simulation went to (a simulation goes to at most 2,000 people)."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /user-management/training/phishing/settings",
   "group": "phishing-simulation",
   "method": "GET",
   "path": "/user-management/training/phishing/settings",
   "purpose": "Read the simulation settings (save with PUT).",
   "auth": {
    "text": "Session + manage_phishing_simulation (licensed)",
    "kind": "session",
    "permissions": [
     "manage_phishing_simulation"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { senderName: string, linkBase: string, retentionMonths: integer }. Defaults when nothing is saved: \"IT Service Desk\", an empty linkBase and 12. PUT takes the same three fields (linkBase must be https://host with no path; retentionMonths 1 to 36), answers 200 with the saved values and writes an audit entry; 400 names the field that is wrong.",
   "responses": [
    {
     "status": 200,
     "text": "{ senderName: string, linkBase: string, retentionMonths: integer }. Defaults when nothing is saved: \"IT Service Desk\", an empty linkBase and 12. PUT takes the same three fields (linkBase must be https://host with no path; retentionMonths 1 to 36), answers 200 with the saved values and writes an audit entry; 400 names the field that is wrong."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /user-management/training/phishing/report",
   "group": "phishing-simulation",
   "method": "POST",
   "path": "/user-management/training/phishing/report",
   "purpose": "Report a suspicious email. Only your own practice email is matched.",
   "auth": {
    "text": "Session (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "The PhishingReport object: { token?:string (the link in the email, or the 64-character code at the end of it), campaign?:integer (a simulation id, read when token is absent) }.",
   "response": "200 { simulation:boolean }",
   "responses": [
    {
     "status": 200,
     "text": "{ simulation:boolean }"
    }
   ],
   "errors": [],
   "guard": "Repeat: safe; only the first report of a practice email is recorded and written to the audit log, and the answer is the same each time"
  },
  {
   "id": "GET /user-management/training/phishing/mine/:token",
   "group": "phishing-simulation",
   "method": "GET",
   "path": "/user-management/training/phishing/mine/:token",
   "purpose": "After a click on your own practice email: the training it points to.",
   "auth": {
    "text": "Session (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "token",
     "type": "string",
     "basis": "unknown"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { simulation: boolean, course: { id: integer, title: string } | null, assignmentId: integer | null }. simulation is false when the token is not one of your own sent practice emails. course is null when the simulation points to no training or the item is retired; assignmentId is null when that item is not open for you.",
   "responses": [
    {
     "status": 200,
     "text": "{ simulation: boolean, course: { id: integer, title: string } | null, assignmentId: integer | null }. simulation is false when the token is not one of your own sent practice emails. course is null when the simulation points to no training or the item is retired; assignmentId is null when that item is not open for you."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /public/phishing/l/:token",
   "group": "phishing-simulation",
   "method": "GET",
   "path": "/public/phishing/l/:token",
   "purpose": "The page a person lands on after clicking a practice link. It still answers after a plan change, so a link already in an inbox explains itself.",
   "auth": {
    "text": "Public (rate-limited)",
    "kind": "public",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": false
   },
   "pathParameters": [
    {
     "name": "token",
     "type": "string",
     "basis": "unknown"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "Always 200 { simulation: true, tells: string[] } with Cache-Control: no-store. tells are the signs that gave the practice email away. A real, unknown or malformed token gets the same shape, so the answer says nothing about a person. The first visit with a real token records the click; later visits change nothing.",
   "responses": [
    {
     "status": null,
     "text": "Always 200 { simulation: true, tells: string[] } with Cache-Control: no-store. tells are the signs that gave the practice email away. A real, unknown or malformed token gets the same shape, so the answer says nothing about a person. The first visit with a real token records the click; later visits change nothing."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /compliance/reports/compliance/policy-templates",
   "group": "policy-signing",
   "method": "GET",
   "path": "/compliance/reports/compliance/policy-templates",
   "purpose": "The policy template library, with which templates you have already added.",
   "auth": {
    "text": "Session + view_reporting | manage_compliance | manage_employee_training (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "manage_employee_training",
     "view_reporting"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { libraryVersion: string, companyName: string | null, templates: [{ id: string, title: string, version: string, summary: string, ownerRole: string, reviewCadenceDays: integer, requiresAcknowledgment: boolean, controlIds: string[], fills: array, ownText: boolean, body: string, adopted: { policyId: string, version: string, owner: string, adoptedAt: timestamp | null, status: string, reviewedAt: timestamp | null, reviewedBy: string | null, draftedBy: string | null } | null }] }. body is the template text with your company name filled in. adopted is null for a template you have not added. Not paged: the whole library in one reply.",
   "responses": [
    {
     "status": 200,
     "text": "{ libraryVersion: string, companyName: string | null, templates: [{ id: string, title: string, version: string, summary: string, ownerRole: string, reviewCadenceDays: integer, requiresAcknowledgment: boolean, controlIds: string[], fills: array, ownText: boolean, body: string, adopted: { policyId: string, version: string, owner: string, adoptedAt: timestamp | null, status: string, reviewedAt: timestamp | null, reviewedBy: string | null, draftedBy: string | null } | null }] }. body is the template text with your company name filled in. adopted is null for a template you have not added. Not paged: the whole library in one reply."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "POST /compliance/reports/compliance/policy-templates/adopt",
   "group": "policy-signing",
   "method": "POST",
   "path": "/compliance/reports/compliance/policy-templates/adopt",
   "purpose": "Add one or several templates to your policy register.",
   "auth": {
    "text": "Session + manage_compliance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ items: [{ templateId (required), owner, nextReviewDue?, requireAck? }] }",
   "response": "201 { created[], skipped[], libraryVersion } | 200 with the same shape when every template was already added | 400 \"Pick at least one template to adopt.\" or an unknown templateId.",
   "responses": [
    {
     "status": 201,
     "text": "{ created[], skipped[], libraryVersion }"
    },
    {
     "status": 200,
     "text": "with the same shape when every template was already added"
    },
    {
     "status": 400,
     "text": "\"Pick at least one template to adopt.\" or an unknown templateId."
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "\"Pick at least one template to adopt.\" or an unknown templateId."
    }
   ],
   "guard": "Each one is added as a draft that its owner must approve. Repeat: safe; a template already in the register is reported in skipped and not added again."
  },
  {
   "id": "POST /compliance/reports/compliance/policies/approve",
   "group": "policy-signing",
   "method": "POST",
   "path": "/compliance/reports/compliance/policies/approve",
   "purpose": "Approve or re-approve one or several policies. The service allows the policy's owner or a holder of manage_compliance.",
   "auth": {
    "text": "Session (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ ids[] } (or { id })",
   "response": "200 { approved[], refused[] }. 400 \"Pick at least one policy to approve.\" for an empty list; otherwise, when nothing was approved, the status and reason of the first refusal (400, 403 or 404) with { message, approved[], refused[] }.",
   "responses": [
    {
     "status": 200,
     "text": "{ approved[], refused[] }. 400 \"Pick at least one policy to approve.\" for an empty list; otherwise, when nothing was approved, the status and reason of the first refusal (400, 403 or 404) with { message, approved[], refused[] }."
    }
   ],
   "errors": [],
   "guard": "The service decides who may approve each policy and enforces separation of duties; every approval and every blocked self-approval is written to the audit log."
  },
  {
   "id": "GET /compliance/reports/compliance/policies/:id/document",
   "group": "policy-signing",
   "method": "GET",
   "path": "/compliance/reports/compliance/policies/:id/document",
   "purpose": "The text of a policy, for any signed-in person.",
   "auth": {
    "text": "Session (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "string",
     "basis": "unknown"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "404 \"Policy not found\" for an unknown id.",
   "responses": [
    {
     "status": 404,
     "text": "\"Policy not found\" for an unknown id."
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "\"Policy not found\" for an unknown id."
    }
   ],
   "guard": null
  },
  {
   "id": "GET /compliance/reports/compliance/policies/ack/mine",
   "group": "policy-signing",
   "method": "GET",
   "path": "/compliance/reports/compliance/policies/ack/mine",
   "purpose": "The policies you are asked to sign, and the ones you have signed.",
   "auth": {
    "text": "Session (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { pending:object[], acknowledged:object[], exempt:object[] }; no paging. Every entry carries id:string, name:string, version:string, hasDocument:boolean and the policy's description, link and owner as stored. pending adds dueBy, overdue:boolean, reason and previouslySignedAt:string (date-time)|null; acknowledged adds acknowledgedAt:string (date-time) and validUntil; exempt adds exemptReason and exemptUntil. All three are empty for an account that is not a person.",
   "responses": [
    {
     "status": 200,
     "text": "{ pending:object[], acknowledged:object[], exempt:object[] }; no paging. Every entry carries id:string, name:string, version:string, hasDocument:boolean and the policy's description, link and owner as stored. pending adds dueBy, overdue:boolean, reason and previouslySignedAt:string (date-time)|null; acknowledged adds acknowledgedAt:string (date-time) and validUntil; exempt adds exemptReason and exemptUntil. All three are empty for an account that is not a person."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /compliance/reports/compliance/policies/:id/sign-document",
   "group": "policy-signing",
   "method": "GET",
   "path": "/compliance/reports/compliance/policies/:id/sign-document",
   "purpose": "What you sign: the policy text cut into sections, each with a fingerprint.",
   "auth": {
    "text": "Session (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "string",
     "basis": "unknown"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 the document with contentHash and sections[{ n, heading, hash }] | 404 \"That policy is not available for signing.\" for a draft, or a policy that does not ask for signatures.",
   "responses": [
    {
     "status": 200,
     "text": "the document with contentHash and sections[{ n, heading, hash }]"
    },
    {
     "status": 404,
     "text": "\"That policy is not available for signing.\" for a draft, or a policy that does not ask for signatures."
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "\"That policy is not available for signing.\" for a draft, or a policy that does not ask for signatures."
    }
   ],
   "guard": null
  },
  {
   "id": "PUT /compliance/reports/compliance/policies/:id/sign-progress",
   "group": "policy-signing",
   "method": "PUT",
   "path": "/compliance/reports/compliance/policies/:id/sign-progress",
   "purpose": "Save which sections you have confirmed so far (read it back with GET).",
   "auth": {
    "text": "Session (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "string",
     "basis": "unknown"
    }
   ],
   "paging": [],
   "query": [],
   "request": "{ contentHash (from sign-document), sections[] (the hashes confirmed so far) }. GET takes ?contentHash=.",
   "response": "200 { sections[], openedAt } | 404 \"That policy is not available for signing.\" | 409 \"This policy changed while you were reading it. Reload and read the current text.\"",
   "responses": [
    {
     "status": 200,
     "text": "{ sections[], openedAt }"
    },
    {
     "status": 404,
     "text": "\"That policy is not available for signing.\""
    },
    {
     "status": 409,
     "text": "\"This policy changed while you were reading it. Reload and read the current text.\""
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "\"That policy is not available for signing.\""
    },
    {
     "status": 409,
     "text": "\"This policy changed while you were reading it. Reload and read the current text.\""
    }
   ],
   "guard": "Repeat: safe; the list sent replaces the saved one."
  },
  {
   "id": "POST /compliance/reports/compliance/policies/:id/ack",
   "group": "policy-signing",
   "method": "POST",
   "path": "/compliance/reports/compliance/policies/:id/ack",
   "purpose": "Sign a policy as yourself.",
   "auth": {
    "text": "Session (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "string",
     "basis": "unknown"
    }
   ],
   "paging": [],
   "query": [],
   "request": "{ signedName (your full name, at least 2 characters), contentHash (from sign-document), sections[] (the hash of every section) }",
   "response": "201 { policyId, name, version, contentHash, sections, signedName, acknowledgedAt, alreadyAcknowledged, renewal, kind?, nameMatches? } | 400 \"Type your full name to sign.\" or \"Confirm every section before you sign\" | 404 \"That policy is not available for signing.\" | 409 when the text changed while you read it.",
   "responses": [
    {
     "status": 201,
     "text": "{ policyId, name, version, contentHash, sections, signedName, acknowledgedAt, alreadyAcknowledged, renewal, kind?, nameMatches? }"
    },
    {
     "status": 400,
     "text": "\"Type your full name to sign.\" or \"Confirm every section before you sign\""
    },
    {
     "status": 404,
     "text": "\"That policy is not available for signing.\""
    },
    {
     "status": 409,
     "text": "when the text changed while you read it."
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "\"Type your full name to sign.\" or \"Confirm every section before you sign\""
    },
    {
     "status": 404,
     "text": "\"That policy is not available for signing.\""
    },
    {
     "status": 409,
     "text": "when the text changed while you read it."
    }
   ],
   "guard": "Repeat: safe. While your signature is current a second call records nothing and answers alreadyAcknowledged: true."
  },
  {
   "id": "GET /compliance/reports/compliance/policies/:id/signed-document",
   "group": "policy-signing",
   "method": "GET",
   "path": "/compliance/reports/compliance/policies/:id/signed-document",
   "purpose": "The text you signed. Only ever your own signature (your signed copy as a PDF at /policies/:id/signed-copy.pdf).",
   "auth": {
    "text": "Session (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "string",
     "basis": "unknown"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { exact, name, version, link, body, ... }. exact is false when the stored version of the text you signed is no longer on record and the current text is shown instead.",
   "responses": [
    {
     "status": 200,
     "text": "{ exact, name, version, link, body, ... }. exact is false when the stored version of the text you signed is no longer on record and the current text is shown instead."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /compliance/reports/compliance/policies/ack/team",
   "group": "policy-signing",
   "method": "GET",
   "path": "/compliance/reports/compliance/policies/ack/team",
   "purpose": "What the people below you in the reporting line still have to sign.",
   "auth": {
    "text": "Session (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { people: [{ userId: integer, name: string, department: string | null, direct: boolean, open: integer, overdue: integer, items: [{ policyId: string, policy: string, version: string, dueBy: 'YYYY-MM-DD' | null, overdue: boolean }] }], summary: { people: integer, open: integer, overdue: integer } }. Only people below you who still have a policy due or overdue are listed, most overdue first; summary.people counts everyone below you. Not paged.",
   "responses": [
    {
     "status": 200,
     "text": "{ people: [{ userId: integer, name: string, department: string | null, direct: boolean, open: integer, overdue: integer, items: [{ policyId: string, policy: string, version: string, dueBy: 'YYYY-MM-DD' | null, overdue: boolean }] }], summary: { people: integer, open: integer, overdue: integer } }. Only people below you who still have a policy due or overdue are listed, most overdue first; summary.people counts everyone below you. Not paged."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /compliance/reports/compliance/policies/ack/status",
   "group": "policy-signing",
   "method": "GET",
   "path": "/compliance/reports/compliance/policies/ack/status",
   "purpose": "Who has and has not signed, across policies.",
   "auth": {
    "text": "Session + manage_compliance | manage_employee_training (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "manage_employee_training"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "Query policyId?, department?, state? (signed | due | overdue | exempt, or open for due and overdue together).",
   "response": "200 { rows[], counts { total, signed, due, overdue, exempt }, departments[], policies[{ id, name, version }] }; overdue first, then by due date and name. Everything in one answer, no paging.",
   "responses": [
    {
     "status": 200,
     "text": "{ rows[], counts { total, signed, due, overdue, exempt }, departments[], policies[{ id, name, version }] }; overdue first, then by due date and name. Everything in one answer, no paging."
    }
   ],
   "errors": [],
   "guard": "Each read is written to the audit log."
  },
  {
   "id": "POST /compliance/reports/compliance/policies/ack/remind",
   "group": "policy-signing",
   "method": "POST",
   "path": "/compliance/reports/compliance/policies/ack/remind",
   "purpose": "Remind the people who still owe a signature now.",
   "auth": {
    "text": "Session + manage_compliance | manage_employee_training (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "manage_employee_training"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "{ policyId? } (without it: every policy that still has open signatures)",
   "response": "200 { owed, reached, failed, policies[] } | 404 \"That policy does not need signing.\"",
   "responses": [
    {
     "status": 200,
     "text": "{ owed, reached, failed, policies[] }"
    },
    {
     "status": 404,
     "text": "\"That policy does not need signing.\""
    }
   ],
   "errors": [
    {
     "status": 404,
     "text": "\"That policy does not need signing.\""
    }
   ],
   "guard": "Repeat: not deduplicated; every call sends the reminder again."
  },
  {
   "id": "GET /compliance/reports/compliance/policies/:id/ack/roster",
   "group": "policy-signing",
   "method": "GET",
   "path": "/compliance/reports/compliance/policies/:id/ack/roster",
   "purpose": "The roster of one policy: who holds a current signature (as a signed PDF at /ack/roster.pdf; the download is written to the audit log).",
   "auth": {
    "text": "Session + manage_compliance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "id",
     "type": "string",
     "basis": "unknown"
    }
   ],
   "paging": [],
   "query": [],
   "request": "Query ?asOf=yyyy-mm-dd for the roster as it stood at the end of that day.",
   "response": "200 { policy, people[], asOf } | 400 \"asOf must be a date like 2026-03-31.\" The PDF is sent with headers X-Signature-Status and X-Content-SHA256. people[] is not paged: it comes back whole.",
   "responses": [
    {
     "status": 200,
     "text": "{ policy, people[], asOf }"
    },
    {
     "status": 400,
     "text": "\"asOf must be a date like 2026-03-31.\" The PDF is sent with headers X-Signature-Status and X-Content-SHA256. people[] is not paged: it comes back whole."
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "\"asOf must be a date like 2026-03-31.\" The PDF is sent with headers X-Signature-Status and X-Content-SHA256. people[] is not paged: it comes back whole."
    }
   ],
   "guard": "Each read is written to the audit log."
  },
  {
   "id": "GET /compliance/reports/compliance/people/evidence.pdf",
   "group": "policy-signing",
   "method": "GET",
   "path": "/compliance/reports/compliance/people/evidence.pdf",
   "purpose": "Everything on record for one person, by ?email= or ?userId=, as a signed PDF.",
   "auth": {
    "text": "Session + manage_compliance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 a PDF, sent with headers X-Signature-Status and X-Content-SHA256.",
   "responses": [
    {
     "status": 200,
     "text": "a PDF, sent with headers X-Signature-Status and X-Content-SHA256."
    }
   ],
   "errors": [],
   "guard": "Each export is written to the audit log with the person it was about."
  },
  {
   "id": "GET /compliance/reports/compliance/training/:courseId/my-certificate.pdf",
   "group": "policy-signing",
   "method": "GET",
   "path": "/compliance/reports/compliance/training/:courseId/my-certificate.pdf",
   "purpose": "Your own certificate of completion for a course.",
   "auth": {
    "text": "Session (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "courseId",
     "type": "string",
     "basis": "unknown"
    }
   ],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 application/pdf as an attachment, with the headers X-Signature-Status and X-Content-SHA256 and Cache-Control: no-store. The download is written to the audit log. 404 \"You have not completed this training.\"",
   "responses": [
    {
     "status": 200,
     "text": "application/pdf as an attachment, with the headers X-Signature-Status and X-Content-SHA256 and Cache-Control: no-store. The download is written to the audit log. 404 \"You have not completed this training.\""
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /compliance/reports/compliance/training/:courseId/roster.pdf",
   "group": "policy-signing",
   "method": "GET",
   "path": "/compliance/reports/compliance/training/:courseId/roster.pdf",
   "purpose": "The signed roster of one course.",
   "auth": {
    "text": "Session + manage_compliance | manage_employee_training (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "manage_employee_training"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [
    {
     "name": "courseId",
     "type": "string",
     "basis": "unknown"
    }
   ],
   "paging": [],
   "query": [],
   "request": "Query ?asOf=yyyy-mm-dd",
   "response": "200 a PDF, sent with headers X-Signature-Status and X-Content-SHA256 | 400 for a bad asOf.",
   "responses": [
    {
     "status": 200,
     "text": "a PDF, sent with headers X-Signature-Status and X-Content-SHA256"
    },
    {
     "status": 400,
     "text": "for a bad asOf."
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "for a bad asOf."
    }
   ],
   "guard": null
  },
  {
   "id": "GET /compliance/reports/compliance/exemptions",
   "group": "policy-signing",
   "method": "GET",
   "path": "/compliance/reports/compliance/exemptions",
   "purpose": "Recorded exemptions (record one with POST; end one at POST /exemptions/:exemptionId/end). The service allows a compliance manager, or HR for training.",
   "auth": {
    "text": "Session (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "GET query: kind (policy | training), targetId?, includeEnded=1. POST: { kind, targetId, email, reasonCode (long_leave | out_of_scope | covered_elsewhere | leaving | other), note? (up to 500 characters; at least 8 with 'other'), expiresAt (yyyy-mm-dd, in the future, at most 366 days ahead) }",
   "response": "GET 200 { exemptions[], reasons[] }. POST 201 the exemption | 400 with the field at fault | 403 when you may not record that kind, or \"You cannot exempt yourself\" | 404 \"Policy not found.\" or \"Training item not found.\" | 409 \"This person is already exempt until ...\". End: 200 the exemption | 404 \"Exemption not found.\" The GET is not paged and reads no limit or offset: it looks at the newest 2,000 recorded exemptions, newest first, and returns those still in force (all of them with includeEnded=1).",
   "responses": [
    {
     "status": null,
     "text": "GET 200 { exemptions[], reasons[] }. POST 201 the exemption"
    },
    {
     "status": 400,
     "text": "with the field at fault"
    },
    {
     "status": 403,
     "text": "when you may not record that kind, or \"You cannot exempt yourself\""
    },
    {
     "status": 404,
     "text": "\"Policy not found.\" or \"Training item not found.\""
    },
    {
     "status": 409,
     "text": "\"This person is already exempt until ...\". End: 200 the exemption"
    },
    {
     "status": 404,
     "text": "\"Exemption not found.\" The GET is not paged and reads no limit or offset: it looks at the newest 2,000 recorded exemptions, newest first, and returns those still in force (all of them with includeEnded=1)."
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "with the field at fault"
    },
    {
     "status": 403,
     "text": "when you may not record that kind, or \"You cannot exempt yourself\""
    },
    {
     "status": 404,
     "text": "\"Policy not found.\" or \"Training item not found.\""
    },
    {
     "status": 409,
     "text": "\"This person is already exempt until ...\". End: 200 the exemption"
    },
    {
     "status": 404,
     "text": "\"Exemption not found.\" The GET is not paged and reads no limit or offset: it looks at the newest 2,000 recorded exemptions, newest first, and returns those still in force (all of them with includeEnded=1)."
    }
   ],
   "guard": "The list is written to the audit log on every read. Repeat: a second POST for the same person and item is refused with 409; ending one twice answers 200 again and logs nothing."
  },
  {
   "id": "GET /compliance/reports/compliance/evidence-retention",
   "group": "policy-signing",
   "method": "GET",
   "path": "/compliance/reports/compliance/evidence-retention",
   "purpose": "How long signatures and completions are kept (change with PUT, manage_compliance). The same periods in plain words at /my-data-notice.",
   "auth": {
    "text": "Session (licensed)",
    "kind": "session",
    "permissions": [],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": "PUT: { signatureMonths } required: 0 keeps signatures indefinitely, otherwise a whole number between min and max.",
   "response": "200 { signatureMonths:integer (0 when signatures are kept with no end date), trainingMonths:integer (the HR retention setting; 0 when it is off), min:12, max:240 }; PUT answers the same shape | 400 for any other value.",
   "responses": [
    {
     "status": 200,
     "text": "{ signatureMonths:integer (0 when signatures are kept with no end date), trainingMonths:integer (the HR retention setting; 0 when it is off), min:12, max:240 }; PUT answers the same shape"
    },
    {
     "status": 400,
     "text": "for any other value."
    }
   ],
   "errors": [
    {
     "status": 400,
     "text": "for any other value."
    }
   ],
   "guard": "PUT is written to the audit log at warning level with the before and after. Repeat: safe."
  },
  {
   "id": "GET /compliance/reports/compliance/policies/ack/coverage",
   "group": "policy-signing",
   "method": "GET",
   "path": "/compliance/reports/compliance/policies/ack/coverage",
   "purpose": "Signing coverage as a combined figure.",
   "auth": {
    "text": "Session + view_reporting | manage_compliance (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "view_reporting"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { total, policies[] }. Without manage_compliance the per-policy name lists (outstanding, overdue, renewing, untrailed) come back empty: counts only. Each entry of policies[] carries the counts acknowledged, required, exemptCount, pct, outstandingCount, overdueCount, untrailedCount, dueCount and rolloutCount, and the name lists outstanding, overdue, untrailed and renewing. policies[] is not paged, but each name list holds at most its first 100 email addresses: read outstandingCount, overdueCount and untrailedCount for the full numbers.",
   "responses": [
    {
     "status": 200,
     "text": "{ total, policies[] }. Without manage_compliance the per-policy name lists (outstanding, overdue, renewing, untrailed) come back empty: counts only. Each entry of policies[] carries the counts acknowledged, required, exemptCount, pct, outstandingCount, overdueCount, untrailedCount, dueCount and rolloutCount, and the name lists outstanding, overdue, untrailed and renewing. policies[] is not paged, but each name list holds at most its first 100 email addresses: read outstandingCount, overdueCount and untrailedCount for the full numbers."
    }
   ],
   "errors": [],
   "guard": null
  },
  {
   "id": "GET /compliance/reports/compliance/evidence-map",
   "group": "policy-signing",
   "method": "GET",
   "path": "/compliance/reports/compliance/evidence-map",
   "purpose": "Which controls training completions and policy signatures feed. A Compliance Hub route: it needs the Compliance Hub on your plan (Advanced).",
   "auth": {
    "text": "Session + view_reporting | manage_compliance | manage_employee_training (licensed)",
    "kind": "session",
    "permissions": [
     "manage_compliance",
     "manage_employee_training",
     "view_reporting"
    ],
    "stepUp": null,
    "administratorRole": false,
    "licensed": true
   },
   "pathParameters": [],
   "paging": [],
   "query": [],
   "request": null,
   "response": "200 { topics[], training[], policySigning[] }; each control as { id, title, frameworks[], topics[] }. Not paged.",
   "responses": [
    {
     "status": 200,
     "text": "{ topics[], training[], policySigning[] }; each control as { id, title, frameworks[], topics[] }. Not paged."
    }
   ],
   "errors": [],
   "guard": null
  }
 ],
 "objects": [
  {
   "id": "Error",
   "name": "Error",
   "group": "directory",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "error",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "message",
     "type": "string",
     "flags": "",
     "note": ""
    }
   ]
  },
  {
   "id": "OkResponse",
   "name": "OkResponse",
   "group": "directory",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "ok",
     "type": "boolean",
     "flags": "",
     "note": ""
    }
   ]
  },
  {
   "id": "User",
   "name": "User",
   "group": "directory",
   "kind": "response",
   "note": "A directory user. Credential material (password hash, MFA secrets, recovery codes, invite token) is never returned.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "firstName",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "lastName",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "email",
     "type": "string (email)",
     "flags": "",
     "note": ""
    },
    {
     "name": "sAMAccountName",
     "type": "string",
     "flags": "nullable",
     "note": "Login / directory account name (unique)."
    },
    {
     "name": "position",
     "type": "string",
     "flags": "nullable",
     "note": "Job title."
    },
    {
     "name": "department",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "status",
     "type": "string",
     "flags": "",
     "note": "e.g. active, inactive, disabled, pending, offboarded, deleted"
    },
    {
     "name": "isPrimaryAdmin",
     "type": "boolean",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "source",
     "type": "string",
     "flags": "",
     "note": "local or the directory it syncs from"
    },
    {
     "name": "sourceProviderId",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "photoUrl",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "managerId",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "externalId",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "employeeId",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "phoneNumber",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "personalEmail",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "attributes",
     "type": "object",
     "flags": "",
     "note": "Custom directory attributes."
    },
    {
     "name": "mfaEnabled",
     "type": "boolean",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "mfaEnabledAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "mustChangePassword",
     "type": "boolean",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "passwordLastChanged",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "personaId",
     "type": "integer",
     "flags": "nullable",
     "note": "Persona (role template / bill of materials)."
    },
    {
     "name": "lastActiveAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "terminationDate",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": "Scheduled last working day; null cancels a scheduled offboarding."
    },
    {
     "name": "inviteExpiresAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "invitedAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "failedLoginAttempts",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "lockoutUntil",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "sessionsInvalidatedAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "Groups",
     "type": "GroupSummary[]",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "Roles",
     "type": "RoleSummary[]",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "Manager",
     "type": "UserRef",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "updatedAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    }
   ]
  },
  {
   "id": "UserCard",
   "name": "UserCard",
   "group": "directory",
   "kind": "response",
   "note": "Minimal picker view of a user (returned to callers without directory permissions).",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "firstName",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "lastName",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "email",
     "type": "string (email)",
     "flags": "",
     "note": ""
    },
    {
     "name": "position",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "department",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "photoUrl",
     "type": "string",
     "flags": "nullable",
     "note": ""
    }
   ]
  },
  {
   "id": "UserRef",
   "name": "UserRef",
   "group": "directory",
   "kind": "response",
   "note": "A labelled user reference.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "firstName",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "lastName",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "email",
     "type": "string (email)",
     "flags": "",
     "note": ""
    }
   ]
  },
  {
   "id": "UserPage",
   "name": "UserPage",
   "group": "directory",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "count",
     "type": "integer",
     "flags": "",
     "note": "Total matching users."
    },
    {
     "name": "rows",
     "type": "(User | UserCard)[]",
     "flags": "",
     "note": ""
    }
   ]
  },
  {
   "id": "UserCreate",
   "name": "UserCreate",
   "group": "directory",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "firstName",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "lastName",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "email",
     "type": "string (email)",
     "flags": "required",
     "note": ""
    },
    {
     "name": "sAMAccountName",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "position",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "department",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "managerId",
     "type": "integer",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "personaId",
     "type": "integer",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "status",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "source",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "employeeId",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "phoneNumber",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "attributes",
     "type": "object",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "roleIds",
     "type": "integer[]",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "groupIds",
     "type": "integer[]",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "sendInvite",
     "type": "boolean",
     "flags": "optional",
     "note": "Email a set-password link (recommended for API use)."
    }
   ]
  },
  {
   "id": "UserUpdate",
   "name": "UserUpdate",
   "group": "directory",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "firstName",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "lastName",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "email",
     "type": "string (email)",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "sAMAccountName",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "position",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "department",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "managerId",
     "type": "integer",
     "flags": "optional, null clears it",
     "note": ""
    },
    {
     "name": "personaId",
     "type": "integer",
     "flags": "optional, null clears it",
     "note": ""
    },
    {
     "name": "photoUrl",
     "type": "string",
     "flags": "optional, null clears it",
     "note": ""
    },
    {
     "name": "employeeId",
     "type": "string",
     "flags": "optional, null clears it",
     "note": ""
    },
    {
     "name": "phoneNumber",
     "type": "string",
     "flags": "optional, null clears it",
     "note": ""
    },
    {
     "name": "terminationDate",
     "type": "string (date)",
     "flags": "optional, null clears it",
     "note": ""
    },
    {
     "name": "status",
     "type": "string",
     "flags": "optional",
     "note": "Non-terminal statuses only (use Offboard to terminate)."
    },
    {
     "name": "roleIds",
     "type": "integer[]",
     "flags": "optional",
     "note": "Replaces the user's roles (requires manage_roles)."
    },
    {
     "name": "groupIds",
     "type": "integer[]",
     "flags": "optional",
     "note": "Replaces the user's groups (requires manage_groups)."
    }
   ]
  },
  {
   "id": "UserBulkUpdate",
   "name": "UserBulkUpdate",
   "group": "directory",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "userIds",
     "type": "integer[]",
     "flags": "required",
     "note": ""
    },
    {
     "name": "action",
     "type": "string: setManager | setDepartment | setPersona | setStatus | addGroup | removeGroup | addRole | removeRole",
     "flags": "required",
     "note": ""
    },
    {
     "name": "value",
     "type": "any",
     "flags": "optional",
     "note": "Action argument (manager id, department, persona id, status in active|inactive|disabled, group/role id)."
    }
   ]
  },
  {
   "id": "Group",
   "name": "Group",
   "group": "directory",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "name",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "description",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "source",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "externalId",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "sourceProviderId",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "requiresMfa",
     "type": "boolean",
     "flags": "",
     "note": "Legacy mirror of mfaPolicy = require."
    },
    {
     "name": "mfaPolicy",
     "type": "string: inherit | require | exempt",
     "flags": "",
     "note": ""
    },
    {
     "name": "mfaExemptUntil",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "mfaExemptReason",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "Users",
     "type": "object[]",
     "flags": "read-only",
     "note": "Member ids only."
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "updatedAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    }
   ]
  },
  {
   "id": "GroupSummary",
   "name": "GroupSummary",
   "group": "directory",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "name",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "description",
     "type": "string",
     "flags": "nullable",
     "note": ""
    }
   ]
  },
  {
   "id": "GroupWrite",
   "name": "GroupWrite",
   "group": "directory",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "name",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "description",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "userIds",
     "type": "integer[]",
     "flags": "optional",
     "note": "Replaces the membership."
    },
    {
     "name": "mfaPolicy",
     "type": "string: inherit | require | exempt",
     "flags": "optional",
     "note": "Requires manage_security."
    },
    {
     "name": "mfaExemptUntil",
     "type": "string (date-time)",
     "flags": "optional, null clears it",
     "note": "Requires manage_security."
    },
    {
     "name": "mfaExemptReason",
     "type": "string",
     "flags": "optional",
     "note": "Requires manage_security."
    }
   ]
  },
  {
   "id": "Role",
   "name": "Role",
   "group": "directory",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "name",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "description",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "Permissions",
     "type": "Permission[]",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "Users",
     "type": "object[]",
     "flags": "read-only",
     "note": "Member ids only."
    },
    {
     "name": "Groups",
     "type": "object[]",
     "flags": "read-only",
     "note": "Groups the role is dynamically bound to."
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "updatedAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    }
   ]
  },
  {
   "id": "RoleSummary",
   "name": "RoleSummary",
   "group": "directory",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "name",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "description",
     "type": "string",
     "flags": "nullable",
     "note": ""
    }
   ]
  },
  {
   "id": "RoleWrite",
   "name": "RoleWrite",
   "group": "directory",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "name",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "description",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "userIds",
     "type": "integer[]",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "permissionIds",
     "type": "integer[]",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "groupIds",
     "type": "integer[]",
     "flags": "optional",
     "note": ""
    }
   ]
  },
  {
   "id": "Permission",
   "name": "Permission",
   "group": "directory",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "name",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "module",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "description",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "Roles",
     "type": "RoleSummary[]",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "Users",
     "type": "object[]",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "updatedAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    }
   ]
  },
  {
   "id": "WorkflowNode",
   "name": "WorkflowNode",
   "group": "operations",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "type",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "data",
     "type": "object",
     "flags": "",
     "note": ""
    }
   ]
  },
  {
   "id": "Workflow",
   "name": "Workflow",
   "group": "operations",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "name",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "version",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "isCurrent",
     "type": "boolean",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "type",
     "type": "string",
     "flags": "",
     "note": "onboarding | offboarding | custom type"
    },
    {
     "name": "description",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "createdBy",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "approvedBy",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "approvedAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "isActive",
     "type": "boolean",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "isDefault",
     "type": "boolean",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "automationMode",
     "type": "string: manual | provisioning | auto",
     "flags": "",
     "note": ""
    },
    {
     "name": "status",
     "type": "string",
     "flags": "",
     "note": "draft | pending | approved | rejected"
    },
    {
     "name": "rejectionReason",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "simulationRun",
     "type": "boolean",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "personaOverrides",
     "type": "object",
     "flags": "",
     "note": "Per-persona exclusions: { \"<personaId>\": { excluded: [...] } }"
    },
    {
     "name": "nodes",
     "type": "WorkflowNode[]",
     "flags": "",
     "note": ""
    },
    {
     "name": "edges",
     "type": "object[]",
     "flags": "",
     "note": ""
    },
    {
     "name": "purgeable",
     "type": "boolean",
     "flags": "read-only",
     "note": "Whether this never-used draft may be permanently deleted."
    },
    {
     "name": "deletedAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "updatedAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    }
   ]
  },
  {
   "id": "WorkflowWrite",
   "name": "WorkflowWrite",
   "group": "operations",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "name",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "type",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "description",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "status",
     "type": "string: draft | pending",
     "flags": "optional",
     "note": "Submit for approval with pending."
    },
    {
     "name": "automationMode",
     "type": "string: manual | provisioning | auto",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "personaOverrides",
     "type": "object",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "nodes",
     "type": "WorkflowNode[]",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "edges",
     "type": "object[]",
     "flags": "optional",
     "note": ""
    }
   ]
  },
  {
   "id": "WorkflowRun",
   "name": "WorkflowRun",
   "group": "operations",
   "kind": "response",
   "note": "A run of a workflow (a \"task\").",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "workflowId",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "status",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "title",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "description",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "currentStep",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "assignedTo",
     "type": "string",
     "flags": "nullable",
     "note": "Queue that owns the current step."
    },
    {
     "name": "assignedToUserId",
     "type": "integer",
     "flags": "nullable",
     "note": "The one person who owns the step, when person-owned."
    },
    {
     "name": "data",
     "type": "object",
     "flags": "",
     "note": "Run payload (temporary credentials are never included)."
    },
    {
     "name": "createdBy",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "userId",
     "type": "integer",
     "flags": "nullable",
     "note": "The subject user of the run."
    },
    {
     "name": "sourceCandidateId",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "startDate",
     "type": "string (date)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "dueDate",
     "type": "string (date)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "nodes",
     "type": "WorkflowNode[]",
     "flags": "nullable, read-only",
     "note": "Built server-side. Lists carry id + label/type only."
    },
    {
     "name": "edges",
     "type": "object[]",
     "flags": "nullable, read-only",
     "note": "Omitted in lists."
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "updatedAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    }
   ]
  },
  {
   "id": "WorkflowRunPage",
   "name": "WorkflowRunPage",
   "group": "operations",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "rows",
     "type": "WorkflowRun[]",
     "flags": "",
     "note": ""
    },
    {
     "name": "total",
     "type": "integer",
     "flags": "",
     "note": ""
    }
   ]
  },
  {
   "id": "WorkflowRunCreate",
   "name": "WorkflowRunCreate",
   "group": "operations",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "workflowId",
     "type": "integer",
     "flags": "required",
     "note": ""
    },
    {
     "name": "title",
     "type": "string",
     "flags": "required",
     "note": ""
    },
    {
     "name": "description",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "status",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "assignedTo",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "currentStep",
     "type": "integer",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "data",
     "type": "object",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "userId",
     "type": "integer",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "sourceCandidateId",
     "type": "integer",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "startDate",
     "type": "string (date)",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "dueDate",
     "type": "string (date)",
     "flags": "optional",
     "note": ""
    }
   ]
  },
  {
   "id": "WorkflowRunUpdate",
   "name": "WorkflowRunUpdate",
   "group": "operations",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "status",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "assignedTo",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "data",
     "type": "object",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "currentStep",
     "type": "integer",
     "flags": "optional",
     "note": ""
    }
   ]
  },
  {
   "id": "Device",
   "name": "Device",
   "group": "operations",
   "kind": "response",
   "note": "A device (read-only view). The device's agent key is never returned.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "name",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "type",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "serialNumber",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "status",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "manufacturer",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "userId",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "assetTag",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "department",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "location",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "costCenter",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "customFields",
     "type": "object",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "platform",
     "type": "string",
     "flags": "nullable",
     "note": "macOS | linux | windows"
    },
    {
     "name": "mdmUdid",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "pendingCommand",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "reEnrollAllowed",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "enrollmentApproved",
     "type": "boolean",
     "flags": "nullable",
     "note": "false = pending approval; null = grandfathered/trusted."
    },
    {
     "name": "retiredAt",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "retiredReason",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "hostname",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "osVersion",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "osBuild",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "architecture",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "chip",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "modelName",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "cpuCores",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "memoryBytes",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "diskTotalBytes",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "diskFreeBytes",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "osUpdateAvailable",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "agentVersion",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "agentVersionPin",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "loggedInUser",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "enrolledAt",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "lastCheckinAt",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "lastCheckinIp",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "inventory",
     "type": "object",
     "flags": "nullable",
     "note": "Raw agent/MDM inventory."
    },
    {
     "name": "Owner",
     "type": "UserRef",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "updatedAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    }
   ]
  },
  {
   "id": "DeviceFleetPage",
   "name": "DeviceFleetPage",
   "group": "operations",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "items",
     "type": "object[]",
     "flags": "",
     "note": ""
    },
    {
     "name": "total",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "page",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "pageSize",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "kpis",
     "type": "object",
     "flags": "",
     "note": ""
    },
    {
     "name": "staleDays",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "truncated",
     "type": "boolean",
     "flags": "",
     "note": ""
    }
   ]
  },
  {
   "id": "AuditLog",
   "name": "AuditLog",
   "group": "operations",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "level",
     "type": "string",
     "flags": "",
     "note": "info | warning | error | critical"
    },
    {
     "name": "action",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "details",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "payload",
     "type": "object",
     "flags": "nullable",
     "note": "Structured context; origin service in payload.source."
    },
    {
     "name": "userEmail",
     "type": "string",
     "flags": "nullable",
     "note": "Actor."
    },
    {
     "name": "chainHash",
     "type": "string",
     "flags": "nullable",
     "note": "SHA-256 of this row chained to the previous one."
    },
    {
     "name": "prevHash",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "chainSeq",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "updatedAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    }
   ]
  },
  {
   "id": "AuditLogPage",
   "name": "AuditLogPage",
   "group": "operations",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "total",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "logs",
     "type": "AuditLog[]",
     "flags": "",
     "note": ""
    },
    {
     "name": "counts",
     "type": "object",
     "flags": "",
     "note": ""
    },
    {
     "name": "windowDays",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "retentionDays",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "limit",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "page",
     "type": "integer",
     "flags": "",
     "note": ""
    }
   ]
  },
  {
   "id": "AuditLogExport",
   "name": "AuditLogExport",
   "group": "operations",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "exportedAt",
     "type": "string (date-time)",
     "flags": "",
     "note": ""
    },
    {
     "name": "count",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "truncated",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "windowDays",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "logs",
     "type": "AuditLog[]",
     "flags": "",
     "note": ""
    }
   ]
  },
  {
   "id": "AuditChainVerification",
   "name": "AuditChainVerification",
   "group": "operations",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "ok",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "chained",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "unchained",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "firstBrokenId",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "brokenType",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "anchor",
     "type": "object",
     "flags": "",
     "note": ""
    }
   ]
  },
  {
   "id": "ApiToken",
   "name": "ApiToken",
   "group": "operations",
   "kind": "response",
   "note": "An API token's metadata. The token value itself is only ever returned once, at creation; its hash is never returned.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "userId",
     "type": "integer",
     "flags": "",
     "note": "Owner."
    },
    {
     "name": "owner",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "name",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "tokenPrefix",
     "type": "string",
     "flags": "",
     "note": "Non-secret leading characters (e.g. zx_AbC123), to recognise a token."
    },
    {
     "name": "scopes",
     "type": "string[]",
     "flags": "",
     "note": "Permissions granted at creation. At use they are intersected with the owner's current permissions."
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "expiresAt",
     "type": "string (date-time)",
     "flags": "",
     "note": ""
    },
    {
     "name": "lastUsedAt",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "lastUsedIp",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "revokedAt",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "revokedBy",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "revokeReason",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "status",
     "type": "string: active | expired | revoked",
     "flags": "",
     "note": ""
    }
   ]
  },
  {
   "id": "ApiTokenScopeCatalog",
   "name": "ApiTokenScopeCatalog",
   "group": "operations",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "scopes",
     "type": "object[]",
     "flags": "",
     "note": ""
    },
    {
     "name": "defaultExpiryDays",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "maxExpiryDays",
     "type": "integer",
     "flags": "",
     "note": ""
    }
   ]
  },
  {
   "id": "ApiTokenCreate",
   "name": "ApiTokenCreate",
   "group": "operations",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "name",
     "type": "string",
     "flags": "required",
     "note": ""
    },
    {
     "name": "expiresInDays",
     "type": "integer",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "scopes",
     "type": "string[]",
     "flags": "required",
     "note": "Subset of your permissions."
    }
   ]
  },
  {
   "id": "ApiTokenCreated",
   "name": "ApiTokenCreated",
   "group": "operations",
   "kind": "response",
   "note": null,
   "extends": "ApiToken",
   "fields": [
    {
     "name": "token",
     "type": "string",
     "flags": "",
     "note": "The plaintext zx_… token. Shown once."
    }
   ]
  },
  {
   "id": "ReportSchedule",
   "name": "ReportSchedule",
   "group": "governance",
   "kind": "response",
   "note": "Answered by the schedule list, create and update routes. The webhook address is never returned in full.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "name",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "kind",
     "type": "string",
     "flags": "",
     "note": "compliance | overview | query | lifecycle | device-posture | budget | crosswalk | type2"
    },
    {
     "name": "framework",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "queries",
     "type": "string[]",
     "flags": "",
     "note": "Data set names. Empty array when none."
    },
    {
     "name": "filters",
     "type": "object",
     "flags": "",
     "note": "Filter values keyed by filter name. Empty object when none."
    },
    {
     "name": "window",
     "type": "object",
     "flags": "nullable",
     "note": "{ from, to }"
    },
    {
     "name": "sections",
     "type": "string[]",
     "flags": "nullable",
     "note": "summary | controls | tables"
    },
    {
     "name": "columns",
     "type": "object",
     "flags": "nullable",
     "note": "Query reports only."
    },
    {
     "name": "groupBy",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "variant",
     "type": "string",
     "flags": "nullable",
     "note": "Budget reports only."
    },
    {
     "name": "lifecycleType",
     "type": "string",
     "flags": "nullable",
     "note": "leaver | joiner"
    },
    {
     "name": "formats",
     "type": "string[]",
     "flags": "",
     "note": "pdf | csv | xlsx | pptx"
    },
    {
     "name": "recipients",
     "type": "string[]",
     "flags": "",
     "note": "Email addresses."
    },
    {
     "name": "everyHours",
     "type": "integer",
     "flags": "",
     "note": "1 to 2160"
    },
    {
     "name": "enabled",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "webhookUrl",
     "type": "string",
     "flags": "nullable, read-only",
     "note": "Masked: the host and the last four characters."
    },
    {
     "name": "webhookConfigured",
     "type": "boolean",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "webhookSigned",
     "type": "boolean",
     "flags": "read-only",
     "note": "True when a signing secret is stored."
    },
    {
     "name": "webhookSecretRotatedAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "webhookSigningSecret",
     "type": "string",
     "flags": "read-only",
     "note": "Present only on the reply that issued it."
    },
    {
     "name": "lastRunAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "lastStatus",
     "type": "string",
     "flags": "nullable, read-only",
     "note": "The status of the last run."
    },
    {
     "name": "failureAlertedAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "owner",
     "type": "string",
     "flags": "nullable, read-only",
     "note": "updatedBy, or createdBy when it was never edited."
    },
    {
     "name": "createdBy",
     "type": "string",
     "flags": "nullable, read-only",
     "note": "Email address."
    },
    {
     "name": "updatedBy",
     "type": "string",
     "flags": "nullable, read-only",
     "note": "Email address."
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "updatedAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    }
   ]
  },
  {
   "id": "ReportRun",
   "name": "ReportRun",
   "group": "governance",
   "kind": "response",
   "note": "One row of GET /compliance/reports/runs, in both the run history and the evidence vault (?vault=1). File bytes are never in the row.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "scheduleId",
     "type": "integer",
     "flags": "nullable",
     "note": "null for a run that no schedule started."
    },
    {
     "name": "scheduleName",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "kind",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "framework",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "trigger",
     "type": "string",
     "flags": "",
     "note": "scheduled | manual"
    },
    {
     "name": "triggeredBy",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "status",
     "type": "string",
     "flags": "",
     "note": "running | ok | failed | interrupted"
    },
    {
     "name": "error",
     "type": "string",
     "flags": "nullable",
     "note": "Why the run failed."
    },
    {
     "name": "readinessScore",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "passCount",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "failCount",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "manualCount",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "totalControls",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "naCount",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "applicable",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "delivered",
     "type": "boolean",
     "flags": "",
     "note": "Whether the email was sent."
    },
    {
     "name": "deliveryError",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "deliveryAttempts",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "deliveryMode",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "nextRetryAt",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "claimedAt",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "durationMs",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "observationFrom",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "observationTo",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "artifactHash",
     "type": "string",
     "flags": "nullable",
     "note": "SHA-256 of the stored PDF."
    },
    {
     "name": "csvHash",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "xlsxHash",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "pdfSignature",
     "type": "object",
     "flags": "nullable",
     "note": "Carries status: signed | unsigned."
    },
    {
     "name": "hasPdf",
     "type": "boolean",
     "flags": "read-only",
     "note": "A PDF is stored for this run."
    },
    {
     "name": "hasCsv",
     "type": "boolean",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "hasXlsx",
     "type": "boolean",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "sealed",
     "type": "boolean",
     "flags": "read-only",
     "note": "The seal value itself is never returned."
    },
    {
     "name": "sealMissing",
     "type": "boolean",
     "flags": "read-only",
     "note": "A stored file that should carry a seal has none."
    },
    {
     "name": "canonical",
     "type": "boolean",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "redacted",
     "type": "object",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "rowCounts",
     "type": "object",
     "flags": "",
     "note": "Empty object when the run stored no row counts."
    },
    {
     "name": "retainUntil",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "legalHold",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "verifyUrl",
     "type": "string",
     "flags": "nullable, read-only",
     "note": "null when the run stored no file hash."
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    }
   ]
  },
  {
   "id": "SavedReport",
   "name": "SavedReport",
   "group": "governance",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "name",
     "type": "string",
     "flags": "",
     "note": "Up to 120 characters."
    },
    {
     "name": "kind",
     "type": "string",
     "flags": "",
     "note": "compliance | overview | query | lifecycle | device-posture | budget | crosswalk | type2"
    },
    {
     "name": "framework",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "queries",
     "type": "string[]",
     "flags": "",
     "note": "Data set names. Empty array when none."
    },
    {
     "name": "filters",
     "type": "object",
     "flags": "",
     "note": "Filter values keyed by filter name. Empty object when none."
    },
    {
     "name": "window",
     "type": "object",
     "flags": "nullable",
     "note": "{ from, to }"
    },
    {
     "name": "sections",
     "type": "string[]",
     "flags": "nullable",
     "note": "summary | controls | tables"
    },
    {
     "name": "columns",
     "type": "object",
     "flags": "nullable",
     "note": "Query reports only."
    },
    {
     "name": "groupBy",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "variant",
     "type": "string",
     "flags": "nullable",
     "note": "Budget reports only."
    },
    {
     "name": "lifecycleType",
     "type": "string",
     "flags": "nullable",
     "note": "leaver | joiner"
    },
    {
     "name": "format",
     "type": "string",
     "flags": "",
     "note": "table | pdf"
    },
    {
     "name": "createdBy",
     "type": "string",
     "flags": "nullable, read-only",
     "note": "Email address."
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "updatedAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    }
   ]
  },
  {
   "id": "ReportShareLink",
   "name": "ReportShareLink",
   "group": "governance",
   "kind": "response",
   "note": "One link to one sealed file. The create reply adds token (string), once; lists carry tokenHint only.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "runId",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "format",
     "type": "string",
     "flags": "",
     "note": "pdf | csv"
    },
    {
     "name": "expiresAt",
     "type": "string (date-time)",
     "flags": "",
     "note": ""
    },
    {
     "name": "revoked",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "viewCount",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "lastViewedAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "createdBy",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "tokenHint",
     "type": "string",
     "flags": "nullable, read-only",
     "note": "The last few characters of the token."
    },
    {
     "name": "recipientEmail",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "recipientLabel",
     "type": "string",
     "flags": "nullable",
     "note": "Up to 80 characters."
    }
   ]
  },
  {
   "id": "AuditorShare",
   "name": "AuditorShare",
   "group": "governance",
   "kind": "response",
   "note": "An auditor workspace link. The create reply adds token (string), once.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "framework",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "label",
     "type": "string",
     "flags": "nullable",
     "note": "Up to 80 characters."
    },
    {
     "name": "expiresAt",
     "type": "string (date-time)",
     "flags": "",
     "note": ""
    },
    {
     "name": "revoked",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "viewCount",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "lastViewedAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "createdBy",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "tokenHint",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    }
   ]
  },
  {
   "id": "AuditorRequest",
   "name": "AuditorRequest",
   "group": "governance",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "string",
     "flags": "read-only",
     "note": "16 hex characters."
    },
    {
     "name": "shareId",
     "type": "integer",
     "flags": "read-only",
     "note": "The auditor link it was raised on."
    },
    {
     "name": "framework",
     "type": "string",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "controlId",
     "type": "string",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "note",
     "type": "string",
     "flags": "read-only",
     "note": "Up to 500 characters."
    },
    {
     "name": "status",
     "type": "string",
     "flags": "read-only",
     "note": "open | resolved"
    },
    {
     "name": "response",
     "type": "string",
     "flags": "nullable",
     "note": "Up to 500 characters."
    },
    {
     "name": "respondedBy",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "respondedAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    }
   ]
  },
  {
   "id": "TenantExportJob",
   "name": "TenantExportJob",
   "group": "governance",
   "kind": "response",
   "note": "One company data export. Answered by the Company Data Export routes. The encryption parameters and the download link are never part of it.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "status",
     "type": "string",
     "flags": "read-only",
     "note": "queued | running | ready | failed | expired"
    },
    {
     "name": "progress",
     "type": "integer",
     "flags": "read-only",
     "note": "0 to 100."
    },
    {
     "name": "stage",
     "type": "string",
     "flags": "nullable, read-only",
     "note": "The current step, in words."
    },
    {
     "name": "requestedBy",
     "type": "string",
     "flags": "nullable, read-only",
     "note": "The email or username of the administrator who started it."
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "startedAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "completedAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "expiresAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": "When the file is deleted: 24 hours after it became ready."
    },
    {
     "name": "expiredAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": "When the file was deleted."
    },
    {
     "name": "sizeBytes",
     "type": "integer",
     "flags": "nullable, read-only",
     "note": "The size of the ZIP."
    },
    {
     "name": "zipSha256",
     "type": "string",
     "flags": "nullable, read-only",
     "note": "The SHA-256 the downloaded ZIP must have."
    },
    {
     "name": "summary",
     "type": "object",
     "flags": "nullable, read-only",
     "note": "{ totals, datasets: [{ name, rows, incomplete }], notes[] } once ready."
    },
    {
     "name": "error",
     "type": "string",
     "flags": "nullable, read-only",
     "note": "Why a failed job failed."
    },
    {
     "name": "downloadCount",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "lastDownloadedAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": "When the current link was used."
    }
   ]
  },
  {
   "id": "EnvironmentProfile",
   "name": "EnvironmentProfile",
   "group": "governance",
   "kind": "response",
   "note": "The systems the organisation has declared it uses. Answered by the environment profile routes.",
   "extends": null,
   "fields": [
    {
     "name": "selected",
     "type": "string[]",
     "flags": "",
     "note": "Identifiers of the declared systems. Empty array when none."
    },
    {
     "name": "custom",
     "type": "object[]",
     "flags": "",
     "note": "Each { id: string, name: string, category: string }. Empty array when none."
    },
    {
     "name": "updatedAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "updatedBy",
     "type": "string",
     "flags": "nullable, read-only",
     "note": "The email of whoever saved it last."
    }
   ]
  },
  {
   "id": "TypeTwoReport",
   "name": "TypeTwoReport",
   "group": "governance",
   "kind": "response",
   "note": "How each control of one framework held over a period. Answered by the type-two route.",
   "extends": null,
   "fields": [
    {
     "name": "framework",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "windowDays",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "from",
     "type": "string (date)",
     "flags": "",
     "note": ""
    },
    {
     "name": "to",
     "type": "string (date)",
     "flags": "",
     "note": ""
    },
    {
     "name": "snapshotCount",
     "type": "integer",
     "flags": "",
     "note": "Automatic snapshots found in the period."
    },
    {
     "name": "firstAt",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": "The first snapshot in the period."
    },
    {
     "name": "lastAt",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": "The last snapshot in the period."
    },
    {
     "name": "heldThroughout",
     "type": "integer",
     "flags": "",
     "note": "Count of rows with that verdict."
    },
    {
     "name": "hadGaps",
     "type": "integer",
     "flags": "",
     "note": "Count of rows with the verdict Gaps in period."
    },
    {
     "name": "noData",
     "type": "integer",
     "flags": "",
     "note": "Count of rows with the verdict No period data."
    },
    {
     "name": "incompleteCoverage",
     "type": "integer",
     "flags": "",
     "note": "Count of rows with that verdict."
    },
    {
     "name": "rows",
     "type": "object[]",
     "flags": "",
     "note": "Each { id: string, title: string, clauseRef: string, observed: integer, passing: integer, gaps: integer, verdict: string }. verdict is Held throughout | Gaps in period | Incomplete coverage | No period data | Not applicable."
    }
   ]
  },
  {
   "id": "ReportRunHold",
   "name": "ReportRunHold",
   "group": "governance",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "legalHold",
     "type": "boolean",
     "flags": "optional",
     "note": "The state you want. Anything that is not true or false answers 400. Left out, the hold is flipped."
    }
   ]
  },
  {
   "id": "ReportShareCreate",
   "name": "ReportShareCreate",
   "group": "governance",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "format",
     "type": "string: pdf | csv",
     "flags": "optional",
     "note": "Default pdf."
    },
    {
     "name": "confirmCsv",
     "type": "boolean",
     "flags": "optional",
     "note": "Must be true to share the CSV; otherwise 400 with code CSV_CONFIRM_REQUIRED."
    },
    {
     "name": "ttlHours",
     "type": "integer",
     "flags": "optional",
     "note": "Default 168. 1 to 2160."
    },
    {
     "name": "recipientEmail",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "recipientLabel",
     "type": "string",
     "flags": "optional",
     "note": "Up to 80 characters."
    }
   ]
  },
  {
   "id": "AuditorEvidenceRequestCreate",
   "name": "AuditorEvidenceRequestCreate",
   "group": "governance",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "controlId",
     "type": "string",
     "flags": "required",
     "note": "A control of the framework the link was made for."
    },
    {
     "name": "note",
     "type": "string",
     "flags": "required",
     "note": "Up to 500 characters."
    }
   ]
  },
  {
   "id": "EvidenceItemCreate",
   "name": "EvidenceItemCreate",
   "group": "governance",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "controlId",
     "type": "string",
     "flags": "required",
     "note": ""
    },
    {
     "name": "url",
     "type": "string",
     "flags": "required",
     "note": "A link must be http or https."
    },
    {
     "name": "kind",
     "type": "string",
     "flags": "optional",
     "note": "file for a file already uploaded to this server. Anything else is a link."
    },
    {
     "name": "label",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "framework",
     "type": "string",
     "flags": "optional",
     "note": ""
    }
   ]
  },
  {
   "id": "SecurityExceptionRequest",
   "name": "SecurityExceptionRequest",
   "group": "governance",
   "kind": "request",
   "note": "Asks to accept the risk of one finding until a date.",
   "extends": null,
   "fields": [
    {
     "name": "justification",
     "type": "string",
     "flags": "required",
     "note": "Empty, or only spaces, answers 400 with code JUSTIFICATION_REQUIRED."
    },
    {
     "name": "acceptedUntil",
     "type": "string (date-time)",
     "flags": "required",
     "note": "When the acceptance ends. Missing, not a date, or not in the future answers 400 with code EXPIRY_REQUIRED."
    }
   ]
  },
  {
   "id": "SecurityExceptionRevoke",
   "name": "SecurityExceptionRevoke",
   "group": "governance",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "note",
     "type": "string",
     "flags": "optional",
     "note": ""
    }
   ]
  },
  {
   "id": "FindingsArchive",
   "name": "FindingsArchive",
   "group": "governance",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "scope",
     "type": "string: all | remote | local",
     "flags": "optional",
     "note": "Default all. Also read from ?scope when the body does not carry it. Any other value answers 400."
    }
   ]
  },
  {
   "id": "AssetCriticalityUpdate",
   "name": "AssetCriticalityUpdate",
   "group": "governance",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "criticality",
     "type": "string",
     "flags": "required",
     "note": "low | medium | high | crit. critical is accepted and stored as crit."
    }
   ]
  },
  {
   "id": "RemediationHandoff",
   "name": "RemediationHandoff",
   "group": "governance",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "handoffRef",
     "type": "string",
     "flags": "optional",
     "note": "A reference to where the fix is tracked. Cut to 255 characters."
    }
   ]
  },
  {
   "id": "TicketingUpdate",
   "name": "TicketingUpdate",
   "group": "governance",
   "kind": "request",
   "note": "Both values are stored on every call: a key left out is stored as empty.",
   "extends": null,
   "fields": [
    {
     "name": "providerId",
     "type": "string",
     "flags": "optional",
     "note": "The connected ticketing system that receives the tickets. Empty switches automatic ticketing off."
    },
    {
     "name": "projectKey",
     "type": "string",
     "flags": "optional",
     "note": ""
    }
   ]
  },
  {
   "id": "Policy",
   "name": "Policy",
   "group": "registers",
   "kind": "response",
   "note": "One entry of the policy register. No reply carries the policy text (body): the list, the create reply and the PUT reply all leave it out and send hasDocument instead. A PUT reply can add resetToDraft, changedFields or signingChanged when the edit returned the policy to draft or changed its signing rules. Entries are returned as stored: a reply can carry further fields, and an older entry can lack one listed here.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "string",
     "flags": "read-only",
     "note": "16 hex characters."
    },
    {
     "name": "name",
     "type": "string",
     "flags": "",
     "note": "Up to 140 characters."
    },
    {
     "name": "version",
     "type": "string",
     "flags": "",
     "note": "Up to 20 characters; '1.0' when not sent."
    },
    {
     "name": "owner",
     "type": "string",
     "flags": "",
     "note": "Empty string when not set."
    },
    {
     "name": "link",
     "type": "string",
     "flags": "",
     "note": "Empty string when not set."
    },
    {
     "name": "description",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "frameworks",
     "type": "string[]",
     "flags": "",
     "note": "Up to 8."
    },
    {
     "name": "status",
     "type": "string",
     "flags": "read-only",
     "note": "draft | approved"
    },
    {
     "name": "requireAck",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "audience",
     "type": "object",
     "flags": "nullable",
     "note": "Present only once it was set. { kind: 'everyone' } or { kind: 'rule', departments: string[], positions: string[], locations: string[] }."
    },
    {
     "name": "ackEveryMonths",
     "type": "integer",
     "flags": "",
     "note": "Present only once it was set. 0 to 36."
    },
    {
     "name": "ackDueDays",
     "type": "integer",
     "flags": "",
     "note": "Present only once it was set. 1 to 180."
    },
    {
     "name": "signSections",
     "type": "boolean",
     "flags": "",
     "note": "Present only once it was set."
    },
    {
     "name": "controlIds",
     "type": "string[]",
     "flags": "",
     "note": "Present only once it was set."
    },
    {
     "name": "nextReviewDue",
     "type": "string (date)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "reviewedAt",
     "type": "string (date)",
     "flags": "nullable, read-only",
     "note": "Set by approval, never by a write."
    },
    {
     "name": "reviewedBy",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "draftedBy",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "draftedAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "templateId",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "templateVersion",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "hasDocument",
     "type": "boolean",
     "flags": "read-only",
     "note": "Whether the policy has text."
    },
    {
     "name": "mappedControls",
     "type": "string[]",
     "flags": "read-only",
     "note": "The controls the policy counts towards."
    },
    {
     "name": "ackSettings",
     "type": "object",
     "flags": "read-only",
     "note": "{ everyMonths: integer | null, dueDays: integer, signSections: boolean, audience: object | null }."
    },
    {
     "name": "createdBy",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "updatedAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    }
   ]
  },
  {
   "id": "Risk",
   "name": "Risk",
   "group": "registers",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "string",
     "flags": "read-only",
     "note": "16 hex characters."
    },
    {
     "name": "title",
     "type": "string",
     "flags": "",
     "note": "Up to 140 characters."
    },
    {
     "name": "description",
     "type": "string",
     "flags": "",
     "note": "Up to 1,000 characters."
    },
    {
     "name": "category",
     "type": "string",
     "flags": "",
     "note": "'General' when not sent."
    },
    {
     "name": "likelihood",
     "type": "integer",
     "flags": "",
     "note": "1 to 5"
    },
    {
     "name": "impact",
     "type": "integer",
     "flags": "",
     "note": "1 to 5"
    },
    {
     "name": "score",
     "type": "integer",
     "flags": "read-only",
     "note": "likelihood x impact"
    },
    {
     "name": "status",
     "type": "string",
     "flags": "",
     "note": "open | treating | accepted | closed"
    },
    {
     "name": "treatment",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "owner",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "controlIds",
     "type": "string[]",
     "flags": "",
     "note": "At most 40."
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "updatedAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "createdBy",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    }
   ]
  },
  {
   "id": "RegistersVendor",
   "name": "Vendor",
   "group": "registers",
   "kind": "response",
   "note": "One entry of the vendor register. Entries are returned as stored: a reply can carry further fields, and an older entry can lack one listed here.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "string",
     "flags": "read-only",
     "note": "16 hex characters."
    },
    {
     "name": "name",
     "type": "string",
     "flags": "",
     "note": "Up to 140 characters."
    },
    {
     "name": "category",
     "type": "string",
     "flags": "",
     "note": "'SaaS' when not sent."
    },
    {
     "name": "tier",
     "type": "string",
     "flags": "",
     "note": "low | medium | high | critical"
    },
    {
     "name": "owner",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "notes",
     "type": "string",
     "flags": "",
     "note": "Up to 500 characters."
    },
    {
     "name": "soc2OnFile",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "dataShared",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "reviewStatus",
     "type": "string",
     "flags": "",
     "note": "none | requested | received | reviewed"
    },
    {
     "name": "reviewedAt",
     "type": "string (date)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "reviewer",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "riskDecision",
     "type": "string",
     "flags": "",
     "note": "approved | conditional | rejected, or an empty string before a decision."
    },
    {
     "name": "nextReviewDue",
     "type": "string (date)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "documents",
     "type": "object",
     "flags": "",
     "note": "{ soc2: boolean, iso: boolean, pentest: boolean, dpa: boolean, questionnaire: boolean }"
    },
    {
     "name": "reviews",
     "type": "VendorReview[]",
     "flags": "read-only",
     "note": "At most 20, oldest first."
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "updatedAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "createdBy",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    }
   ]
  },
  {
   "id": "VendorReview",
   "name": "VendorReview",
   "group": "registers",
   "kind": "response",
   "note": "One entry of a vendor’s review history.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "string",
     "flags": "read-only",
     "note": "12 hex characters."
    },
    {
     "name": "kind",
     "type": "string",
     "flags": "read-only",
     "note": "requested | completed"
    },
    {
     "name": "at",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "by",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "reviewer",
     "type": "string",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "dueDate",
     "type": "string (date)",
     "flags": "nullable, read-only",
     "note": "On a requested entry."
    },
    {
     "name": "decision",
     "type": "string",
     "flags": "read-only",
     "note": "On a completed entry: approved | conditional | rejected."
    },
    {
     "name": "riskRating",
     "type": "string",
     "flags": "read-only",
     "note": "On a completed entry: low | medium | high | critical."
    },
    {
     "name": "summary",
     "type": "string",
     "flags": "read-only",
     "note": "On a completed entry. Up to 1,000 characters."
    },
    {
     "name": "documents",
     "type": "object",
     "flags": "read-only",
     "note": "On a completed entry: the same five flags as Vendor.documents."
    },
    {
     "name": "nextReviewDue",
     "type": "string (date)",
     "flags": "nullable, read-only",
     "note": "On a completed entry."
    }
   ]
  },
  {
   "id": "EvidenceItem",
   "name": "EvidenceItem",
   "group": "registers",
   "kind": "response",
   "note": "Evidence a person attached to a control.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "string (uuid)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "controlId",
     "type": "string",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "kind",
     "type": "string",
     "flags": "read-only",
     "note": "file | link"
    },
    {
     "name": "url",
     "type": "string",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "label",
     "type": "string",
     "flags": "read-only",
     "note": "Empty string when none was given."
    },
    {
     "name": "sha256",
     "type": "string",
     "flags": "nullable, read-only",
     "note": "For a file: computed by the server from the stored bytes."
    },
    {
     "name": "addedBy",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "addedAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    }
   ]
  },
  {
   "id": "TrustCenterSettings",
   "name": "TrustCenterSettings",
   "group": "registers",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "published",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "headline",
     "type": "string",
     "flags": "",
     "note": "Up to 140 characters."
    },
    {
     "name": "intro",
     "type": "string",
     "flags": "",
     "note": "Up to 1,000 characters."
    },
    {
     "name": "frameworks",
     "type": "string[]",
     "flags": "",
     "note": "Up to 8 catalogue names."
    },
    {
     "name": "showReadiness",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "practices",
     "type": "object[]",
     "flags": "",
     "note": "Each { title: string, detail: string }. Up to 20."
    },
    {
     "name": "autoPractices",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "contactEmail",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "updatedAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "updatedBy",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    }
   ]
  },
  {
   "id": "BackupPoint",
   "name": "BackupPoint",
   "group": "registers",
   "kind": "response",
   "note": "One row of GET /compliance/reports/backups.",
   "extends": null,
   "fields": [
    {
     "name": "filename",
     "type": "string",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "type",
     "type": "string",
     "flags": "read-only",
     "note": "dump | base"
    },
    {
     "name": "restorable",
     "type": "boolean",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "fetch",
     "type": "boolean",
     "flags": "read-only",
     "note": "Present only on a backup with no local file: true when it is copied back from your storage before it is restored."
    },
    {
     "name": "foundInStorage",
     "type": "boolean",
     "flags": "read-only",
     "note": "Present (true) only on a backup found in your storage that this server holds no record of."
    },
    {
     "name": "sizeBytes",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "modifiedAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "pgVersion",
     "type": "string",
     "flags": "nullable, read-only",
     "note": "The PostgreSQL version the dump was taken from; null when it could not be read."
    },
    {
     "name": "serverPgMajor",
     "type": "integer",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "versionCompatible",
     "type": "boolean",
     "flags": "read-only",
     "note": "true when either version is not known."
    },
    {
     "name": "local",
     "type": "boolean",
     "flags": "read-only",
     "note": "A copy is on this server."
    },
    {
     "name": "offbox",
     "type": "boolean",
     "flags": "read-only",
     "note": "A copy is in your storage."
    },
    {
     "name": "offboxMissing",
     "type": "boolean",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "previousDestination",
     "type": "boolean",
     "flags": "read-only",
     "note": "Not sent on a foundInStorage row."
    },
    {
     "name": "destinationKey",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "offboxInfo",
     "type": "object",
     "flags": "nullable, read-only",
     "note": "{ provider: string, bucket: string | null, region: string | null, key: string | null }"
    }
   ]
  },
  {
   "id": "BackupAuditEntry",
   "name": "BackupAuditEntry",
   "group": "registers",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "action",
     "type": "string",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "details",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "userEmail",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "level",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    }
   ]
  },
  {
   "id": "WalArchiveSummary",
   "name": "WalArchiveSummary",
   "group": "registers",
   "kind": "response",
   "note": "With ?list=1 the reply is { count: integer, segments: WalSegment[] } instead.",
   "extends": null,
   "fields": [
    {
     "name": "count",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "totalBytes",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "newest",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "oldest",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "provider",
     "type": "string",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "bucket",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "region",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    }
   ]
  },
  {
   "id": "WalSegment",
   "name": "WalSegment",
   "group": "registers",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "name",
     "type": "string",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "sizeBytes",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "uploadedAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "key",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "bucket",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "region",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "provider",
     "type": "string",
     "flags": "read-only",
     "note": ""
    }
   ]
  },
  {
   "id": "ScanSchedule",
   "name": "ScanSchedule",
   "group": "registers",
   "kind": "response",
   "note": "GET and POST /auth/security-lab/scan-schedule. Every field is optional in the POST body; send at least one.",
   "extends": null,
   "fields": [
    {
     "name": "enabled",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "intervalHours",
     "type": "integer",
     "flags": "",
     "note": "1 to 168; 24 when never set."
    },
    {
     "name": "lastRunAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "nextDueAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": "null when the schedule is off, has never run, or is already due."
    },
    {
     "name": "reportDeliver",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "reportRecipients",
     "type": "string",
     "flags": "",
     "note": "Empty string when none."
    },
    {
     "name": "externalEnabled",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "externalIntervalHours",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "externalLastRunAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "trivyImages",
     "type": "string",
     "flags": "",
     "note": "Empty string when none."
    },
    {
     "name": "slaAlertEnabled",
     "type": "boolean",
     "flags": "",
     "note": "true when never set."
    },
    {
     "name": "slaAlertPermission",
     "type": "string",
     "flags": "",
     "note": "The permission whose holders are emailed."
    }
   ]
  },
  {
   "id": "SecurityIssue",
   "name": "SecurityIssue",
   "group": "registers",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "string (uuid)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "findingId",
     "type": "string (uuid)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "fingerprint",
     "type": "string",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "probe",
     "type": "string",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "target",
     "type": "string",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "title",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "severity",
     "type": "string",
     "flags": "read-only",
     "note": "Critical | High | Medium | Low | Inconclusive | Informational; treat any other value as the lowest rank."
    },
    {
     "name": "cvssVector",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "cvssScore",
     "type": "number",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "cveId",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "pkgName",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "installedVersion",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "fixedVersion",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "location",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "status",
     "type": "string",
     "flags": "",
     "note": "open | acknowledged | in_remediation | retest_pending | risk_accepted | false_positive | verified_fixed"
    },
    {
     "name": "statusNote",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "owner",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "ticketRef",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "ticketProvider",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "ticketStatus",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "slaDueAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "slaBreachAlertedAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "kevListed",
     "type": "boolean",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "epssScore",
     "type": "number",
     "flags": "nullable, read-only",
     "note": "0 to 1"
    },
    {
     "name": "confirmedExposure",
     "type": "boolean",
     "flags": "read-only",
     "note": "true only when the issue is KEV-listed and the installed version is known to be older than the fixed one."
    },
    {
     "name": "regressed",
     "type": "boolean",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "firstSeenAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "lastSeenAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "evidence",
     "type": "object",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "archivedAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "updatedAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    }
   ]
  },
  {
   "id": "SecurityRemediation",
   "name": "SecurityRemediation",
   "group": "registers",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "string (uuid)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "findingId",
     "type": "string (uuid)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "issueId",
     "type": "string (uuid)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "findingProbe",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "findingTarget",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "targetKind",
     "type": "string",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "targetId",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "targetLabel",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "recommendedPayload",
     "type": "object",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "status",
     "type": "string",
     "flags": "read-only",
     "note": "Values the service writes include proposed, handed_off, resolved and failed."
    },
    {
     "name": "handoffRef",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "createdBy",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "timeline",
     "type": "object[]",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "evidence",
     "type": "object",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "resolvedAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "updatedAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    }
   ]
  },
  {
   "id": "EmployeeListItem",
   "name": "EmployeeListItem",
   "group": "people",
   "kind": "response",
   "note": "One row of GET /user-management/employees.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "firstName",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "lastName",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "email",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "name",
     "type": "string",
     "flags": "",
     "note": "First and last name joined; the email address when both are empty."
    },
    {
     "name": "position",
     "type": "string",
     "flags": "nullable",
     "note": "Job title."
    },
    {
     "name": "department",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "managerId",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "managerName",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "status",
     "type": "string",
     "flags": "nullable",
     "note": "The account status, for example active."
    },
    {
     "name": "employeeId",
     "type": "string",
     "flags": "nullable",
     "note": "Your own employee number, not the Zonitix id."
    },
    {
     "name": "photoUrl",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "terminationDate",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "hireDate",
     "type": "string (date)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "employmentType",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "workLocation",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "level",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "reminders",
     "type": "EmployeeReminder[]",
     "flags": "",
     "note": "Empty for a person who has left."
    }
   ]
  },
  {
   "id": "EmployeeReminder",
   "name": "EmployeeReminder",
   "group": "people",
   "kind": "response",
   "note": "An upcoming date on a file. Worked out on each read, not stored.",
   "extends": null,
   "fields": [
    {
     "name": "kind",
     "type": "string: probation | contract | right_to_work",
     "flags": "",
     "note": ""
    },
    {
     "name": "label",
     "type": "string",
     "flags": "",
     "note": "\"Probation ends\", \"Contract ends\" or \"Right to work expires\"."
    },
    {
     "name": "date",
     "type": "string (date)",
     "flags": "",
     "note": ""
    },
    {
     "name": "days",
     "type": "integer",
     "flags": "",
     "note": "Days from today; negative once the date has passed."
    },
    {
     "name": "overdue",
     "type": "boolean",
     "flags": "",
     "note": "true when days is below 0."
    }
   ]
  },
  {
   "id": "EmployeeFile",
   "name": "EmployeeFile",
   "group": "people",
   "kind": "response",
   "note": "GET /user-management/employees/:userId, and the reply of every write on a file.",
   "extends": null,
   "fields": [
    {
     "name": "user",
     "type": "EmployeeFileUser",
     "flags": "",
     "note": ""
    },
    {
     "name": "profile",
     "type": "EmployeeProfile",
     "flags": "",
     "note": ""
    },
    {
     "name": "emergencyContacts",
     "type": "EmergencyContact[]",
     "flags": "",
     "note": "In the stored order."
    },
    {
     "name": "history",
     "type": "EmploymentEvent[]",
     "flags": "",
     "note": "The newest 200 job changes, newest effective date first."
    },
    {
     "name": "reminders",
     "type": "EmployeeReminder[]",
     "flags": "",
     "note": ""
    },
    {
     "name": "authority",
     "type": "HrAuthority",
     "flags": "",
     "note": "Which system owns each governed field."
    },
    {
     "name": "people",
     "type": "object",
     "flags": "",
     "note": "{ \"<user id>\": name } for the managers named in history."
    }
   ]
  },
  {
   "id": "EmployeeFileUser",
   "name": "EmployeeFileUser",
   "group": "people",
   "kind": "response",
   "note": "The identity half of an employee file.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "firstName",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "lastName",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "email",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "name",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "position",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "department",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "managerId",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "managerName",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "status",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "employeeId",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "phoneNumber",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "personalEmail",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "photoUrl",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "terminationDate",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "personaId",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "source",
     "type": "string",
     "flags": "nullable",
     "note": "Where the account came from, for example local."
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "",
     "note": ""
    }
   ]
  },
  {
   "id": "EmployeeProfile",
   "name": "EmployeeProfile",
   "group": "people",
   "kind": "response",
   "note": "The HR half of an employee file. Every field is null until it is set.",
   "extends": null,
   "fields": [
    {
     "name": "preferredName",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "legalName",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "nationality",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "hireDate",
     "type": "string (date)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "originalHireDate",
     "type": "string (date)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "employmentType",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "workLocation",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "level",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "costCenter",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "legalEntity",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "probationEndDate",
     "type": "string (date)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "contractEndDate",
     "type": "string (date)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "noticePeriod",
     "type": "string",
     "flags": "nullable",
     "note": "Free text, up to 255 characters."
    },
    {
     "name": "rightToWorkStatus",
     "type": "string: verified | pending | expired | not_required",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "rightToWorkVerifiedAt",
     "type": "string (date)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "rightToWorkExpiresAt",
     "type": "string (date)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "hasDateOfBirth",
     "type": "boolean",
     "flags": "",
     "note": "The date itself is never in a file; reveal it with POST /reveal."
    },
    {
     "name": "hasHomeAddress",
     "type": "boolean",
     "flags": "",
     "note": "The address itself is never in a file; reveal it with POST /reveal."
    }
   ]
  },
  {
   "id": "EmergencyContact",
   "name": "EmergencyContact",
   "group": "people",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "name",
     "type": "string",
     "flags": "nullable",
     "note": "null only when the stored value cannot be read."
    },
    {
     "name": "relationship",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "phone",
     "type": "string",
     "flags": "nullable",
     "note": ""
    }
   ]
  },
  {
   "id": "EmploymentEvent",
   "name": "EmploymentEvent",
   "group": "people",
   "kind": "response",
   "note": "One job change in a file's history.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "userId",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "type",
     "type": "string",
     "flags": "",
     "note": "promotion | transfer | manager_change | title_change | other for a change a person recorded."
    },
    {
     "name": "effectiveDate",
     "type": "string (date)",
     "flags": "",
     "note": ""
    },
    {
     "name": "status",
     "type": "string: applied | scheduled | cancelled",
     "flags": "",
     "note": "scheduled until its effective date arrives."
    },
    {
     "name": "changes",
     "type": "object",
     "flags": "",
     "note": "{ \"<field>\": { from, to } } for each field that changed."
    },
    {
     "name": "reason",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "source",
     "type": "string",
     "flags": "",
     "note": "manual for a change a person recorded."
    },
    {
     "name": "sourceRef",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "actorEmail",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "appliedAt",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "",
     "note": ""
    },
    {
     "name": "updatedAt",
     "type": "string (date-time)",
     "flags": "",
     "note": ""
    }
   ]
  },
  {
   "id": "HrAuthority",
   "name": "HrAuthority",
   "group": "people",
   "kind": "response",
   "note": "Which system owns people data. Also the base of GET /employees/hr-settings.",
   "extends": null,
   "fields": [
    {
     "name": "mode",
     "type": "string: zonitix | external",
     "flags": "",
     "note": ""
    },
    {
     "name": "providerId",
     "type": "integer",
     "flags": "nullable",
     "note": "The connected HR system, when mode is external."
    },
    {
     "name": "providerName",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "providerMissing",
     "type": "boolean",
     "flags": "",
     "note": "true when mode is external and that connection no longer exists."
    },
    {
     "name": "fields",
     "type": "object",
     "flags": "",
     "note": "{ \"<field key>\": \"zonitix\" | \"external\" }: the owner of each governed field."
    },
    {
     "name": "overrides",
     "type": "object",
     "flags": "",
     "note": "The per-field choices as saved."
    },
    {
     "name": "lastSync",
     "type": "object",
     "flags": "nullable",
     "note": "What the last sync from the HR system recorded."
    }
   ]
  },
  {
   "id": "CompensationRecord",
   "name": "CompensationRecord",
   "group": "people",
   "kind": "response",
   "note": "The amount is never part of this object: reveal one with POST /compensation/:recordId/reveal.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "effectiveDate",
     "type": "string (date)",
     "flags": "",
     "note": ""
    },
    {
     "name": "currency",
     "type": "string",
     "flags": "",
     "note": "A 3-letter code."
    },
    {
     "name": "payPeriod",
     "type": "string: annual | monthly | hourly",
     "flags": "",
     "note": ""
    },
    {
     "name": "bonusPct",
     "type": "number",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "reason",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "source",
     "type": "string",
     "flags": "",
     "note": "manual for a record a person typed in."
    },
    {
     "name": "sourceOfferId",
     "type": "integer",
     "flags": "nullable",
     "note": "The signed offer the record was carried over from."
    },
    {
     "name": "actorEmail",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "",
     "note": ""
    },
    {
     "name": "voidedAt",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "voidedBy",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "voidReason",
     "type": "string",
     "flags": "nullable",
     "note": ""
    }
   ]
  },
  {
   "id": "EmployeeDocument",
   "name": "EmployeeDocument",
   "group": "people",
   "kind": "response",
   "note": "The entry for a stored file. The file itself is fetched from the download route.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "name",
     "type": "string",
     "flags": "",
     "note": "Up to 180 characters."
    },
    {
     "name": "category",
     "type": "string: contract | offer | job_change | policy | certificate | review | letter | other",
     "flags": "",
     "note": ""
    },
    {
     "name": "visibility",
     "type": "string: hr | employee | manager",
     "flags": "",
     "note": ""
    },
    {
     "name": "mimeType",
     "type": "string",
     "flags": "nullable",
     "note": "null until the file has been uploaded."
    },
    {
     "name": "sizeBytes",
     "type": "integer",
     "flags": "",
     "note": "0 until the file has been uploaded."
    },
    {
     "name": "expiresAt",
     "type": "string (date)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "uploadedBy",
     "type": "string",
     "flags": "nullable",
     "note": "The email address of the person who created the entry."
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "",
     "note": ""
    }
   ]
  },
  {
   "id": "DepartureRecord",
   "name": "DepartureRecord",
   "group": "people",
   "kind": "response",
   "note": "The leaving record on a file.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "lastDay",
     "type": "string (date)",
     "flags": "",
     "note": ""
    },
    {
     "name": "departureType",
     "type": "string: voluntary | involuntary | end_of_contract",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "reasonCode",
     "type": "string",
     "flags": "nullable",
     "note": "One of the keys in the reply's reasons list."
    },
    {
     "name": "rehireEligible",
     "type": "boolean",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "checklist",
     "type": "object[]",
     "flags": "",
     "note": "Each { key:string, label:string, done:boolean, doneBy:string|null, doneAt:string (date-time)|null }."
    },
    {
     "name": "source",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "recordedBy",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "",
     "note": ""
    }
   ]
  },
  {
   "id": "LeaveBalance",
   "name": "LeaveBalance",
   "group": "people",
   "kind": "response",
   "note": "One per type of time off.",
   "extends": null,
   "fields": [
    {
     "name": "typeId",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "name",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "paid",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "tracksBalance",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "requiresApproval",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "annualDays",
     "type": "number",
     "flags": "",
     "note": ""
    },
    {
     "name": "allowNegative",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "isActive",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "allowHours",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "minPartMinutes",
     "type": "integer",
     "flags": "",
     "note": "The smallest booking by the hour."
    },
    {
     "name": "maxPartMinutes",
     "type": "integer",
     "flags": "nullable",
     "note": "The most per booking by the hour."
    },
    {
     "name": "balance",
     "type": "number",
     "flags": "nullable",
     "note": "null for a type that does not track a balance."
    },
    {
     "name": "pending",
     "type": "number",
     "flags": "",
     "note": "Days in requests still waiting for a decision."
    },
    {
     "name": "available",
     "type": "number",
     "flags": "nullable",
     "note": "balance minus pending, to two places; null without a balance."
    },
    {
     "name": "availableExact",
     "type": "number",
     "flags": "nullable",
     "note": "The same figure before rounding."
    },
    {
     "name": "taken",
     "type": "number",
     "flags": "",
     "note": "Approved days that start in the current calendar year."
    }
   ]
  },
  {
   "id": "LeaveRequest",
   "name": "LeaveRequest",
   "group": "people",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "userId",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "leaveTypeId",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "typeName",
     "type": "string",
     "flags": "nullable",
     "note": "Not sent in the reply to a cancel."
    },
    {
     "name": "startDate",
     "type": "string (date)",
     "flags": "",
     "note": ""
    },
    {
     "name": "endDate",
     "type": "string (date)",
     "flags": "",
     "note": ""
    },
    {
     "name": "startHalf",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "endHalf",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "startTime",
     "type": "string (HH:MM)",
     "flags": "nullable",
     "note": "Set only for time off by the hour."
    },
    {
     "name": "minutes",
     "type": "integer",
     "flags": "nullable",
     "note": "Set only for time off by the hour."
    },
    {
     "name": "days",
     "type": "number",
     "flags": "",
     "note": "Working days the request uses."
    },
    {
     "name": "status",
     "type": "string: pending | approved | declined | cancelled",
     "flags": "",
     "note": ""
    },
    {
     "name": "note",
     "type": "string",
     "flags": "nullable",
     "note": "Up to 500 characters."
    },
    {
     "name": "decidedByEmail",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "decidedAt",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "decisionNote",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "",
     "note": ""
    }
   ]
  },
  {
   "id": "LeaveType",
   "name": "LeaveType",
   "group": "people",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "name",
     "type": "string",
     "flags": "",
     "note": "Up to 60 characters; unique without regard to case."
    },
    {
     "name": "paid",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "requiresApproval",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "tracksBalance",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "annualDays",
     "type": "number",
     "flags": "",
     "note": "0 to 366."
    },
    {
     "name": "accrual",
     "type": "string: upfront | monthly",
     "flags": "",
     "note": ""
    },
    {
     "name": "accrueFrom",
     "type": "string (date)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "carryOverMaxDays",
     "type": "number",
     "flags": "",
     "note": "0 to 366."
    },
    {
     "name": "allowNegative",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "allowHours",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "minPartMinutes",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "maxPartMinutes",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "isActive",
     "type": "boolean",
     "flags": "",
     "note": "false for a retired type."
    },
    {
     "name": "sortOrder",
     "type": "integer",
     "flags": "",
     "note": ""
    }
   ]
  },
  {
   "id": "WorkingCalendar",
   "name": "WorkingCalendar",
   "group": "people",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "name",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "location",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "workDays",
     "type": "integer[]",
     "flags": "",
     "note": "The working days of the week."
    },
    {
     "name": "hoursPerDay",
     "type": "number",
     "flags": "",
     "note": ""
    },
    {
     "name": "dayStart",
     "type": "string (HH:MM)",
     "flags": "",
     "note": ""
    },
    {
     "name": "isDefault",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "holidays",
     "type": "object[]",
     "flags": "",
     "note": "Each { date:string (date), name:string }, earliest first."
    }
   ]
  },
  {
   "id": "Goal",
   "name": "Goal",
   "group": "people",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "userId",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "title",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "description",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "period",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "dueDate",
     "type": "string (date)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "status",
     "type": "string: on_track | at_risk | done | dropped",
     "flags": "",
     "note": ""
    },
    {
     "name": "progress",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "weight",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "createdByEmail",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "completedAt",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "",
     "note": ""
    },
    {
     "name": "updatedAt",
     "type": "string (date-time)",
     "flags": "",
     "note": ""
    }
   ]
  },
  {
   "id": "GoalTemplate",
   "name": "GoalTemplate",
   "group": "people",
   "kind": "response",
   "note": "An example goal.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "title",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "description",
     "type": "string",
     "flags": "nullable",
     "note": "Up to 2,000 characters."
    },
    {
     "name": "isActive",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "sortOrder",
     "type": "integer",
     "flags": "",
     "note": ""
    }
   ]
  },
  {
   "id": "ReviewCycle",
   "name": "ReviewCycle",
   "group": "people",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "name",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "periodLabel",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "selfDueDate",
     "type": "string (date)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "managerDueDate",
     "type": "string (date)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "questions",
     "type": "object[]",
     "flags": "",
     "note": "Each { key:string, label:string, help:string|null }; at most 12."
    },
    {
     "name": "includeRating",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "department",
     "type": "string",
     "flags": "nullable",
     "note": "null when the cycle covers everyone."
    },
    {
     "name": "peerFeedback",
     "type": "string",
     "flags": "",
     "note": "The colleague-feedback mode of the cycle."
    },
    {
     "name": "calibration",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "ratingsReleased",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "ratingTargets",
     "type": "object",
     "flags": "nullable",
     "note": "null when the cycle does not use calibration."
    },
    {
     "name": "goalRatings",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "status",
     "type": "string",
     "flags": "",
     "note": "draft, open or closed."
    },
    {
     "name": "launchedAt",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "closedAt",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    }
   ]
  },
  {
   "id": "Survey",
   "name": "Survey",
   "group": "people",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "title",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "intro",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "questions",
     "type": "object[]",
     "flags": "",
     "note": "Each { key:string, label:string, type: scale | enps | text }; at most 20."
    },
    {
     "name": "department",
     "type": "string",
     "flags": "nullable",
     "note": "null when everyone is asked."
    },
    {
     "name": "status",
     "type": "string",
     "flags": "",
     "note": "draft, open or closed."
    },
    {
     "name": "closesAt",
     "type": "string (date)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "openedAt",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "closedAt",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "expired",
     "type": "boolean",
     "flags": "",
     "note": "true once the closing date has passed."
    }
   ]
  },
  {
   "id": "TrainingCourse",
   "name": "TrainingCourse",
   "group": "people",
   "kind": "response",
   "note": "A training item. The correct answers of its knowledge check are never part of this object.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "title",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "description",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "url",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "renewEveryMonths",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "isActive",
     "type": "boolean",
     "flags": "",
     "note": "false for a retired item."
    },
    {
     "name": "content",
     "type": "string",
     "flags": "nullable",
     "note": "The text to read; up to 20,000 characters."
    },
    {
     "name": "autoAssign",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "dueDays",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "proof",
     "type": "string",
     "flags": "",
     "note": "What finishing takes; none when nothing is asked."
    },
    {
     "name": "securityAwareness",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "securityAwarenessSince",
     "type": "string (date)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "topic",
     "type": "string",
     "flags": "nullable",
     "note": "The compliance topic the item counts under."
    },
    {
     "name": "topicLabel",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "videoUrl",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "videoRequired",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "hasVideoFile",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "scorm",
     "type": "object",
     "flags": "nullable",
     "note": "{ title:string|null, launch:string, files:integer, bytes:integer, importedAt:string (date-time)|null }."
    },
    {
     "name": "audience",
     "type": "object",
     "flags": "nullable",
     "note": "{ kind: \"everyone\" } or { kind: \"rule\", departments:string[], positions:string[], locations:string[] }; null when people are assigned by hand."
    },
    {
     "name": "audienceText",
     "type": "string",
     "flags": "",
     "note": "The same audience in words."
    },
    {
     "name": "quiz",
     "type": "TrainingQuiz",
     "flags": "nullable",
     "note": ""
    }
   ]
  },
  {
   "id": "TrainingQuiz",
   "name": "TrainingQuiz",
   "group": "people",
   "kind": "response",
   "note": "A knowledge check as a person sees it: questions and options only.",
   "extends": null,
   "fields": [
    {
     "name": "passMark",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "maxAttempts",
     "type": "integer",
     "flags": "nullable",
     "note": "Tries per day; null when unlimited."
    },
    {
     "name": "perTry",
     "type": "integer",
     "flags": "nullable",
     "note": "How many questions one try draws from the pool."
    },
    {
     "name": "draw",
     "type": "string",
     "flags": "optional",
     "note": "Present on one person's try: identifies the questions drawn. Send it back with the answers."
    },
    {
     "name": "pool",
     "type": "integer",
     "flags": "optional",
     "note": "Present with draw."
    },
    {
     "name": "questions",
     "type": "object[]",
     "flags": "",
     "note": "Each { id, text:string, options:string[], example? }."
    }
   ]
  },
  {
   "id": "TrainingAssignment",
   "name": "TrainingAssignment",
   "group": "people",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "courseId",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "userId",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "dueDate",
     "type": "string (date)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "status",
     "type": "string",
     "flags": "",
     "note": "assigned or completed."
    },
    {
     "name": "completedAt",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "lastCompletedAt",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "expiresAt",
     "type": "string (date)",
     "flags": "nullable",
     "note": "When a completion that has to be renewed runs out."
    },
    {
     "name": "overdue",
     "type": "boolean",
     "flags": "",
     "note": "Worked out on each read: assigned, past its due date, and the item is not retired."
    },
    {
     "name": "acknowledgedAt",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "hasCertificate",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "quizTriesLeft",
     "type": "integer",
     "flags": "nullable",
     "note": "null when tries are not limited or the item was not loaded."
    },
    {
     "name": "quiz",
     "type": "TrainingQuiz",
     "flags": "nullable",
     "note": "This try's questions, only while the item is still to be done."
    },
    {
     "name": "course",
     "type": "TrainingCourse",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "exemptUntil",
     "type": "string (date)",
     "flags": "optional",
     "note": "Present only on an open item the person is excused from; overdue is then false."
    }
   ]
  },
  {
   "id": "LearningPath",
   "name": "LearningPath",
   "group": "people",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "title",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "description",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "isActive",
     "type": "boolean",
     "flags": "",
     "note": "false for a paused path."
    },
    {
     "name": "autoAssign",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "autoAssignFrom",
     "type": "string (date)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "steps",
     "type": "object[]",
     "flags": "",
     "note": "Each { courseId:integer, position:integer, title:string, isActive:boolean, dueDays:integer }, in order."
    },
    {
     "name": "people",
     "type": "integer",
     "flags": "optional",
     "note": "In the path list only: active members."
    },
    {
     "name": "finished",
     "type": "integer",
     "flags": "optional",
     "note": "In the path list only."
    },
    {
     "name": "percent",
     "type": "integer",
     "flags": "optional",
     "note": "In the path list only: finished as a whole percentage of people."
    }
   ]
  },
  {
   "id": "PhishingCampaign",
   "name": "PhishingCampaign",
   "group": "people",
   "kind": "response",
   "note": "A practice phishing simulation with its totals.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "title",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "templateKey",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "difficulty",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "subject",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "status",
     "type": "string: draft | pending_approval | approved | sending | sent | cancelled",
     "flags": "",
     "note": ""
    },
    {
     "name": "createdBy",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "createdById",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "approvedBy",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "approvedAt",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "sendAt",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "sentAt",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "courseId",
     "type": "integer",
     "flags": "nullable",
     "note": "The training item a person who clicks is pointed to."
    },
    {
     "name": "resultsPrunedAt",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "recipients",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "sent",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "clicked",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "reported",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "clickRate",
     "type": "integer",
     "flags": "nullable",
     "note": "A whole percentage of emails sent; null when none was sent."
    },
    {
     "name": "reportRate",
     "type": "integer",
     "flags": "nullable",
     "note": "A whole percentage of emails sent; null when none was sent."
    }
   ]
  },
  {
   "id": "EmployeeProfileUpdate",
   "name": "EmployeeProfileUpdate",
   "group": "people",
   "kind": "request",
   "note": "Only the keys you send are changed. Job title, department, manager, level, employment type and work location are not read here: they change through a job change.",
   "extends": null,
   "fields": [
    {
     "name": "preferredName",
     "type": "string",
     "flags": "optional, null clears it",
     "note": ""
    },
    {
     "name": "legalName",
     "type": "string",
     "flags": "optional, null clears it",
     "note": ""
    },
    {
     "name": "nationality",
     "type": "string",
     "flags": "optional, null clears it",
     "note": ""
    },
    {
     "name": "costCenter",
     "type": "string",
     "flags": "optional, null clears it",
     "note": ""
    },
    {
     "name": "legalEntity",
     "type": "string",
     "flags": "optional, null clears it",
     "note": ""
    },
    {
     "name": "noticePeriod",
     "type": "string",
     "flags": "optional, null clears it",
     "note": ""
    },
    {
     "name": "rightToWorkStatus",
     "type": "string: verified | pending | expired | not_required",
     "flags": "optional, null clears it",
     "note": "Any other value answers 400."
    },
    {
     "name": "hireDate",
     "type": "string (date)",
     "flags": "optional, null clears it",
     "note": "A real calendar date, or the answer is 400. The same for every date below."
    },
    {
     "name": "originalHireDate",
     "type": "string (date)",
     "flags": "optional, null clears it",
     "note": ""
    },
    {
     "name": "probationEndDate",
     "type": "string (date)",
     "flags": "optional, null clears it",
     "note": ""
    },
    {
     "name": "contractEndDate",
     "type": "string (date)",
     "flags": "optional, null clears it",
     "note": ""
    },
    {
     "name": "rightToWorkVerifiedAt",
     "type": "string (date)",
     "flags": "optional, null clears it",
     "note": ""
    },
    {
     "name": "rightToWorkExpiresAt",
     "type": "string (date)",
     "flags": "optional, null clears it",
     "note": ""
    },
    {
     "name": "dateOfBirth",
     "type": "string (date)",
     "flags": "optional, null clears it",
     "note": "Stored encrypted."
    },
    {
     "name": "homeAddress",
     "type": "string",
     "flags": "optional, null clears it",
     "note": "At most 1000 characters. Stored encrypted."
    }
   ]
  },
  {
   "id": "EmergencyContactInput",
   "name": "EmergencyContactInput",
   "group": "people",
   "kind": "request",
   "note": "One entry of { contacts: [...] }. A call replaces the whole list: at most 5, kept in the order sent.",
   "extends": null,
   "fields": [
    {
     "name": "name",
     "type": "string",
     "flags": "required",
     "note": ""
    },
    {
     "name": "relationship",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "phone",
     "type": "string",
     "flags": "optional",
     "note": "At most 40 characters."
    }
   ]
  },
  {
   "id": "JobChangeCreate",
   "name": "JobChangeCreate",
   "group": "people",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "type",
     "type": "string: promotion | transfer | manager_change | title_change | other",
     "flags": "required",
     "note": ""
    },
    {
     "name": "effectiveDate",
     "type": "string (date)",
     "flags": "optional",
     "note": "Default today. A later date stores the change as scheduled; it is applied on that day."
    },
    {
     "name": "changes",
     "type": "object",
     "flags": "required",
     "note": "At least one of position, department, level, employmentType, workLocation (strings; null clears) and managerId (a user id; null removes the manager)."
    },
    {
     "name": "reason",
     "type": "string",
     "flags": "optional",
     "note": "At most 1000 characters."
    }
   ]
  },
  {
   "id": "MyDetailsUpdate",
   "name": "MyDetailsUpdate",
   "group": "people",
   "kind": "request",
   "note": "What a person may change on their own file. At least one key is needed, or the answer is 400.",
   "extends": null,
   "fields": [
    {
     "name": "phoneNumber",
     "type": "string",
     "flags": "optional, null clears it",
     "note": "At most 40 characters."
    },
    {
     "name": "personalEmail",
     "type": "string (email)",
     "flags": "optional, null clears it",
     "note": "Must be an email address and must differ from the work email."
    },
    {
     "name": "preferredName",
     "type": "string",
     "flags": "optional, null clears it",
     "note": ""
    },
    {
     "name": "homeAddress",
     "type": "string",
     "flags": "optional, null clears it",
     "note": "At most 1000 characters. Stored encrypted."
    }
   ]
  },
  {
   "id": "CompensationCreate",
   "name": "CompensationCreate",
   "group": "people",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "amount",
     "type": "number",
     "flags": "required",
     "note": "Greater than 0 and at most 10000000000."
    },
    {
     "name": "currency",
     "type": "string",
     "flags": "required",
     "note": "Three letters; upper-cased."
    },
    {
     "name": "effectiveDate",
     "type": "string (date)",
     "flags": "optional",
     "note": "Default today."
    },
    {
     "name": "payPeriod",
     "type": "string: annual | monthly | hourly",
     "flags": "optional",
     "note": "Default annual."
    },
    {
     "name": "bonusPct",
     "type": "number",
     "flags": "optional",
     "note": "0 to 999."
    },
    {
     "name": "reason",
     "type": "string",
     "flags": "optional",
     "note": ""
    }
   ]
  },
  {
   "id": "EmployeeDocumentWrite",
   "name": "EmployeeDocumentWrite",
   "group": "people",
   "kind": "request",
   "note": "The entry a file is then uploaded to. An update (PUT on the document) takes the same keys and changes only those sent.",
   "extends": null,
   "fields": [
    {
     "name": "name",
     "type": "string",
     "flags": "required on create",
     "note": "Characters that cannot be in a file name become spaces; cut to 180 characters."
    },
    {
     "name": "category",
     "type": "string: contract | offer | job_change | policy | certificate | review | letter | other",
     "flags": "optional",
     "note": "Default other."
    },
    {
     "name": "visibility",
     "type": "string: hr | employee | manager",
     "flags": "optional",
     "note": "Default hr. employee means HR and the person; manager means HR, the person and their manager."
    },
    {
     "name": "expiresAt",
     "type": "string (date)",
     "flags": "optional, null clears it",
     "note": ""
    }
   ]
  },
  {
   "id": "LeaveRequestCreate",
   "name": "LeaveRequestCreate",
   "group": "people",
   "kind": "request",
   "note": "Whole or half days over a range, or hours on one day when minutes is sent.",
   "extends": null,
   "fields": [
    {
     "name": "leaveTypeId",
     "type": "integer",
     "flags": "required",
     "note": "A type that is in use."
    },
    {
     "name": "startDate",
     "type": "string (date)",
     "flags": "required",
     "note": ""
    },
    {
     "name": "endDate",
     "type": "string (date)",
     "flags": "optional",
     "note": "Default startDate. Not before startDate and at most 366 days after it. With minutes it must be the same day."
    },
    {
     "name": "startHalf",
     "type": "boolean",
     "flags": "optional",
     "note": "The first day is a half day."
    },
    {
     "name": "endHalf",
     "type": "boolean",
     "flags": "optional",
     "note": "The last day is a half day."
    },
    {
     "name": "minutes",
     "type": "integer",
     "flags": "optional",
     "note": "Time off by the hour: at least 15, and it must end on the day it starts."
    },
    {
     "name": "startTime",
     "type": "string",
     "flags": "required with minutes",
     "note": "HH:MM."
    },
    {
     "name": "note",
     "type": "string",
     "flags": "optional",
     "note": "At most 500 characters."
    },
    {
     "name": "repeatWeeks",
     "type": "integer",
     "flags": "optional",
     "note": "2 to 12: the same one-day request on that weekday for this many weeks. Weeks that hold no working day are reported in skipped."
    },
    {
     "name": "replaceIds",
     "type": "integer[]",
     "flags": "optional",
     "note": "At most 20 of your own bookings by the hour that this whole-day request replaces."
    }
   ]
  },
  {
   "id": "LeaveDecision",
   "name": "LeaveDecision",
   "group": "people",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "decision",
     "type": "string: approve | decline",
     "flags": "required",
     "note": ""
    },
    {
     "name": "note",
     "type": "string",
     "flags": "required for decline",
     "note": "At most 500 characters. Shown to the person."
    }
   ]
  },
  {
   "id": "LeaveTypeWrite",
   "name": "LeaveTypeWrite",
   "group": "people",
   "kind": "request",
   "note": "Create and update take the same keys, and an update sets every one of them: a key left out takes its default.",
   "extends": null,
   "fields": [
    {
     "name": "name",
     "type": "string",
     "flags": "required",
     "note": "At most 60 characters."
    },
    {
     "name": "tracksBalance",
     "type": "boolean",
     "flags": "optional",
     "note": "Default true. When false, annualDays and carryOverMaxDays are stored as 0."
    },
    {
     "name": "annualDays",
     "type": "number",
     "flags": "optional",
     "note": "0 to 366. Default 0."
    },
    {
     "name": "accrual",
     "type": "string: upfront | monthly",
     "flags": "optional",
     "note": "Default upfront; any other value is stored as upfront."
    },
    {
     "name": "carryOverMaxDays",
     "type": "number",
     "flags": "optional",
     "note": "0 to 366. Default 0."
    },
    {
     "name": "paid",
     "type": "boolean",
     "flags": "optional",
     "note": "Default true."
    },
    {
     "name": "requiresApproval",
     "type": "boolean",
     "flags": "optional",
     "note": "Default true."
    },
    {
     "name": "allowHours",
     "type": "boolean",
     "flags": "optional",
     "note": "Default true."
    },
    {
     "name": "allowNegative",
     "type": "boolean",
     "flags": "optional",
     "note": "Default false."
    },
    {
     "name": "minPartMinutes",
     "type": "integer",
     "flags": "optional, null clears it",
     "note": "The smallest amount by the hour: 15 to 1440."
    },
    {
     "name": "maxPartMinutes",
     "type": "integer",
     "flags": "optional, null clears it",
     "note": "The most by the hour in one request: 15 to 1440, not less than minPartMinutes."
    },
    {
     "name": "isActive",
     "type": "boolean",
     "flags": "optional",
     "note": "Default true."
    },
    {
     "name": "sortOrder",
     "type": "integer",
     "flags": "optional",
     "note": "Default 0."
    },
    {
     "name": "accrueFrom",
     "type": "string (date)",
     "flags": "optional",
     "note": "Create only: the day balances start to build from. Kept between 1 January of this year and today; default 1 January."
    }
   ]
  },
  {
   "id": "HolidayCalendarWrite",
   "name": "HolidayCalendarWrite",
   "group": "people",
   "kind": "request",
   "note": "Create and update take the same keys. The holidays sent replace the stored list.",
   "extends": null,
   "fields": [
    {
     "name": "name",
     "type": "string",
     "flags": "required",
     "note": ""
    },
    {
     "name": "location",
     "type": "string",
     "flags": "optional, null clears it",
     "note": "The work location this calendar applies to."
    },
    {
     "name": "workDays",
     "type": "integer[]",
     "flags": "optional",
     "note": "Days of the week as 0 (Sunday) to 6 (Saturday). Default 1 to 5."
    },
    {
     "name": "holidays",
     "type": "object[]",
     "flags": "optional",
     "note": "Each { date:string (YYYY-MM-DD), name:string }, both required. At most 400, and no date twice."
    },
    {
     "name": "hoursPerDay",
     "type": "number",
     "flags": "optional",
     "note": "1 to 24."
    },
    {
     "name": "dayStart",
     "type": "string",
     "flags": "optional",
     "note": "HH:MM."
    },
    {
     "name": "isDefault",
     "type": "boolean",
     "flags": "optional",
     "note": "true makes this the default calendar. The only calendar is always the default."
    }
   ]
  },
  {
   "id": "LeaveBalanceAdjust",
   "name": "LeaveBalanceAdjust",
   "group": "people",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "leaveTypeId",
     "type": "integer",
     "flags": "required",
     "note": "A type that tracks a balance."
    },
    {
     "name": "days",
     "type": "number",
     "flags": "required",
     "note": "Not 0, between -366 and 366; a negative number removes days."
    },
    {
     "name": "note",
     "type": "string",
     "flags": "required",
     "note": "Why the balance is changed."
    }
   ]
  },
  {
   "id": "GoalWrite",
   "name": "GoalWrite",
   "group": "people",
   "kind": "request",
   "note": "Create and update take the same keys; an update changes only the keys you send. A person can have at most 50 goals.",
   "extends": null,
   "fields": [
    {
     "name": "title",
     "type": "string",
     "flags": "required on create",
     "note": ""
    },
    {
     "name": "userId",
     "type": "integer",
     "flags": "optional",
     "note": "Create only: whose goal it is. Default yourself."
    },
    {
     "name": "description",
     "type": "string",
     "flags": "optional, null clears it",
     "note": "At most 2000 characters."
    },
    {
     "name": "period",
     "type": "string",
     "flags": "optional, null clears it",
     "note": "At most 40 characters."
    },
    {
     "name": "dueDate",
     "type": "string (date)",
     "flags": "optional, null clears it",
     "note": ""
    },
    {
     "name": "status",
     "type": "string: on_track | at_risk | done | dropped",
     "flags": "optional",
     "note": "done sets progress to 100."
    },
    {
     "name": "progress",
     "type": "integer",
     "flags": "optional",
     "note": "0 to 100. 100 with no status sets status to done."
    },
    {
     "name": "weight",
     "type": "integer",
     "flags": "optional, null clears it",
     "note": "0 to 100."
    }
   ]
  },
  {
   "id": "ReviewCycleWrite",
   "name": "ReviewCycleWrite",
   "group": "people",
   "kind": "request",
   "note": "Create and update take the same keys. The questions, department, rating and feedback options can be changed only while the cycle is a draft; a closed cycle cannot be changed.",
   "extends": null,
   "fields": [
    {
     "name": "name",
     "type": "string",
     "flags": "required",
     "note": ""
    },
    {
     "name": "periodLabel",
     "type": "string",
     "flags": "optional, null clears it",
     "note": "At most 60 characters."
    },
    {
     "name": "selfDueDate",
     "type": "string (date)",
     "flags": "optional, null clears it",
     "note": ""
    },
    {
     "name": "managerDueDate",
     "type": "string (date)",
     "flags": "optional, null clears it",
     "note": "Not before selfDueDate."
    },
    {
     "name": "department",
     "type": "string",
     "flags": "optional, null clears it",
     "note": "Limits the cycle to one department."
    },
    {
     "name": "questions",
     "type": "object[]",
     "flags": "optional",
     "note": "1 to 12, each { label:string (required), key?:string, help?:string (at most 300 characters) }. The default questions when left out."
    },
    {
     "name": "includeRating",
     "type": "boolean",
     "flags": "optional",
     "note": "Default true."
    },
    {
     "name": "calibration",
     "type": "boolean",
     "flags": "optional",
     "note": "Default false. Takes effect only with includeRating."
    },
    {
     "name": "goalRatings",
     "type": "boolean",
     "flags": "optional",
     "note": "Default false."
    },
    {
     "name": "peerFeedback",
     "type": "string: off | reviewer | person",
     "flags": "optional",
     "note": "Default off. Any other value answers 400."
    }
   ]
  },
  {
   "id": "SurveyWrite",
   "name": "SurveyWrite",
   "group": "people",
   "kind": "request",
   "note": "Create and update take the same keys. The questions and the department can be changed only while the survey is a draft; a closed survey cannot be changed.",
   "extends": null,
   "fields": [
    {
     "name": "title",
     "type": "string",
     "flags": "required",
     "note": ""
    },
    {
     "name": "intro",
     "type": "string",
     "flags": "optional, null clears it",
     "note": "At most 2000 characters."
    },
    {
     "name": "department",
     "type": "string",
     "flags": "optional, null clears it",
     "note": "Limits the survey to one department."
    },
    {
     "name": "closesAt",
     "type": "string (date)",
     "flags": "optional, null clears it",
     "note": "Not in the past."
    },
    {
     "name": "questions",
     "type": "object[]",
     "flags": "optional",
     "note": "1 to 20, each { label:string (required, at most 300 characters), type: scale | enps | text (required), key?:string }. The default questions when left out."
    }
   ]
  },
  {
   "id": "TrainingCourseWrite",
   "name": "TrainingCourseWrite",
   "group": "people",
   "kind": "request",
   "note": "Create and update take the same keys. A course with a topic must be redone at least every 12 months and cannot use proof none.",
   "extends": null,
   "fields": [
    {
     "name": "title",
     "type": "string",
     "flags": "required",
     "note": ""
    },
    {
     "name": "description",
     "type": "string",
     "flags": "optional, null clears it",
     "note": "At most 2000 characters."
    },
    {
     "name": "url",
     "type": "string",
     "flags": "optional, null clears it",
     "note": "An http or https address, at most 1000 characters."
    },
    {
     "name": "content",
     "type": "string",
     "flags": "optional",
     "note": "The text of the course, at most 20000 characters."
    },
    {
     "name": "proof",
     "type": "string: none | acknowledge | certificate | quiz",
     "flags": "optional",
     "note": "How a person marks it done. Default none."
    },
    {
     "name": "quiz",
     "type": "object",
     "flags": "optional",
     "note": "The knowledge check. With proof quiz it needs at least one question."
    },
    {
     "name": "renewEveryMonths",
     "type": "integer",
     "flags": "optional, null clears it",
     "note": "1 to 120."
    },
    {
     "name": "dueDays",
     "type": "integer",
     "flags": "optional",
     "note": "Days to finish, 1 to 365. Default 30."
    },
    {
     "name": "topic",
     "type": "string",
     "flags": "optional, null clears it",
     "note": "One of the training topics the instance lists."
    },
    {
     "name": "autoAssign",
     "type": "boolean",
     "flags": "optional",
     "note": "Default false."
    },
    {
     "name": "isActive",
     "type": "boolean",
     "flags": "optional",
     "note": "Default true. false retires the course."
    },
    {
     "name": "videoUrl",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "videoRequired",
     "type": "boolean",
     "flags": "optional",
     "note": "Takes effect only when the course has a video."
    },
    {
     "name": "audience",
     "type": "object",
     "flags": "optional",
     "note": "Who autoAssign applies to."
    }
   ]
  },
  {
   "id": "LearningPathWrite",
   "name": "LearningPathWrite",
   "group": "people",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "title",
     "type": "string",
     "flags": "required",
     "note": ""
    },
    {
     "name": "courseIds",
     "type": "integer[]",
     "flags": "required",
     "note": "2 to 20 courses in order, each once, none retired."
    },
    {
     "name": "description",
     "type": "string",
     "flags": "optional, null clears it",
     "note": "At most 2000 characters."
    },
    {
     "name": "isActive",
     "type": "boolean",
     "flags": "optional",
     "note": "Default true."
    },
    {
     "name": "autoAssign",
     "type": "boolean",
     "flags": "optional",
     "note": "Default false."
    }
   ]
  },
  {
   "id": "PhishingCampaignWrite",
   "name": "PhishingCampaignWrite",
   "group": "people",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "templateKey",
     "type": "string",
     "flags": "required",
     "note": "One of the built-in templates."
    },
    {
     "name": "title",
     "type": "string",
     "flags": "optional",
     "note": "Cut to 160 characters. Defaults to the name of the template."
    },
    {
     "name": "userIds",
     "type": "integer[]",
     "flags": "required",
     "note": "1 to 2000 active people."
    },
    {
     "name": "courseId",
     "type": "integer",
     "flags": "optional, null clears it",
     "note": "A training course in use, linked to the simulation."
    },
    {
     "name": "sendAt",
     "type": "string (date-time)",
     "flags": "optional, null clears it",
     "note": "When to send."
    }
   ]
  },
  {
   "id": "SensitiveFieldReveal",
   "name": "SensitiveFieldReveal",
   "group": "people",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "field",
     "type": "string: dateOfBirth | homeAddress",
     "flags": "required",
     "note": "Any other value is refused with 400."
    }
   ]
  },
  {
   "id": "HrAuthorityWrite",
   "name": "HrAuthorityWrite",
   "group": "people",
   "kind": "request",
   "note": "Which system holds the people data: this instance, or a connected HR system.",
   "extends": null,
   "fields": [
    {
     "name": "mode",
     "type": "string: zonitix | external",
     "flags": "required",
     "note": "Any other value is refused with 400."
    },
    {
     "name": "providerId",
     "type": "integer",
     "flags": "required for external",
     "note": "A connected HR system; any other id is refused with 400. Not read for zonitix."
    },
    {
     "name": "fields",
     "type": "object",
     "flags": "optional",
     "note": "Per field: the field key, and zonitix or external as its value."
    }
   ]
  },
  {
   "id": "HrRetentionWrite",
   "name": "HrRetentionWrite",
   "group": "people",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "months",
     "type": "integer",
     "flags": "required",
     "note": "0 switches retention off; otherwise a whole number from 6 to 240. Anything else is refused with 400."
    }
   ]
  },
  {
   "id": "CompensationVoid",
   "name": "CompensationVoid",
   "group": "people",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "reason",
     "type": "string",
     "flags": "required",
     "note": "Cut to 255 characters; blank is refused with 400."
    }
   ]
  },
  {
   "id": "LeaveApproveMany",
   "name": "LeaveApproveMany",
   "group": "people",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "ids",
     "type": "integer[]",
     "flags": "required",
     "note": "1 to 50 request ids. An id sent twice is handled once."
    }
   ]
  },
  {
   "id": "LeaveDeclineItem",
   "name": "LeaveDeclineItem",
   "group": "people",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "required",
     "note": "A request id."
    },
    {
     "name": "note",
     "type": "string",
     "flags": "required",
     "note": "The reason the person reads; cut to 500 characters."
    }
   ]
  },
  {
   "id": "LeaveDeclineMany",
   "name": "LeaveDeclineMany",
   "group": "people",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "items",
     "type": "LeaveDeclineItem[]",
     "flags": "required",
     "note": "1 to 50 items. When any item has no note, the whole call is refused with 400 and nothing is declined."
    }
   ]
  },
  {
   "id": "FeedbackRequest",
   "name": "FeedbackRequest",
   "group": "people",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "userIds",
     "type": "integer[]",
     "flags": "required",
     "note": "Active colleagues; not the person reviewed and not the reviewer."
    }
   ]
  },
  {
   "id": "ReviewerReassign",
   "name": "ReviewerReassign",
   "group": "people",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "reviewerId",
     "type": "integer",
     "flags": "required",
     "note": "An active person other than the person reviewed."
    }
   ]
  },
  {
   "id": "TrainingComplete",
   "name": "TrainingComplete",
   "group": "people",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "acknowledged",
     "type": "boolean",
     "flags": "optional",
     "note": "Must be true when you finish your own item that asks you to confirm you have read it."
    },
    {
     "name": "reason",
     "type": "string",
     "flags": "optional",
     "note": "Read when HR marks an item done for someone else; cut to 500 characters."
    }
   ]
  },
  {
   "id": "LibraryAdopt",
   "name": "LibraryAdopt",
   "group": "people",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "replace",
     "type": "boolean",
     "flags": "optional",
     "note": "true gives a training item you already have under the same title the library text and knowledge check. Only the value true counts."
    }
   ]
  },
  {
   "id": "TrainingAssign",
   "name": "TrainingAssign",
   "group": "people",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "audience",
     "type": "string: everyone | department | people",
     "flags": "required",
     "note": "Any other value is refused with 400."
    },
    {
     "name": "department",
     "type": "string",
     "flags": "required for department",
     "note": ""
    },
    {
     "name": "userIds",
     "type": "integer[]",
     "flags": "required for people",
     "note": "At most 2,000 ids."
    },
    {
     "name": "dueDate",
     "type": "string (date)",
     "flags": "optional",
     "note": "Default: the item's days to finish, counted from today."
    }
   ]
  },
  {
   "id": "TrainingBatchFilter",
   "name": "TrainingBatchFilter",
   "group": "people",
   "kind": "request",
   "note": "Selects every assignment on the roster that matches, in place of a list of ids.",
   "extends": null,
   "fields": [
    {
     "name": "courseId",
     "type": "integer",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "department",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "status",
     "type": "string",
     "flags": "optional",
     "note": ""
    }
   ]
  },
  {
   "id": "TrainingBatchAction",
   "name": "TrainingBatchAction",
   "group": "people",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "action",
     "type": "string: remove | due | remind | complete",
     "flags": "required",
     "note": "Any other value is refused with 400."
    },
    {
     "name": "ids",
     "type": "integer[]",
     "flags": "optional",
     "note": "At most 2,000 assignment ids. When empty or left out, filter is read."
    },
    {
     "name": "filter",
     "type": "TrainingBatchFilter",
     "flags": "optional",
     "note": "Read only when ids is empty or left out."
    },
    {
     "name": "dueDate",
     "type": "string (date)",
     "flags": "required for due",
     "note": ""
    },
    {
     "name": "reason",
     "type": "string",
     "flags": "optional",
     "note": "Cut to 500 characters. Needed to remove open security-awareness training from someone, or to move its deadline later; without it that assignment is listed in skipped."
    }
   ]
  },
  {
   "id": "CompletionRow",
   "name": "CompletionRow",
   "group": "people",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "email",
     "type": "string",
     "flags": "required",
     "note": "Matched to an active person without regard to letter case. An address listed twice is used once."
    },
    {
     "name": "completedAt",
     "type": "string (date)",
     "flags": "required",
     "note": "YYYY-MM-DD; not in the future and not more than 5 years ago."
    }
   ]
  },
  {
   "id": "CompletionsImport",
   "name": "CompletionsImport",
   "group": "people",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "source",
     "type": "string",
     "flags": "required",
     "note": "Where the completions come from; at least 3 characters, cut to 200."
    },
    {
     "name": "rows",
     "type": "CompletionRow[]",
     "flags": "required",
     "note": "1 to 5,000 rows. A row that cannot be used is listed in skipped with its reason."
    }
   ]
  },
  {
   "id": "PathMembersAdd",
   "name": "PathMembersAdd",
   "group": "people",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "userIds",
     "type": "integer[]",
     "flags": "optional",
     "note": "At most 500 people. Send this or userId."
    },
    {
     "name": "userId",
     "type": "integer",
     "flags": "optional",
     "note": "One person; read only when userIds is not a list."
    }
   ]
  },
  {
   "id": "PhishingReport",
   "name": "PhishingReport",
   "group": "people",
   "kind": "request",
   "note": "Says which practice email you are reporting. Only your own practice email can match; nothing you send is stored.",
   "extends": null,
   "fields": [
    {
     "name": "token",
     "type": "string",
     "flags": "optional",
     "note": "The link in the email, or the 64-character code at the end of it."
    },
    {
     "name": "campaign",
     "type": "integer",
     "flags": "optional",
     "note": "A simulation id; read when token is absent or not a valid code."
    }
   ]
  },
  {
   "id": "JobPosting",
   "name": "JobPosting",
   "group": "hiring",
   "kind": "response",
   "note": "A requisition.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "title",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "description",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "department",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "status",
     "type": "string: Draft | Open | Published | Closed | Filled | On Hold",
     "flags": "",
     "note": "Only Published roles are on the careers site."
    },
    {
     "name": "financeApprovalStatus",
     "type": "string: Pending | Approved | Rejected",
     "flags": "read-only",
     "note": "The headcount budget gate."
    },
    {
     "name": "location",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "employmentType",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "seniority",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "workMode",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "minExperienceMonths",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "currency",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "salaryRange",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "salaryMin",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "salaryMax",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "payPeriod",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "competencies",
     "type": "array",
     "flags": "",
     "note": ""
    },
    {
     "name": "requirements",
     "type": "array",
     "flags": "",
     "note": ""
    },
    {
     "name": "applicationQuestions",
     "type": "object[]",
     "flags": "",
     "note": "Each has id, label, type, required and options on the careers site."
    },
    {
     "name": "hiringManagerId",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "ownerId",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "personaId",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "createdById",
     "type": "integer",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "financeApprovedById",
     "type": "integer",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "financeApprovedAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "financeRejectionReason",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "financeRequestedAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "isTalentPool",
     "type": "boolean",
     "flags": "read-only",
     "note": "true only for the talent-pool requisition, which the list leaves out."
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "updatedAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "candidateCount",
     "type": "integer",
     "flags": "read-only",
     "note": "In the list only: every applicant, not the preview."
    },
    {
     "name": "stageCounts",
     "type": "object",
     "flags": "nullable, read-only",
     "note": "In the list only: { \"<STAGE IN CAPITALS>\": integer }; null when the role has no candidates."
    },
    {
     "name": "Candidates",
     "type": "object[]",
     "flags": "read-only",
     "note": "In the list only: the newest applicants in your own candidate scope, each { id, firstName, lastName, email, stage, matchScore, jobPostingId }."
    },
    {
     "name": "HiringManager",
     "type": "{ id:integer, firstName:string|null, lastName:string|null, email:string }",
     "flags": "nullable, read-only",
     "note": "In the list only. Owner, Creator and FinanceApprover have the same shape."
    },
    {
     "name": "OfferTemplates",
     "type": "object[]",
     "flags": "read-only",
     "note": "In the list only: each { id:integer, name:string, createdAt }."
    },
    {
     "name": "InterviewTemplates",
     "type": "object[]",
     "flags": "read-only",
     "note": "In the list only: each { id:integer, name:string, roundName:string|null, createdAt }."
    }
   ]
  },
  {
   "id": "Candidate",
   "name": "Candidate",
   "group": "hiring",
   "kind": "response",
   "note": "One application to one requisition.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "firstName",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "lastName",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "email",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "phoneNumber",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "resumeUrl",
     "type": "string",
     "flags": "nullable",
     "note": "The stored file name; fetch the file with GET /recruitment/candidates/:id/resume."
    },
    {
     "name": "source",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "stage",
     "type": "string: Sourced | Screen | Interview | Offer | Hired | Rejected",
     "flags": "",
     "note": ""
    },
    {
     "name": "matchScore",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "aiSummary",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "scoreSource",
     "type": "string",
     "flags": "nullable",
     "note": "processing until the first screen has finished."
    },
    {
     "name": "calibrationResult",
     "type": "object",
     "flags": "nullable",
     "note": "null for an interviewer who has not yet submitted their own scorecard."
    },
    {
     "name": "parsedProfile",
     "type": "object",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "rejectionEmailSent",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "onboardingData",
     "type": "object",
     "flags": "",
     "note": ""
    },
    {
     "name": "applicationAnswers",
     "type": "array",
     "flags": "",
     "note": ""
    },
    {
     "name": "knockoutFlag",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "knockoutReasons",
     "type": "array",
     "flags": "",
     "note": ""
    },
    {
     "name": "jobPostingId",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "userId",
     "type": "integer",
     "flags": "nullable",
     "note": "The linked user account, when there is one."
    },
    {
     "name": "ownerId",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "consentAt",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "humanReviewRequested",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "aiNoticeAckAt",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "tags",
     "type": "string[]",
     "flags": "",
     "note": "Each up to 40 characters."
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "updatedAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "technicalFitScore",
     "type": "integer",
     "flags": "nullable, read-only",
     "note": "In the list only: 0 to 100 from the completed scorecards; null without one."
    },
    {
     "name": "Job",
     "type": "JobPosting",
     "flags": "read-only",
     "note": "In the list only, with HiringManager."
    },
    {
     "name": "Owner",
     "type": "{ id:integer, firstName:string|null, lastName:string|null, email:string }",
     "flags": "nullable, read-only",
     "note": "In the list only."
    },
    {
     "name": "Offer",
     "type": "Offer",
     "flags": "nullable, read-only",
     "note": "In the list only."
    },
    {
     "name": "Interviews",
     "type": "Interview[]",
     "flags": "read-only",
     "note": "In the list only."
    }
   ]
  },
  {
   "id": "Offer",
   "name": "Offer",
   "group": "hiring",
   "kind": "response",
   "note": "A candidate has at most one. The signing link and the candidate-portal link are never part of a reply.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "candidateId",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "salary",
     "type": "number",
     "flags": "nullable",
     "note": "A number in the candidate list, for a caller who may see offer pay; null for anyone else. null in the reply to a write on the offer itself."
    },
    {
     "name": "signOnBonus",
     "type": "string",
     "flags": "nullable",
     "note": "In the candidate list only, under the same rule as salary."
    },
    {
     "name": "currency",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "payPeriod",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "level",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "targetBonusPct",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "startDate",
     "type": "string (date)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "status",
     "type": "string: Draft | Sent | Accepted | Declined",
     "flags": "",
     "note": ""
    },
    {
     "name": "declineReason",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "signedAt",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "signedManuallyBy",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "approvalStatus",
     "type": "string: Pending | Approved | Rejected",
     "flags": "",
     "note": ""
    },
    {
     "name": "approverId",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "termsSetBy",
     "type": "integer",
     "flags": "nullable",
     "note": "The person an approval is refused from."
    },
    {
     "name": "offerTemplateId",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "supportingDocs",
     "type": "object[]",
     "flags": "",
     "note": ""
    },
    {
     "name": "documentUrl",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "docusealSubmissionId",
     "type": "string",
     "flags": "nullable",
     "note": "The signing submission, once the offer has been sent."
    },
    {
     "name": "rescindStatus",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "rescindRequestedBy",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "rescindReason",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "rescindApprovedBy",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "tokenExpiresAt",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "updatedAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "Template",
     "type": "OfferTemplate",
     "flags": "nullable, read-only",
     "note": "In the candidate list only."
    },
    {
     "name": "Approver",
     "type": "{ id:integer, firstName:string|null, lastName:string|null, email:string }",
     "flags": "nullable, read-only",
     "note": "In the candidate list only."
    }
   ]
  },
  {
   "id": "Interview",
   "name": "Interview",
   "group": "hiring",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "candidateId",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "interviewerId",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "scheduledAt",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": "null while the candidate has not yet picked one of the proposed times."
    },
    {
     "name": "feedback",
     "type": "string",
     "flags": "nullable",
     "note": "\"Hidden from your view\" on another interviewer's row until you have submitted your own scorecard."
    },
    {
     "name": "structuredFeedback",
     "type": "object",
     "flags": "nullable",
     "note": "null on a masked row."
    },
    {
     "name": "score",
     "type": "integer",
     "flags": "nullable",
     "note": "1 to 5; null on a masked row."
    },
    {
     "name": "status",
     "type": "string: Scheduled | Completed | Cancelled",
     "flags": "",
     "note": ""
    },
    {
     "name": "externalEventId",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "icsSequence",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "cancelledAt",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "cancelReason",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "sequence",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "isShadow",
     "type": "boolean",
     "flags": "nullable",
     "note": "A shadow interviewer's score does not count towards technicalFitScore."
    },
    {
     "name": "interviewTemplateId",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "proposedSlots",
     "type": "array",
     "flags": "",
     "note": "The times offered for self-scheduling."
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "updatedAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "masked",
     "type": "boolean",
     "flags": "optional",
     "note": "true on a row whose score and feedback were hidden from you."
    },
    {
     "name": "Interviewer",
     "type": "{ id:integer, firstName:string|null, lastName:string|null, email:string }",
     "flags": "nullable, read-only",
     "note": "In the candidate list only."
    },
    {
     "name": "Template",
     "type": "InterviewTemplate",
     "flags": "nullable, read-only",
     "note": "In the candidate list only, with its Questions."
    }
   ]
  },
  {
   "id": "InterviewTemplate",
   "name": "InterviewTemplate",
   "group": "hiring",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "name",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "description",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "roundName",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "jobPostingId",
     "type": "integer",
     "flags": "nullable",
     "note": "null for a template that is not tied to one requisition."
    },
    {
     "name": "sourceJobId",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "sourceJobTitle",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "updatedAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "Questions",
     "type": "InterviewQuestion[]",
     "flags": "",
     "note": "In order."
    },
    {
     "name": "usageCount",
     "type": "integer",
     "flags": "read-only",
     "note": "In the list only."
    }
   ]
  },
  {
   "id": "InterviewQuestion",
   "name": "InterviewQuestion",
   "group": "hiring",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "interviewTemplateId",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "questionText",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "category",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "context",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "idealAnswer",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "weight",
     "type": "integer",
     "flags": "nullable",
     "note": "Default 1."
    },
    {
     "name": "mustHave",
     "type": "boolean",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "anchors",
     "type": "object",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "order",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "updatedAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    }
   ]
  },
  {
   "id": "OfferTemplate",
   "name": "OfferTemplate",
   "group": "hiring",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "name",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "body",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "docusealTemplateId",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "jobPostingId",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "sourceJobId",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "sourceJobTitle",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "supportingDocs",
     "type": "object[]",
     "flags": "",
     "note": ""
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "updatedAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "usageCount",
     "type": "integer",
     "flags": "read-only",
     "note": "In the list only."
    }
   ]
  },
  {
   "id": "CandidateNote",
   "name": "CandidateNote",
   "group": "hiring",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "candidateId",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "authorId",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "body",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "mentions",
     "type": "array",
     "flags": "",
     "note": ""
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "updatedAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "Author",
     "type": "{ id:integer, firstName:string|null, lastName:string|null, email:string }",
     "flags": "nullable, read-only",
     "note": "In the list only."
    }
   ]
  },
  {
   "id": "CandidateMessage",
   "name": "CandidateMessage",
   "group": "hiring",
   "kind": "response",
   "note": "One email to or from a candidate, as the message list returns it.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "direction",
     "type": "string: outbound | inbound",
     "flags": "",
     "note": ""
    },
    {
     "name": "fromAddr",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "toAddr",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "subject",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "body",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "sentById",
     "type": "integer",
     "flags": "nullable",
     "note": "The user who sent an outbound message."
    }
   ]
  },
  {
   "id": "Referral",
   "name": "Referral",
   "group": "hiring",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "userId",
     "type": "integer",
     "flags": "",
     "note": "The employee who made the referral."
    },
    {
     "name": "candidateId",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "notes",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "status",
     "type": "string: Pending | Reviewed | Hired | Rejected",
     "flags": "",
     "note": ""
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "updatedAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "Candidate",
     "type": "object",
     "flags": "read-only",
     "note": "In the list only: { id, firstName, lastName, stage, source, createdAt, Job:{ id, title, department } }."
    }
   ]
  },
  {
   "id": "JobPostingWrite",
   "name": "JobPostingWrite",
   "group": "hiring",
   "kind": "request",
   "note": "Create and update take the same keys; an update changes only the keys you send. The budget-approval fields and the creator are set by the server and are not read from the body.",
   "extends": null,
   "fields": [
    {
     "name": "title",
     "type": "string",
     "flags": "required on create",
     "note": ""
    },
    {
     "name": "description",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "department",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "status",
     "type": "string: Draft | Open | Published | Closed | Filled | On Hold",
     "flags": "optional",
     "note": "Open and Published are refused with 409 until the headcount budget is approved."
    },
    {
     "name": "location",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "employmentType",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "seniority",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "workMode",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "minExperienceMonths",
     "type": "integer",
     "flags": "optional, null clears it",
     "note": "A number, or text that is a number: rounded, 0 to 2147483647. Blank is stored as empty; anything else answers 400 INVALID_NUMBER."
    },
    {
     "name": "currency",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "salaryRange",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "salaryMin",
     "type": "integer",
     "flags": "optional, null clears it",
     "note": "Read the same way as minExperienceMonths."
    },
    {
     "name": "salaryMax",
     "type": "integer",
     "flags": "optional, null clears it",
     "note": "Read the same way as minExperienceMonths."
    },
    {
     "name": "payPeriod",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "competencies",
     "type": "array",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "requirements",
     "type": "array",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "applicationQuestions",
     "type": "object[]",
     "flags": "optional",
     "note": "The extra questions on the public application form."
    },
    {
     "name": "hiringManagerId",
     "type": "integer",
     "flags": "optional, null clears it",
     "note": ""
    },
    {
     "name": "ownerId",
     "type": "integer",
     "flags": "optional, null clears it",
     "note": ""
    },
    {
     "name": "personaId",
     "type": "integer",
     "flags": "optional, null clears it",
     "note": "Assigning a persona is checked against what you are allowed to grant."
    }
   ]
  },
  {
   "id": "CandidateCreate",
   "name": "CandidateCreate",
   "group": "hiring",
   "kind": "request",
   "note": "A candidate added by a signed-in recruiter. The stage, the scores and the consent fields are set by the server.",
   "extends": null,
   "fields": [
    {
     "name": "jobPostingId",
     "type": "integer",
     "flags": "required",
     "note": ""
    },
    {
     "name": "email",
     "type": "string (email)",
     "flags": "required",
     "note": "Stored in one normalised spelling. The same email on the same requisition answers 409."
    },
    {
     "name": "firstName",
     "type": "string",
     "flags": "required",
     "note": ""
    },
    {
     "name": "lastName",
     "type": "string",
     "flags": "required",
     "note": ""
    },
    {
     "name": "phoneNumber",
     "type": "string",
     "flags": "optional",
     "note": "Stored encrypted."
    },
    {
     "name": "source",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "ownerId",
     "type": "integer",
     "flags": "optional",
     "note": "Read only from a caller who holds manage_recruitment. Otherwise the candidate takes the owner of the requisition, or you when it has none."
    }
   ]
  },
  {
   "id": "CandidateBulkAction",
   "name": "CandidateBulkAction",
   "group": "hiring",
   "kind": "request",
   "note": "One action for many candidates. Each candidate is handled on its own and lands in succeeded, skipped or failed.",
   "extends": null,
   "fields": [
    {
     "name": "ids",
     "type": "integer[]",
     "flags": "required",
     "note": "1 to 200 candidate ids."
    },
    {
     "name": "action",
     "type": "string: stage | reject | reassign | tag",
     "flags": "required",
     "note": ""
    },
    {
     "name": "stage",
     "type": "string: Sourced | Screen | Interview | Offer | Hired | Rejected",
     "flags": "required for stage",
     "note": "Any capitalisation."
    },
    {
     "name": "sendEmail",
     "type": "boolean",
     "flags": "optional",
     "note": "reject only: the rejection email is sent unless this is false."
    },
    {
     "name": "reason",
     "type": "string",
     "flags": "optional",
     "note": "reject only."
    },
    {
     "name": "ownerId",
     "type": "integer",
     "flags": "optional, null clears it",
     "note": "reassign only."
    },
    {
     "name": "add",
     "type": "string[]",
     "flags": "optional",
     "note": "tag only: labels to add."
    },
    {
     "name": "remove",
     "type": "string[]",
     "flags": "optional",
     "note": "tag only: labels to remove."
    }
   ]
  },
  {
   "id": "CandidateTagsUpdate",
   "name": "CandidateTagsUpdate",
   "group": "hiring",
   "kind": "request",
   "note": "A label is trimmed and cut to 40 characters; labels that differ only in capitalisation count as one; a candidate keeps at most 30.",
   "extends": null,
   "fields": [
    {
     "name": "add",
     "type": "string[]",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "remove",
     "type": "string[]",
     "flags": "optional",
     "note": "Matched without regard to capitalisation."
    }
   ]
  },
  {
   "id": "CandidateNoteCreate",
   "name": "CandidateNoteCreate",
   "group": "hiring",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "body",
     "type": "string",
     "flags": "required",
     "note": "Trimmed and cut to 5000 characters; empty answers 400."
    },
    {
     "name": "mentions",
     "type": "integer[]",
     "flags": "optional",
     "note": "User ids to notify. Values that are not positive whole numbers, and repeats, are dropped."
    }
   ]
  },
  {
   "id": "CandidateMessageSend",
   "name": "CandidateMessageSend",
   "group": "hiring",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "subject",
     "type": "string",
     "flags": "required",
     "note": "Trimmed and cut to 500 characters."
    },
    {
     "name": "body",
     "type": "string",
     "flags": "required",
     "note": "Trimmed. A blank line starts a new paragraph in the email."
    }
   ]
  },
  {
   "id": "InterviewCreate",
   "name": "InterviewCreate",
   "group": "hiring",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "interviewerId",
     "type": "integer",
     "flags": "required",
     "note": "A person who is allowed to conduct interviews."
    },
    {
     "name": "scheduledAt",
     "type": "string (date-time)",
     "flags": "optional, null clears it",
     "note": "Text that is not a date answers 400 INVALID_TIME. Without it the interview is created with no time."
    },
    {
     "name": "sequence",
     "type": "integer",
     "flags": "optional",
     "note": "The round number. Default 1."
    },
    {
     "name": "interviewTemplateId",
     "type": "integer",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "isShadow",
     "type": "boolean",
     "flags": "optional",
     "note": "Default false."
    },
    {
     "name": "durationMins",
     "type": "integer",
     "flags": "optional",
     "note": "Used when checking the interviewer's other bookings."
    },
    {
     "name": "allowOverlap",
     "type": "boolean",
     "flags": "optional",
     "note": "true books the time although the interviewer already has an interview then."
    }
   ]
  },
  {
   "id": "InterviewSlotsPropose",
   "name": "InterviewSlotsPropose",
   "group": "hiring",
   "kind": "request",
   "note": "Offers the candidate a choice of times. Times in the past, times that are not dates and repeats are dropped, and the first 10 are kept.",
   "extends": null,
   "fields": [
    {
     "name": "interviewerId",
     "type": "integer",
     "flags": "required",
     "note": "400 INTERVIEWER_REQUIRED without it."
    },
    {
     "name": "slots",
     "type": "string (date-time)[]",
     "flags": "required",
     "note": "At least two usable times, or the answer is 400 NOT_ENOUGH_SLOTS."
    },
    {
     "name": "interviewTemplateId",
     "type": "integer",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "durationMins",
     "type": "integer",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "allowOverlap",
     "type": "boolean",
     "flags": "optional",
     "note": "Without it, times that clash with the interviewer's bookings are dropped, and fewer than two left answers 409 DOUBLE_BOOKED."
    },
    {
     "name": "isShadow",
     "type": "boolean",
     "flags": "optional",
     "note": "Default false."
    }
   ]
  },
  {
   "id": "ScorecardSubmit",
   "name": "ScorecardSubmit",
   "group": "hiring",
   "kind": "request",
   "note": "Marks the interview Completed.",
   "extends": null,
   "fields": [
    {
     "name": "score",
     "type": "integer",
     "flags": "optional",
     "note": "1 to 5; anything else answers 400 INVALID_SCORE."
    },
    {
     "name": "feedback",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "structuredFeedback",
     "type": "object",
     "flags": "optional",
     "note": "Your marks and notes per competency. The recommendation is carried inside it."
    }
   ]
  },
  {
   "id": "InterviewQuestionInput",
   "name": "InterviewQuestionInput",
   "group": "hiring",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "questionText",
     "type": "string",
     "flags": "required",
     "note": ""
    },
    {
     "name": "category",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "context",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "idealAnswer",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "weight",
     "type": "integer",
     "flags": "optional",
     "note": "Default 1."
    },
    {
     "name": "mustHave",
     "type": "boolean",
     "flags": "optional",
     "note": "Default false."
    },
    {
     "name": "anchors",
     "type": "object",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "order",
     "type": "integer",
     "flags": "optional",
     "note": "Defaults to the position in the list."
    }
   ]
  },
  {
   "id": "InterviewTemplateWrite",
   "name": "InterviewTemplateWrite",
   "group": "hiring",
   "kind": "request",
   "note": "Create and update take the same keys. On an update, sending questions replaces the question list; leaving it out keeps the questions as they are.",
   "extends": null,
   "fields": [
    {
     "name": "name",
     "type": "string",
     "flags": "required on create",
     "note": ""
    },
    {
     "name": "description",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "roundName",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "jobPostingId",
     "type": "integer",
     "flags": "optional, null clears it",
     "note": "null makes the template available to every requisition."
    },
    {
     "name": "questions",
     "type": "InterviewQuestionInput[]",
     "flags": "optional",
     "note": ""
    }
   ]
  },
  {
   "id": "OfferTemplateWrite",
   "name": "OfferTemplateWrite",
   "group": "hiring",
   "kind": "request",
   "note": "Create and update take the same keys; an update changes only the keys you send.",
   "extends": null,
   "fields": [
    {
     "name": "name",
     "type": "string",
     "flags": "required on create",
     "note": ""
    },
    {
     "name": "body",
     "type": "string",
     "flags": "required on create",
     "note": "The text of the offer letter."
    },
    {
     "name": "jobPostingId",
     "type": "integer",
     "flags": "optional, null clears it",
     "note": "null makes the template available to every requisition."
    },
    {
     "name": "supportingDocs",
     "type": "object[]",
     "flags": "optional",
     "note": "At most 20. A link is { type:\"link\", label, url } with an http or https address; an uploaded file is { type:\"file\", label, storedName, filename?, size? } as POST /recruitment/offer-documents returns it. An entry without a label, or with an address or file name that does not pass the check, is dropped."
    }
   ]
  },
  {
   "id": "OfferTermsWrite",
   "name": "OfferTermsWrite",
   "group": "hiring",
   "kind": "request",
   "note": "The terms of a candidate's offer. Only the keys you send are changed; an empty text or null clears a value. Changing the terms of an approved offer sends it back for approval.",
   "extends": null,
   "fields": [
    {
     "name": "salary",
     "type": "number",
     "flags": "optional, null clears it",
     "note": "Not negative, or the answer is 400."
    },
    {
     "name": "currency",
     "type": "string",
     "flags": "optional, null clears it",
     "note": ""
    },
    {
     "name": "payPeriod",
     "type": "string",
     "flags": "optional, null clears it",
     "note": ""
    },
    {
     "name": "startDate",
     "type": "string (date)",
     "flags": "optional",
     "note": "YYYY-MM-DD, or the answer is 400."
    },
    {
     "name": "level",
     "type": "string",
     "flags": "optional, null clears it",
     "note": ""
    },
    {
     "name": "targetBonusPct",
     "type": "integer",
     "flags": "optional, null clears it",
     "note": "0 to 100; rounded to a whole number."
    },
    {
     "name": "signOnBonus",
     "type": "number",
     "flags": "optional, null clears it",
     "note": "Not negative."
    },
    {
     "name": "hiringManagerId",
     "type": "integer",
     "flags": "optional, null clears it",
     "note": "Written to the requisition, not the offer."
    }
   ]
  },
  {
   "id": "OfferBulkDecision",
   "name": "OfferBulkDecision",
   "group": "hiring",
   "kind": "request",
   "note": "Approve or refuse many offers. Each one goes through the same checks as the single routes and lands in succeeded or failed.",
   "extends": null,
   "fields": [
    {
     "name": "ids",
     "type": "integer[]",
     "flags": "required",
     "note": "1 to 200 candidate ids."
    },
    {
     "name": "approve",
     "type": "boolean",
     "flags": "required",
     "note": "Only true approves; any other value refuses."
    },
    {
     "name": "reason",
     "type": "string",
     "flags": "optional",
     "note": "Stored with a refusal."
    },
    {
     "name": "termsVersions",
     "type": "object",
     "flags": "optional",
     "note": "{ \"<candidate id>\": string }: the version of the terms you saw for each offer, cut to 64 characters. An offer whose terms changed since lands in failed."
    }
   ]
  },
  {
   "id": "RequisitionOwnerWrite",
   "name": "RequisitionOwnerWrite",
   "group": "hiring",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "ownerId",
     "type": "integer",
     "flags": "optional, null clears it",
     "note": "A user id. null, 0 or a missing key leaves the requisition without an owner."
    },
    {
     "name": "cascade",
     "type": "boolean",
     "flags": "optional",
     "note": "true also moves the requisition's candidates that belonged to the previous owner or had none. A candidate given to someone else by hand keeps that owner."
    }
   ]
  },
  {
   "id": "CandidateOwnerWrite",
   "name": "CandidateOwnerWrite",
   "group": "hiring",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "ownerId",
     "type": "integer",
     "flags": "optional, null clears it",
     "note": "A user id. null, 0 or a missing key leaves the candidate without an owner."
    }
   ]
  },
  {
   "id": "BudgetRefusal",
   "name": "BudgetRefusal",
   "group": "hiring",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "reason",
     "type": "string",
     "flags": "required",
     "note": "Trimmed; at least 3 characters, or the answer is 400 with code REASON_REQUIRED. Cut to 1000 characters and stored on the requisition."
    }
   ]
  },
  {
   "id": "CompetencySuggest",
   "name": "CompetencySuggest",
   "group": "hiring",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "jobTitle",
     "type": "string",
     "flags": "required",
     "note": "Blank is refused with 400."
    },
    {
     "name": "jobDescription",
     "type": "string",
     "flags": "optional",
     "note": ""
    }
   ]
  },
  {
   "id": "JobDescriptionSuggest",
   "name": "JobDescriptionSuggest",
   "group": "hiring",
   "kind": "request",
   "note": "The whole body is passed to the model as context, so the other fields of the requisition form may be sent with it.",
   "extends": null,
   "fields": [
    {
     "name": "jobTitle",
     "type": "string",
     "flags": "required",
     "note": "Blank is refused with 400."
    }
   ]
  },
  {
   "id": "CandidateStageWrite",
   "name": "CandidateStageWrite",
   "group": "hiring",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "stage",
     "type": "string",
     "flags": "required",
     "note": "Sourced, Screen, Interview, Offer, Hired or Rejected, in any capitalisation."
    }
   ]
  },
  {
   "id": "CandidateReject",
   "name": "CandidateReject",
   "group": "hiring",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "sendEmail",
     "type": "boolean",
     "flags": "optional",
     "note": "Whether the candidate is sent the rejection email."
    },
    {
     "name": "reason",
     "type": "string",
     "flags": "optional",
     "note": ""
    }
   ]
  },
  {
   "id": "InterviewReschedule",
   "name": "InterviewReschedule",
   "group": "hiring",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "scheduledAt",
     "type": "string (date-time)",
     "flags": "required",
     "note": "Text that is not a date is refused with 400, code INVALID_TIME."
    },
    {
     "name": "durationMins",
     "type": "integer",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "allowOverlap",
     "type": "boolean",
     "flags": "optional",
     "note": "Only the value true books the time although the interviewer already has an interview then."
    }
   ]
  },
  {
   "id": "InterviewCancel",
   "name": "InterviewCancel",
   "group": "hiring",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "reason",
     "type": "string",
     "flags": "optional",
     "note": "Cut to 500 characters."
    }
   ]
  },
  {
   "id": "DraftForRequisition",
   "name": "DraftForRequisition",
   "group": "hiring",
   "kind": "request",
   "note": "Asks for a generated draft: interview questions, or an offer template.",
   "extends": null,
   "fields": [
    {
     "name": "jobPostingId",
     "type": "integer",
     "flags": "optional",
     "note": "The requisition used as context. Without it the draft is written for a general role."
    }
   ]
  },
  {
   "id": "OfferGatePolicyWrite",
   "name": "OfferGatePolicyWrite",
   "group": "hiring",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "requireScorecardsBeforeOffer",
     "type": "boolean",
     "flags": "required",
     "note": "Anything that is not true or false is refused with 400."
    }
   ]
  },
  {
   "id": "OfferApprove",
   "name": "OfferApprove",
   "group": "hiring",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "termsVersion",
     "type": "string",
     "flags": "optional",
     "note": "The version of the terms the approver saw; cut to 64 characters. When it no longer matches the stored terms the answer is 409, code OFFER_TERMS_CHANGED."
    }
   ]
  },
  {
   "id": "OfferReject",
   "name": "OfferReject",
   "group": "hiring",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "reason",
     "type": "string",
     "flags": "optional",
     "note": ""
    }
   ]
  },
  {
   "id": "OfferDeclineReason",
   "name": "OfferDeclineReason",
   "group": "hiring",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "reason",
     "type": "string",
     "flags": "optional",
     "note": "Trimmed and cut to 2000 characters. Blank, or anything that is not text, clears the stored reason."
    }
   ]
  },
  {
   "id": "OfferRescindRequest",
   "name": "OfferRescindRequest",
   "group": "hiring",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "reason",
     "type": "string",
     "flags": "required",
     "note": "Blank is refused with 400, code REASON_REQUIRED."
    }
   ]
  },
  {
   "id": "OfferSend",
   "name": "OfferSend",
   "group": "hiring",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "offerTemplateId",
     "type": "integer | null",
     "flags": "optional",
     "note": "The offer template the contract is generated from. null sends the offer with no template; a missing key keeps the template already on the offer."
    }
   ]
  },
  {
   "id": "AssistantQuery",
   "name": "AssistantQuery",
   "group": "hiring",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "query",
     "type": "string",
     "flags": "required",
     "note": "The question. The streaming route refuses a blank one with 400."
    }
   ]
  },
  {
   "id": "FeedbackRefine",
   "name": "FeedbackRefine",
   "group": "hiring",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "text",
     "type": "string",
     "flags": "required",
     "note": "The feedback text to tidy."
    }
   ]
  },
  {
   "id": "JobAlertSubscribe",
   "name": "JobAlertSubscribe",
   "group": "hiring",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "email",
     "type": "string",
     "flags": "required",
     "note": "Trimmed, lower-cased and cut to 200 characters; must look like an address."
    },
    {
     "name": "consent",
     "type": "boolean",
     "flags": "required",
     "note": "Must be true."
    },
    {
     "name": "department",
     "type": "string",
     "flags": "optional",
     "note": "Cut to 120 characters. Without it the alert covers every department."
    },
    {
     "name": "locale",
     "type": "string",
     "flags": "optional",
     "note": "Cut to 8 characters."
    }
   ]
  },
  {
   "id": "JobAlertToken",
   "name": "JobAlertToken",
   "group": "hiring",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "token",
     "type": "string",
     "flags": "required",
     "note": "The token from the link in the email."
    }
   ]
  },
  {
   "id": "SelfScheduleChoice",
   "name": "SelfScheduleChoice",
   "group": "hiring",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "chosen",
     "type": "string (date-time)",
     "flags": "required",
     "note": "One of the times offered by the GET on the same path; any other time is refused with 400, code SLOT_NOT_OFFERED."
    }
   ]
  },
  {
   "id": "Invoice",
   "name": "Invoice",
   "group": "finance",
   "kind": "response",
   "note": "One row of GET /parser/invoices.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "db_id",
     "type": "integer",
     "flags": "",
     "note": "The same number as id; the path parameter of every route that takes :doc_id."
    },
    {
     "name": "displayId",
     "type": "string",
     "flags": "",
     "note": "INV- followed by the id, padded to three digits."
    },
    {
     "name": "filename",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "vendor",
     "type": "string",
     "flags": "nullable",
     "note": "The vendor name as read from the document."
    },
    {
     "name": "canonicalVendor",
     "type": "string",
     "flags": "nullable",
     "note": "The name of the vendor record it is linked to; otherwise the same as vendor."
    },
    {
     "name": "vendorId",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "amount",
     "type": "number",
     "flags": "",
     "note": "In the invoice's own currency; 0 when none was read."
    },
    {
     "name": "currency",
     "type": "string",
     "flags": "",
     "note": "A 3-letter code; USD when none is stored."
    },
    {
     "name": "currencyDefaulted",
     "type": "boolean",
     "flags": "",
     "note": "true when no currency was on the document and your base currency was assumed."
    },
    {
     "name": "amountBase",
     "type": "number",
     "flags": "",
     "note": "The amount in your base currency; the entered amount when no conversion is stored."
    },
    {
     "name": "date",
     "type": "string",
     "flags": "",
     "note": "A display date such as \"Oct 10, 2026\", or \"N/A\". Use dateIso in code."
    },
    {
     "name": "dateIso",
     "type": "string (date-time)",
     "flags": "",
     "note": ""
    },
    {
     "name": "renewalDate",
     "type": "string",
     "flags": "",
     "note": "A display date, or \"N/A\"."
    },
    {
     "name": "category",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "costCenter",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "department",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "project",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "status",
     "type": "string",
     "flags": "",
     "note": "The payment status; Pending when none is stored."
    },
    {
     "name": "approvalStatus",
     "type": "string",
     "flags": "",
     "note": "Draft when none is stored. See the approval states on the Finance Operations page."
    },
    {
     "name": "extractionConfidence",
     "type": "number",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "invoiceNumber",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "taxAmount",
     "type": "number",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "poNumber",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "glCode",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "customFields",
     "type": "object",
     "flags": "",
     "note": "Your own allocation fields; {} when none is set."
    },
    {
     "name": "matchStatus",
     "type": "string",
     "flags": "",
     "note": "unmatched when no match has run."
    },
    {
     "name": "submittedBy",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "approvedBy",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "paidBy",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "rejectionReason",
     "type": "string",
     "flags": "nullable",
     "note": ""
    }
   ]
  },
  {
   "id": "InvoicePage",
   "name": "InvoicePage",
   "group": "finance",
   "kind": "response",
   "note": "The reply of GET /parser/invoices. Page-numbered: page and limit, no offset.",
   "extends": null,
   "fields": [
    {
     "name": "items",
     "type": "Invoice[]",
     "flags": "",
     "note": ""
    },
    {
     "name": "total",
     "type": "integer",
     "flags": "",
     "note": "Every invoice matching the filters, not the page."
    },
    {
     "name": "page",
     "type": "integer",
     "flags": "",
     "note": "Starts at 1."
    },
    {
     "name": "limit",
     "type": "integer",
     "flags": "",
     "note": "Default 25, 1 to 200."
    },
    {
     "name": "pages",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "filteredTotalAmount",
     "type": "number",
     "flags": "",
     "note": "The sum of every matching invoice in your base currency."
    }
   ]
  },
  {
   "id": "FinanceVendor",
   "name": "Vendor",
   "group": "finance",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "canonicalName",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "aliases",
     "type": "string[]",
     "flags": "",
     "note": "Other spellings that are matched to this vendor."
    },
    {
     "name": "category",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "owner",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "contractRef",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "contractRenewal",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "notes",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "customFields",
     "type": "object",
     "flags": "",
     "note": "{} when none is set."
    },
    {
     "name": "totalSpend",
     "type": "number",
     "flags": "",
     "note": "To two places."
    },
    {
     "name": "mergedIntoId",
     "type": "integer",
     "flags": "nullable",
     "note": "Set on a vendor that was merged into another."
    }
   ]
  },
  {
   "id": "PurchaseOrder",
   "name": "PurchaseOrder",
   "group": "finance",
   "kind": "response",
   "note": "One row of GET /parser/finance/purchase-orders.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "poNumber",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "vendor",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "currency",
     "type": "string",
     "flags": "",
     "note": "A 3-letter code."
    },
    {
     "name": "totalAmount",
     "type": "number",
     "flags": "",
     "note": "In the order's own currency."
    },
    {
     "name": "totalAmountBase",
     "type": "number",
     "flags": "",
     "note": "In your base currency: the figure budgets and matching use."
    },
    {
     "name": "baseConverted",
     "type": "boolean",
     "flags": "",
     "note": "false when no converted value is stored; totalAmountBase is then the entered figure."
    },
    {
     "name": "status",
     "type": "string",
     "flags": "",
     "note": "open until the order is closed."
    },
    {
     "name": "department",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "costCenter",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "project",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "lineCount",
     "type": "integer",
     "flags": "",
     "note": "In the list only."
    },
    {
     "name": "grnCount",
     "type": "integer",
     "flags": "",
     "note": "In the list only: goods receipts recorded against the order."
    },
    {
     "name": "orderedDate",
     "type": "string",
     "flags": "nullable",
     "note": "A display date such as \"Oct 10, 2026\"."
    }
   ]
  },
  {
   "id": "MatchException",
   "name": "MatchException",
   "group": "finance",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "docId",
     "type": "integer",
     "flags": "",
     "note": "The invoice."
    },
    {
     "name": "displayId",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "vendor",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "poNumber",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "exceptionType",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "severity",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "status",
     "type": "string: open | waived | resolved",
     "flags": "",
     "note": ""
    },
    {
     "name": "expected",
     "type": "number",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "actual",
     "type": "number",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "variancePct",
     "type": "number",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "tolerancePct",
     "type": "number",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "reason",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    }
   ]
  },
  {
   "id": "ApprovalQueueItem",
   "name": "ApprovalQueueItem",
   "group": "finance",
   "kind": "response",
   "note": "One submitted invoice in GET /parser/finance/approvals-queue.",
   "extends": null,
   "fields": [
    {
     "name": "db_id",
     "type": "integer",
     "flags": "",
     "note": "The invoice id."
    },
    {
     "name": "displayId",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "vendor",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "amount",
     "type": "number",
     "flags": "",
     "note": ""
    },
    {
     "name": "amountBase",
     "type": "number",
     "flags": "",
     "note": ""
    },
    {
     "name": "currency",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "matchStatus",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "poNumber",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "approvalsCount",
     "type": "integer",
     "flags": "",
     "note": "Approvals recorded so far."
    },
    {
     "name": "approvalsRequired",
     "type": "integer",
     "flags": "",
     "note": "What the approval rule for this amount asks for."
    },
    {
     "name": "approvers",
     "type": "string[]",
     "flags": "",
     "note": "The names of the people who have approved."
    },
    {
     "name": "ageDays",
     "type": "integer",
     "flags": "nullable",
     "note": "Days since it was submitted."
    },
    {
     "name": "submittedAt",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "highValue",
     "type": "boolean",
     "flags": "",
     "note": ""
    }
   ]
  },
  {
   "id": "ApprovalRule",
   "name": "ApprovalRule",
   "group": "finance",
   "kind": "response",
   "note": "How many approvers an invoice of a given amount needs.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "name",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "minAmount",
     "type": "number",
     "flags": "",
     "note": "In your base currency."
    },
    {
     "name": "maxAmount",
     "type": "number",
     "flags": "nullable",
     "note": "null for no upper limit."
    },
    {
     "name": "requiredApprovals",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "isActive",
     "type": "boolean",
     "flags": "",
     "note": ""
    }
   ]
  },
  {
   "id": "GLAccount",
   "name": "GLAccount",
   "group": "finance",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "code",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "name",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "type",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "isActive",
     "type": "boolean",
     "flags": "",
     "note": ""
    }
   ]
  },
  {
   "id": "Budget",
   "name": "Budget",
   "group": "finance",
   "kind": "response",
   "note": "All the money fields are in your base currency except budgetEntered.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "scopeType",
     "type": "string",
     "flags": "",
     "note": "global when the budget covers all spend."
    },
    {
     "name": "scopeValue",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "label",
     "type": "string",
     "flags": "",
     "note": "scopeValue, or \"All spend\"."
    },
    {
     "name": "name",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "displayName",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "description",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "budgetType",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "startDate",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "endDate",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "periodKind",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "authoritativeSource",
     "type": "string: invoice | cloud | both",
     "flags": "",
     "note": "Which actuals drive consumedPct."
    },
    {
     "name": "currency",
     "type": "string",
     "flags": "nullable",
     "note": "The currency the budget was entered in."
    },
    {
     "name": "tags",
     "type": "array",
     "flags": "",
     "note": ""
    },
    {
     "name": "glAccountId",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "recurrence",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "recurrenceInterval",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "warnPct",
     "type": "number",
     "flags": "nullable",
     "note": "This budget's own warning threshold, as a fraction."
    },
    {
     "name": "overPct",
     "type": "number",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "warnThreshold",
     "type": "number",
     "flags": "",
     "note": "The threshold in force, as a fraction."
    },
    {
     "name": "overThreshold",
     "type": "number",
     "flags": "",
     "note": ""
    },
    {
     "name": "ownerEmail",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "ownerUserId",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "ownerDepartment",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "period",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "budget",
     "type": "number",
     "flags": "",
     "note": "The budget amount in base currency."
    },
    {
     "name": "budgetEntered",
     "type": "number",
     "flags": "",
     "note": "As entered, in currency."
    },
    {
     "name": "baseCurrency",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "actual",
     "type": "number",
     "flags": "",
     "note": "Per authoritativeSource."
    },
    {
     "name": "invoiceActual",
     "type": "number",
     "flags": "",
     "note": ""
    },
    {
     "name": "cloudActual",
     "type": "number",
     "flags": "",
     "note": ""
    },
    {
     "name": "cloudSynced",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "cloudSystemCount",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "committed",
     "type": "number",
     "flags": "",
     "note": "Open purchase-order value that has not been invoiced yet."
    },
    {
     "name": "softCommitted",
     "type": "number",
     "flags": "",
     "note": "Pending and approved spend requests."
    },
    {
     "name": "available",
     "type": "number",
     "flags": "",
     "note": "budget minus actual, committed and softCommitted."
    },
    {
     "name": "remaining",
     "type": "number",
     "flags": "",
     "note": "budget minus actual."
    },
    {
     "name": "variance",
     "type": "number",
     "flags": "",
     "note": ""
    },
    {
     "name": "baselineBudget",
     "type": "number",
     "flags": "",
     "note": "The first version of this budget."
    },
    {
     "name": "varianceVsBaseline",
     "type": "number",
     "flags": "",
     "note": ""
    },
    {
     "name": "varianceType",
     "type": "string: favorable | unfavorable",
     "flags": "",
     "note": ""
    },
    {
     "name": "consumedPct",
     "type": "number",
     "flags": "",
     "note": "A percentage to one place."
    },
    {
     "name": "projected",
     "type": "number",
     "flags": "",
     "note": ""
    },
    {
     "name": "projectedPct",
     "type": "number",
     "flags": "",
     "note": ""
    },
    {
     "name": "onPaceToExceed",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "status",
     "type": "string: OK | Warning | Over",
     "flags": "",
     "note": "The consumption status."
    },
    {
     "name": "lifecycleStatus",
     "type": "string",
     "flags": "",
     "note": "Draft, Submitted, Approved or Rejected."
    },
    {
     "name": "pendingReapproval",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "approvedAmountBase",
     "type": "number",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "reapprovalEditor",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "submittedBy",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "approvedBy",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "rejectionReason",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "versionNo",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "parentBudgetId",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "isCurrent",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "locked",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "versionLabel",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "method",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "hasLines",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "frozen",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "frozenReason",
     "type": "string",
     "flags": "nullable",
     "note": ""
    }
   ]
  },
  {
   "id": "BudgetLine",
   "name": "BudgetLine",
   "group": "finance",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "budgetId",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "label",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "glAccountId",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "category",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "costCenter",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "amount",
     "type": "number",
     "flags": "",
     "note": ""
    },
    {
     "name": "justification",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "driverKey",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "driverQty",
     "type": "number",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "unitCost",
     "type": "number",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "sortOrder",
     "type": "integer",
     "flags": "",
     "note": ""
    }
   ]
  },
  {
   "id": "BudgetComment",
   "name": "BudgetComment",
   "group": "finance",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "budgetId",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "authorId",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "author",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "comment",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    }
   ]
  },
  {
   "id": "BudgetTransfer",
   "name": "BudgetTransfer",
   "group": "finance",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "amount",
     "type": "number",
     "flags": "",
     "note": ""
    },
    {
     "name": "reason",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "status",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "currency",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "amountBase",
     "type": "number",
     "flags": "",
     "note": ""
    },
    {
     "name": "fromAmount",
     "type": "number",
     "flags": "nullable",
     "note": "What leaves the source budget, in its own currency."
    },
    {
     "name": "fromCurrency",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "toAmount",
     "type": "number",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "toCurrency",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "fromBudgetId",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "toBudgetId",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "fromLabel",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "toLabel",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "requestedBy",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "requestedByEmail",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "requestedAt",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "approvedBy",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "approvedAt",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "rejectionReason",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "approvalsHave",
     "type": "integer",
     "flags": "",
     "note": "Distinct approvers recorded so far."
    },
    {
     "name": "approvalsNeed",
     "type": "integer",
     "flags": "",
     "note": "What a transfer of this amount requires."
    }
   ]
  },
  {
   "id": "SpendRequest",
   "name": "SpendRequest",
   "group": "finance",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "requesterEmail",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "department",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "budgetId",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "budgetName",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "amount",
     "type": "number",
     "flags": "",
     "note": ""
    },
    {
     "name": "amountBase",
     "type": "number",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "currency",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "description",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "vendor",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "status",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "approvedAt",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "rejectionReason",
     "type": "string",
     "flags": "nullable",
     "note": ""
    }
   ]
  },
  {
   "id": "BudgetAlert",
   "name": "BudgetAlert",
   "group": "finance",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "budgetId",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "tier",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "message",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "consumedPct",
     "type": "number",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "ownerEmail",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "ownerDepartment",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "acknowledgedAt",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    }
   ]
  },
  {
   "id": "InvoiceDetail",
   "name": "InvoiceDetail",
   "group": "finance",
   "kind": "response",
   "note": "One invoice as the review screen reads it. Not the Invoice object of the list: the field names differ.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "displayId",
     "type": "string",
     "flags": "",
     "note": "INV- and the id, padded to three digits."
    },
    {
     "name": "filename",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "hasFile",
     "type": "boolean",
     "flags": "",
     "note": "Whether the uploaded file is stored."
    },
    {
     "name": "fileContentType",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "vendor",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "amount",
     "type": "number",
     "flags": "",
     "note": "In currency."
    },
    {
     "name": "currency",
     "type": "string",
     "flags": "",
     "note": "USD when none was read."
    },
    {
     "name": "currencyDefaulted",
     "type": "boolean",
     "flags": "",
     "note": "true when the currency was filled from the instance default."
    },
    {
     "name": "amountAmbiguous",
     "type": "boolean",
     "flags": "",
     "note": "true when amountReviewNote is set."
    },
    {
     "name": "amountReviewNote",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "dateReviewNote",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "submitterNote",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "baseCurrency",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "amountBase",
     "type": "number",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "dateIso",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "category",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "status",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "approvalStatus",
     "type": "string",
     "flags": "",
     "note": "Draft when none is stored."
    },
    {
     "name": "extractionConfidence",
     "type": "number",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "invoiceNumber",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "taxAmount",
     "type": "number",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "poNumber",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "matchStatus",
     "type": "string",
     "flags": "",
     "note": "unmatched when none is stored."
    },
    {
     "name": "lineItems",
     "type": "object[]",
     "flags": "",
     "note": "In line order, each { lineNo:integer, description:string|null, quantity:number, unitPrice:number, amount:number }."
    },
    {
     "name": "rawText",
     "type": "string",
     "flags": "",
     "note": "The text read from the file; empty when there is none."
    }
   ]
  },
  {
   "id": "PurchaseOrderDetail",
   "name": "PurchaseOrderDetail",
   "group": "finance",
   "kind": "response",
   "note": "One purchase order with its lines and goods receipts.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "poNumber",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "vendor",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "currency",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "totalAmount",
     "type": "number",
     "flags": "",
     "note": "In currency."
    },
    {
     "name": "totalAmountBase",
     "type": "number",
     "flags": "",
     "note": "In base currency."
    },
    {
     "name": "baseConverted",
     "type": "boolean",
     "flags": "",
     "note": "false when no base amount was stored for the order."
    },
    {
     "name": "status",
     "type": "string",
     "flags": "",
     "note": "open, partially_received, received, closed or cancelled."
    },
    {
     "name": "department",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "costCenter",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "project",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "notes",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "orderedDate",
     "type": "string",
     "flags": "nullable",
     "note": "Display text such as \"Oct 10, 2026\"."
    },
    {
     "name": "lines",
     "type": "object[]",
     "flags": "",
     "note": "In line order, each { id:integer, lineNo:integer, description:string|null, quantity:number, unitPrice:number, amount:number, qtyReceived:number, qtyInvoiced:number }."
    },
    {
     "name": "goodsReceipts",
     "type": "object[]",
     "flags": "",
     "note": "Newest first, each { id:integer, grnNumber:string|null, receivedDate:string|null (display text) }."
    }
   ]
  },
  {
   "id": "OwnedSystem",
   "name": "OwnedSystem",
   "group": "finance",
   "kind": "response",
   "note": "A system linked to an owner and, optionally, a budget, with the cost its provider reports.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "label",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "providerType",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "kind",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "systemRef",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "budgetId",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "metering",
     "type": "string: live | derived | discovery",
     "flags": "",
     "note": ""
    },
    {
     "name": "ownerEmail",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "ownerDepartment",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "cost",
     "type": "number",
     "flags": "",
     "note": "0 when the provider could not be read."
    },
    {
     "name": "forecast",
     "type": "number",
     "flags": "",
     "note": ""
    },
    {
     "name": "isSynced",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "lastSync",
     "type": "string",
     "flags": "nullable",
     "note": ""
    }
   ]
  },
  {
   "id": "BulkOutcomeItem",
   "name": "BulkOutcomeItem",
   "group": "finance",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "any",
     "flags": "",
     "note": "The id as an integer, or the value you sent when it could not be read as one."
    },
    {
     "name": "ok",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "error",
     "type": "string",
     "flags": "",
     "note": "Present only when ok is false: the reason this item was not done."
    },
    {
     "name": "approvalStatus",
     "type": "string",
     "flags": "",
     "note": "Present only in the invoice bulk reply, on an item that was done by submit, approve, reject, pay or clear_review: the invoice's approval status after the action."
    },
    {
     "name": "kind",
     "type": "string: budget | transfer",
     "flags": "",
     "note": "Present only in the budget-approvals bulk reply."
    }
   ]
  },
  {
   "id": "BulkOutcome",
   "name": "BulkOutcome",
   "group": "finance",
   "kind": "response",
   "note": "What a bulk call did. Each item is handled and saved on its own, so a failed item never undoes the others. A valid id sent twice appears once; a value that cannot be read as an id is reported each time it is sent.",
   "extends": null,
   "fields": [
    {
     "name": "action",
     "type": "string",
     "flags": "",
     "note": "The action you sent. Present only in the invoice bulk reply and the budget-approvals bulk reply."
    },
    {
     "name": "ok",
     "type": "integer",
     "flags": "",
     "note": "How many items were done."
    },
    {
     "name": "failed",
     "type": "integer",
     "flags": "",
     "note": "How many were not."
    },
    {
     "name": "results",
     "type": "BulkOutcomeItem[]",
     "flags": "",
     "note": "One entry per distinct valid id, plus one for each value that could not be read as an id."
    }
   ]
  },
  {
   "id": "InvoiceImportSummary",
   "name": "InvoiceImportSummary",
   "group": "finance",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "imported",
     "type": "integer",
     "flags": "",
     "note": "Rows stored as invoices."
    },
    {
     "name": "skipped",
     "type": "integer",
     "flags": "",
     "note": "Rows not stored."
    },
    {
     "name": "errors",
     "type": "string[]",
     "flags": "",
     "note": "One message per skipped row; the first 50 are returned."
    },
    {
     "name": "currencyDefaulted",
     "type": "integer",
     "flags": "",
     "note": "Rows that named no currency and were given the base currency."
    },
    {
     "name": "amountAmbiguous",
     "type": "integer",
     "flags": "",
     "note": "Rows held for review because the amount could be read two ways."
    },
    {
     "name": "dateHeld",
     "type": "integer",
     "flags": "",
     "note": "Rows held for review because the date could not be read with certainty."
    },
    {
     "name": "duplicatesSkipped",
     "type": "integer",
     "flags": "",
     "note": "Rows skipped because the same vendor and invoice number are already on file or repeated in the file."
    },
    {
     "name": "duplicatesHeld",
     "type": "integer",
     "flags": "",
     "note": "Rows stored but held for review because another invoice has the same vendor, amount and date."
    }
   ]
  },
  {
   "id": "CurrencyRate",
   "name": "CurrencyRate",
   "group": "finance",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "code",
     "type": "string",
     "flags": "",
     "note": "Three letters."
    },
    {
     "name": "rateToBase",
     "type": "number",
     "flags": "",
     "note": "How many units of the base currency one unit of code is worth."
    },
    {
     "name": "updatedAt",
     "type": "string (date-time)",
     "flags": "",
     "note": "UTC."
    }
   ]
  },
  {
   "id": "CurrencyTable",
   "name": "CurrencyTable",
   "group": "finance",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "baseCurrency",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "missingRates",
     "type": "array",
     "flags": "",
     "note": "Currencies in use on invoices, purchase orders, budgets or spend requests that have no rate, ordered by code; each entry carries code, base and a count for documents, purchaseOrders, budgets and spendRequests."
    },
    {
     "name": "rates",
     "type": "CurrencyRate[]",
     "flags": "",
     "note": "Every stored rate, ordered by code."
    }
   ]
  },
  {
   "id": "VendorMergeResult",
   "name": "VendorMergeResult",
   "group": "finance",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "success",
     "type": "boolean",
     "flags": "",
     "note": "Always true."
    },
    {
     "name": "keptVendorId",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "repointed",
     "type": "integer",
     "flags": "",
     "note": "How many invoices moved to the kept vendor."
    },
    {
     "name": "skippedLocked",
     "type": "array",
     "flags": "",
     "note": "The invoices left on the merged vendor because their accounting period is locked; each entry carries id, period and status."
    },
    {
     "name": "loserRetained",
     "type": "boolean",
     "flags": "",
     "note": "true when the merged vendor was kept because invoices in a locked period still point at it."
    }
   ]
  },
  {
   "id": "PeriodCloseResult",
   "name": "PeriodCloseResult",
   "group": "finance",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "period",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "status",
     "type": "string",
     "flags": "",
     "note": "Always locked."
    },
    {
     "name": "journalsPosted",
     "type": "integer",
     "flags": "",
     "note": "Invoices that were journalled by this close; one already journalled is not posted again."
    },
    {
     "name": "batch",
     "type": "string",
     "flags": "",
     "note": "The period, a hyphen and the UTC time of the close to the second."
    },
    {
     "name": "forced",
     "type": "boolean",
     "flags": "",
     "note": "true when the period was locked over open items."
    }
   ]
  },
  {
   "id": "GLSyncRun",
   "name": "GLSyncRun",
   "group": "finance",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "connector",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "pushed",
     "type": "integer",
     "flags": "",
     "note": "Journal lines marked as synced by this call."
    },
    {
     "name": "live",
     "type": "boolean",
     "flags": "",
     "note": "false for the manual connector."
    },
    {
     "name": "note",
     "type": "string",
     "flags": "",
     "note": ""
    }
   ]
  },
  {
   "id": "AlertSweepResult",
   "name": "AlertSweepResult",
   "group": "finance",
   "kind": "response",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "evaluated",
     "type": "integer",
     "flags": "",
     "note": "Current budgets looked at."
    },
    {
     "name": "fired",
     "type": "integer",
     "flags": "",
     "note": "Alerts raised by this call."
    }
   ]
  },
  {
   "id": "InvoiceReview",
   "name": "InvoiceReview",
   "group": "finance",
   "kind": "request",
   "note": "Corrections to what was read from an uploaded invoice. Only the keys you send are changed.",
   "extends": null,
   "fields": [
    {
     "name": "vendor",
     "type": "string",
     "flags": "optional",
     "note": "Trimmed; an empty value changes nothing."
    },
    {
     "name": "category",
     "type": "string",
     "flags": "optional",
     "note": "Trimmed; an empty value changes nothing."
    },
    {
     "name": "currency",
     "type": "string",
     "flags": "optional",
     "note": "Upper-cased and cut to 3 letters."
    },
    {
     "name": "date",
     "type": "string (date-time)",
     "flags": "optional",
     "note": "ISO 8601; the first 19 characters are read and text that is not a date changes nothing. A date in a locked accounting period is refused."
    },
    {
     "name": "amount",
     "type": "number",
     "flags": "optional",
     "note": "A number, or text in a recognised number format. An entry that could be read two ways is refused with 400."
    },
    {
     "name": "po_number",
     "type": "string",
     "flags": "optional, null clears it",
     "note": "Trimmed, upper-cased and cut to 64 characters."
    },
    {
     "name": "note",
     "type": "string",
     "flags": "optional, null clears it",
     "note": "Trimmed and cut to 1000 characters."
    },
    {
     "name": "commit",
     "type": "boolean",
     "flags": "optional",
     "note": "true releases an invoice held for review to Draft. Ignored unless the caller holds manage_finance."
    }
   ]
  },
  {
   "id": "InvoiceAllocation",
   "name": "InvoiceAllocation",
   "group": "finance",
   "kind": "request",
   "note": "Where an invoice is charged. Only the keys you send are changed. Refused with 409 once the invoice is Submitted, Approved or Paid.",
   "extends": null,
   "fields": [
    {
     "name": "costCenter",
     "type": "string",
     "flags": "optional, null clears it",
     "note": ""
    },
    {
     "name": "department",
     "type": "string",
     "flags": "optional, null clears it",
     "note": ""
    },
    {
     "name": "project",
     "type": "string",
     "flags": "optional, null clears it",
     "note": ""
    },
    {
     "name": "glCode",
     "type": "string",
     "flags": "optional, null clears it",
     "note": "Once a chart of accounts exists the code must be in it, or the answer is 400."
    },
    {
     "name": "currency",
     "type": "string",
     "flags": "optional",
     "note": "Upper-cased and cut to 3 letters; the base amount is worked out again. 400 when no exchange rate is set for it."
    },
    {
     "name": "customFields",
     "type": "object",
     "flags": "optional",
     "note": "Replaces the stored set. Keys that are not in the allocation custom-field schema, and empty values, are dropped."
    }
   ]
  },
  {
   "id": "InvoiceAction",
   "name": "InvoiceAction",
   "group": "finance",
   "kind": "request",
   "note": "The body of an approval step. Which keys are read depends on the action in the path; submit and clear_review read none.",
   "extends": null,
   "fields": [
    {
     "name": "reason",
     "type": "string",
     "flags": "required for reject",
     "note": "Blank is refused with 400."
    },
    {
     "name": "budget_override",
     "type": "boolean",
     "flags": "optional",
     "note": "approve only: approve although the invoice takes a budget over its limit. Needs approve_invoice."
    },
    {
     "name": "override_reason",
     "type": "string",
     "flags": "required with budget_override",
     "note": "Blank is refused with 400."
    },
    {
     "name": "comment",
     "type": "string",
     "flags": "optional",
     "note": "approve only: stored with the approval record."
    }
   ]
  },
  {
   "id": "InvoiceBulkAction",
   "name": "InvoiceBulkAction",
   "group": "finance",
   "kind": "request",
   "note": "One action for many invoices. Each invoice is handled on its own and reported in results; an id sent twice is handled once.",
   "extends": null,
   "fields": [
    {
     "name": "action",
     "type": "string: submit | approve | reject | pay | clear_review | allocate | delete",
     "flags": "required",
     "note": ""
    },
    {
     "name": "ids",
     "type": "integer[]",
     "flags": "required",
     "note": "1 to 500 invoice ids."
    },
    {
     "name": "allocation",
     "type": "InvoiceAllocation",
     "flags": "optional",
     "note": "Read for allocate only; without it nothing is changed."
    },
    {
     "name": "reason",
     "type": "string",
     "flags": "required for reject",
     "note": "One reason shared by every invoice."
    },
    {
     "name": "budget_override",
     "type": "boolean",
     "flags": "optional",
     "note": "approve only, as in InvoiceAction."
    },
    {
     "name": "override_reason",
     "type": "string",
     "flags": "required with budget_override",
     "note": ""
    },
    {
     "name": "comment",
     "type": "string",
     "flags": "optional",
     "note": "approve only, as in InvoiceAction; one comment shared by every invoice."
    }
   ]
  },
  {
   "id": "VendorUpdate",
   "name": "VendorUpdate",
   "group": "finance",
   "kind": "request",
   "note": "Only the keys you send are changed.",
   "extends": null,
   "fields": [
    {
     "name": "canonicalName",
     "type": "string",
     "flags": "optional",
     "note": "A name another vendor already has is refused with 409. Budgets scoped to the old name follow the new one."
    },
    {
     "name": "category",
     "type": "string",
     "flags": "optional, null clears it",
     "note": ""
    },
    {
     "name": "owner",
     "type": "string",
     "flags": "optional, null clears it",
     "note": ""
    },
    {
     "name": "ownerUserId",
     "type": "integer",
     "flags": "optional, null clears it",
     "note": ""
    },
    {
     "name": "contractRef",
     "type": "string",
     "flags": "optional, null clears it",
     "note": ""
    },
    {
     "name": "contractRenewal",
     "type": "string (date-time)",
     "flags": "optional, null clears it",
     "note": "ISO 8601; text that is not a date is stored as empty."
    },
    {
     "name": "notes",
     "type": "string",
     "flags": "optional, null clears it",
     "note": ""
    },
    {
     "name": "aliases",
     "type": "string[]",
     "flags": "optional",
     "note": "Replaces the alias list; each alias is normalised the way vendor names are matched."
    },
    {
     "name": "customFields",
     "type": "object",
     "flags": "optional",
     "note": "Replaces the stored set. Keys that are not in the vendor custom-field schema, and empty values, are dropped."
    }
   ]
  },
  {
   "id": "CustomFieldDefinition",
   "name": "CustomFieldDefinition",
   "group": "finance",
   "kind": "request",
   "note": "One entry of a custom-field schema (vendor or allocation).",
   "extends": null,
   "fields": [
    {
     "name": "key",
     "type": "string",
     "flags": "required",
     "note": "Characters other than letters, digits and _ become _. An entry with an empty key, or a key already used in the same call (ignoring case), is skipped."
    },
    {
     "name": "label",
     "type": "string",
     "flags": "optional",
     "note": "Cut to 60 characters. Defaults to the key."
    },
    {
     "name": "type",
     "type": "string: text | select | number | date",
     "flags": "optional",
     "note": "Any other value is stored as text."
    },
    {
     "name": "options",
     "type": "string[]",
     "flags": "optional",
     "note": "Read for select only: each cut to 60 characters, duplicates dropped, the first 50 kept."
    }
   ]
  },
  {
   "id": "CurrencyPolicyUpdate",
   "name": "CurrencyPolicyUpdate",
   "group": "finance",
   "kind": "request",
   "note": "Sets the base currency, one exchange rate, or both. The base totals of invoices that are not yet in approval are then worked out again.",
   "extends": null,
   "fields": [
    {
     "name": "baseCurrency",
     "type": "string",
     "flags": "optional",
     "note": "Three letters. Refused with 400 once invoices, purchase orders or budgets exist, or while the new currency has no rate."
    },
    {
     "name": "code",
     "type": "string",
     "flags": "required with rateToBase",
     "note": "Upper-cased and cut to 3 letters."
    },
    {
     "name": "rateToBase",
     "type": "number",
     "flags": "required with code",
     "note": "How many units of the base currency one unit of code is worth. Greater than 0, between 0.000001 and 1000000."
    }
   ]
  },
  {
   "id": "FinanceSettingWrite",
   "name": "FinanceSettingWrite",
   "group": "finance",
   "kind": "request",
   "note": "One setting per call. The keys, their kinds and defaults are what GET /parser/finance/settings lists.",
   "extends": null,
   "fields": [
    {
     "name": "key",
     "type": "string: enforce_sod | finance.high_value_floor | finance.review_confidence_floor | match.tolerance_pct | match.require_grn | finance.llm_extraction | finance.digest_enabled | finance.digest_recipients | finance.digest_interval_hours | finance.fiscal_year_start_month | finance.budget_warn_pct | finance.budget_over_pct | finance.cloud_anomaly_pct",
     "flags": "required",
     "note": "Any other key is refused with 400."
    },
    {
     "name": "value",
     "type": "any",
     "flags": "required",
     "note": "A yes/no setting stores true for true, 1, yes or on and false for anything else. A number setting refuses text with 400 and is brought inside its range: finance.review_confidence_floor 0 to 1, match.tolerance_pct 0 to 0.5, finance.fiscal_year_start_month a whole number 1 to 12."
    }
   ]
  },
  {
   "id": "PurchaseOrderLineInput",
   "name": "PurchaseOrderLineInput",
   "group": "finance",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "description",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "quantity",
     "type": "number",
     "flags": "optional",
     "note": "Default 0."
    },
    {
     "name": "unit_price",
     "type": "number",
     "flags": "optional",
     "note": "Default 0. The line amount is quantity times unit_price; an amount you send is not read."
    }
   ]
  },
  {
   "id": "PurchaseOrderCreate",
   "name": "PurchaseOrderCreate",
   "group": "finance",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "po_number",
     "type": "string",
     "flags": "required",
     "note": "Trimmed and upper-cased. A number that already exists is refused with 409."
    },
    {
     "name": "vendor",
     "type": "string",
     "flags": "optional",
     "note": "Trimmed and matched to a known vendor where one exists."
    },
    {
     "name": "currency",
     "type": "string",
     "flags": "optional",
     "note": "Default USD. Upper-cased and cut to 3 letters; 400 when no exchange rate is set for it."
    },
    {
     "name": "lines",
     "type": "PurchaseOrderLineInput[]",
     "flags": "optional",
     "note": "When present, the order total is the sum of the lines."
    },
    {
     "name": "total_amount",
     "type": "number",
     "flags": "optional",
     "note": "Read only when lines is empty. Default 0."
    },
    {
     "name": "cost_center",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "department",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "project",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "budget_override",
     "type": "boolean",
     "flags": "optional",
     "note": "true creates the order although it takes a budget over its limit; without it that case answers 409."
    }
   ]
  },
  {
   "id": "GoodsReceiptLineInput",
   "name": "GoodsReceiptLineInput",
   "group": "finance",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "po_line_id",
     "type": "integer",
     "flags": "optional",
     "note": "The purchase-order line received against."
    },
    {
     "name": "quantity_received",
     "type": "number",
     "flags": "optional",
     "note": "Default 0."
    }
   ]
  },
  {
   "id": "GoodsReceiptCreate",
   "name": "GoodsReceiptCreate",
   "group": "finance",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "po_id",
     "type": "integer",
     "flags": "required",
     "note": "404 when the purchase order does not exist."
    },
    {
     "name": "grn_number",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "lines",
     "type": "GoodsReceiptLineInput[]",
     "flags": "optional",
     "note": ""
    }
   ]
  },
  {
   "id": "ApprovalRuleWrite",
   "name": "ApprovalRuleWrite",
   "group": "finance",
   "kind": "request",
   "note": "Create and update take the same keys; an update changes only the keys you send. Amounts are in your base currency.",
   "extends": null,
   "fields": [
    {
     "name": "name",
     "type": "string",
     "flags": "optional",
     "note": "At most 80 characters. Default \"Rule\"."
    },
    {
     "name": "minAmount",
     "type": "number",
     "flags": "optional",
     "note": "Default 0. Not negative; at most 999999999999.99."
    },
    {
     "name": "maxAmount",
     "type": "number",
     "flags": "optional, null clears it",
     "note": "null means no ceiling. Otherwise greater than minAmount and at most 999999999999.99."
    },
    {
     "name": "requiredApprovals",
     "type": "integer",
     "flags": "optional",
     "note": "Default 1. A whole number from 1 to 10."
    },
    {
     "name": "isActive",
     "type": "boolean",
     "flags": "optional",
     "note": "Default true."
    }
   ]
  },
  {
   "id": "AdjustmentCreate",
   "name": "AdjustmentCreate",
   "group": "finance",
   "kind": "request",
   "note": "An adjusting journal entry in the current open period: one debit and one credit of the same amount, in base currency.",
   "extends": null,
   "fields": [
    {
     "name": "reason",
     "type": "string",
     "flags": "required",
     "note": "Blank is refused with 400."
    },
    {
     "name": "debitGl",
     "type": "string",
     "flags": "required",
     "note": "A general-ledger code. Once a chart of accounts exists it must be in it."
    },
    {
     "name": "creditGl",
     "type": "string",
     "flags": "required",
     "note": "Must differ from debitGl."
    },
    {
     "name": "amount",
     "type": "number",
     "flags": "required",
     "note": "Greater than 0; rounded to 2 places."
    }
   ]
  },
  {
   "id": "BudgetWrite",
   "name": "BudgetWrite",
   "group": "finance",
   "kind": "request",
   "note": "Create and update take the same keys. An update changes only the keys you send. The owner is set by the server and is not read from the body.",
   "extends": null,
   "fields": [
    {
     "name": "period",
     "type": "string",
     "flags": "required on create",
     "note": "YYYY-MM or YYYY."
    },
    {
     "name": "scopeType",
     "type": "string",
     "flags": "optional",
     "note": "Default global. A caller without a finance-wide budget permission may send only department, and it is set to their own department."
    },
    {
     "name": "scopeValue",
     "type": "string",
     "flags": "optional, null clears it",
     "note": "The department, cost centre or vendor the budget covers."
    },
    {
     "name": "amount",
     "type": "number",
     "flags": "optional",
     "note": "Default 0. A negative number or text that is not a number is stored as 0."
    },
    {
     "name": "currency",
     "type": "string",
     "flags": "optional, null clears it",
     "note": "The currency amount is entered in; empty means the base currency. 400 when no exchange rate is set for it."
    },
    {
     "name": "name",
     "type": "string",
     "flags": "optional, null clears it",
     "note": ""
    },
    {
     "name": "description",
     "type": "string",
     "flags": "optional, null clears it",
     "note": ""
    },
    {
     "name": "budgetType",
     "type": "string",
     "flags": "optional",
     "note": "Default opex."
    },
    {
     "name": "method",
     "type": "string",
     "flags": "optional",
     "note": "Default top_down."
    },
    {
     "name": "startDate",
     "type": "string (date-time)",
     "flags": "optional, null clears it",
     "note": "ISO 8601; text that is not a date is stored as empty."
    },
    {
     "name": "endDate",
     "type": "string (date-time)",
     "flags": "optional, null clears it",
     "note": ""
    },
    {
     "name": "periodKind",
     "type": "string",
     "flags": "optional, null clears it",
     "note": ""
    },
    {
     "name": "authoritativeSource",
     "type": "string: invoice | cloud | both",
     "flags": "optional",
     "note": "Default invoice. An update ignores any other value."
    },
    {
     "name": "tags",
     "type": "array",
     "flags": "optional",
     "note": "Read only when it is a list."
    },
    {
     "name": "glAccountId",
     "type": "integer",
     "flags": "optional, null clears it",
     "note": ""
    },
    {
     "name": "recurrence",
     "type": "string",
     "flags": "optional, null clears it",
     "note": ""
    },
    {
     "name": "recurrenceInterval",
     "type": "string",
     "flags": "optional, null clears it",
     "note": ""
    },
    {
     "name": "warnPct",
     "type": "number",
     "flags": "optional, null clears it",
     "note": "A fraction; brought inside 0 to 1."
    },
    {
     "name": "overPct",
     "type": "number",
     "flags": "optional, null clears it",
     "note": "A fraction; brought inside 0 to 1."
    }
   ]
  },
  {
   "id": "BudgetLineWrite",
   "name": "BudgetLineWrite",
   "group": "finance",
   "kind": "request",
   "note": "Create and update take the same keys; an update changes only the keys you send. The line amount is driverQty times unitCost when both are known, otherwise amount.",
   "extends": null,
   "fields": [
    {
     "name": "label",
     "type": "string",
     "flags": "optional",
     "note": "Default \"Line\"."
    },
    {
     "name": "amount",
     "type": "number",
     "flags": "optional",
     "note": "Rounded to 2 places. Not read when driverQty and unitCost are both set."
    },
    {
     "name": "driverKey",
     "type": "string",
     "flags": "optional",
     "note": "With headcount or fte and no driverQty, the quantity is the head count of the budget's department where the directory holds one."
    },
    {
     "name": "driverQty",
     "type": "number",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "unitCost",
     "type": "number",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "glAccountId",
     "type": "integer",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "category",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "costCenter",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "justification",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "sortOrder",
     "type": "integer",
     "flags": "optional",
     "note": "Default 0."
    }
   ]
  },
  {
   "id": "BudgetTransferCreate",
   "name": "BudgetTransferCreate",
   "group": "finance",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "fromBudgetId",
     "type": "integer",
     "flags": "required",
     "note": ""
    },
    {
     "name": "toBudgetId",
     "type": "integer",
     "flags": "required",
     "note": "Must differ from fromBudgetId."
    },
    {
     "name": "amount",
     "type": "number",
     "flags": "required",
     "note": "Greater than 0."
    },
    {
     "name": "currency",
     "type": "string",
     "flags": "optional",
     "note": "The currency amount is entered in; empty means the base currency. 400 when an exchange rate is missing for any side."
    },
    {
     "name": "reason",
     "type": "string",
     "flags": "optional",
     "note": ""
    }
   ]
  },
  {
   "id": "BudgetApprovalsBulk",
   "name": "BudgetApprovalsBulk",
   "group": "finance",
   "kind": "request",
   "note": "Approve or reject budgets and transfers in one call. Each item is decided on its own and reported in results.",
   "extends": null,
   "fields": [
    {
     "name": "action",
     "type": "string: approve | reject",
     "flags": "required",
     "note": ""
    },
    {
     "name": "budgetIds",
     "type": "integer[]",
     "flags": "optional",
     "note": "Together with transferIds: at least 1 and at most 500 items."
    },
    {
     "name": "transferIds",
     "type": "integer[]",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "reason",
     "type": "string",
     "flags": "required to reject a budget",
     "note": "Stored as the rejection reason. Without it each budget in a reject is reported in results as failed; a transfer can be rejected without one."
    }
   ]
  },
  {
   "id": "SpendRequestCreate",
   "name": "SpendRequestCreate",
   "group": "finance",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "budgetId",
     "type": "integer",
     "flags": "required",
     "note": "A budget in your scope: 404 when it does not exist, 403 when it is outside your scope."
    },
    {
     "name": "amount",
     "type": "number",
     "flags": "required",
     "note": "Greater than 0."
    },
    {
     "name": "currency",
     "type": "string",
     "flags": "optional",
     "note": "Empty means the base currency. 400 when no exchange rate is set for it."
    },
    {
     "name": "description",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "vendor",
     "type": "string",
     "flags": "optional",
     "note": ""
    }
   ]
  },
  {
   "id": "OwnedSystemClaim",
   "name": "OwnedSystemClaim",
   "group": "finance",
   "kind": "request",
   "note": "Attaches a discovered system to an owner and, optionally, a budget.",
   "extends": null,
   "fields": [
    {
     "name": "providerType",
     "type": "string",
     "flags": "optional",
     "note": "Upper-cased. AWS, AZURE and AZURE_AD are metered live; GOOGLE_WORKSPACE and GITHUB are derived; any other value is recorded as discovery."
    },
    {
     "name": "externalProviderId",
     "type": "integer",
     "flags": "optional",
     "note": "A provider id from GET /parser/finance/systems/providers."
    },
    {
     "name": "systemRef",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "label",
     "type": "string",
     "flags": "optional",
     "note": "Defaults to systemRef, then providerType, then \"System\"."
    },
    {
     "name": "kind",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "budgetId",
     "type": "integer",
     "flags": "optional",
     "note": "A budget you can edit: 404 when it does not exist, 403 when it is not yours to edit."
    },
    {
     "name": "ownerEmail",
     "type": "string",
     "flags": "optional",
     "note": "Defaults to you. Ignored for a caller without a finance-wide budget permission, who always becomes the owner."
    }
   ]
  },
  {
   "id": "DocumentStatusWrite",
   "name": "DocumentStatusWrite",
   "group": "finance",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "status",
     "type": "string",
     "flags": "required",
     "note": "Stored as sent. paid and approved (in any letter case) are refused with 403: those are reached through the approval steps."
    }
   ]
  },
  {
   "id": "FinanceQuestion",
   "name": "FinanceQuestion",
   "group": "finance",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "question",
     "type": "string",
     "flags": "required",
     "note": "Trimmed; blank is refused with 400."
    }
   ]
  },
  {
   "id": "AppBudgetWrite",
   "name": "AppBudgetWrite",
   "group": "finance",
   "kind": "request",
   "note": "The yearly budget of one app (vendor), for the current calendar year.",
   "extends": null,
   "fields": [
    {
     "name": "amount",
     "type": "number",
     "flags": "optional",
     "note": "Greater than 0 sets the budget. Zero, a negative number, a missing key or text that is not a number clears it."
    }
   ]
  },
  {
   "id": "VendorBulkPatch",
   "name": "VendorBulkPatch",
   "group": "finance",
   "kind": "request",
   "note": "At least one of the two keys. Any other key is not read.",
   "extends": null,
   "fields": [
    {
     "name": "category",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "owner",
     "type": "string",
     "flags": "optional",
     "note": ""
    }
   ]
  },
  {
   "id": "VendorBulkUpdate",
   "name": "VendorBulkUpdate",
   "group": "finance",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "ids",
     "type": "integer[]",
     "flags": "required",
     "note": "1 to 500 vendor ids; the limit of 500 is counted before duplicates are removed. A valid id sent twice is handled once."
    },
    {
     "name": "patch",
     "type": "VendorBulkPatch",
     "flags": "required",
     "note": ""
    }
   ]
  },
  {
   "id": "VendorMerge",
   "name": "VendorMerge",
   "group": "finance",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "keepId",
     "type": "integer",
     "flags": "required",
     "note": "The vendor that stays."
    },
    {
     "name": "mergeId",
     "type": "integer",
     "flags": "required",
     "note": "The vendor whose invoices and aliases move to keepId."
    }
   ]
  },
  {
   "id": "DigestSend",
   "name": "DigestSend",
   "group": "finance",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "recipients",
     "type": "string[]",
     "flags": "optional",
     "note": "Email addresses. Without it the configured digest recipients are used."
    }
   ]
  },
  {
   "id": "PurchaseOrderStatusWrite",
   "name": "PurchaseOrderStatusWrite",
   "group": "finance",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "status",
     "type": "string: closed | cancelled | open",
     "flags": "optional",
     "note": "Default closed. Any other value is refused with 400."
    }
   ]
  },
  {
   "id": "PurchaseOrderBulkStatus",
   "name": "PurchaseOrderBulkStatus",
   "group": "finance",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "ids",
     "type": "integer[]",
     "flags": "required",
     "note": "1 to 500 purchase-order ids; the limit of 500 is counted before duplicates are removed. A valid id sent twice is handled once."
    },
    {
     "name": "status",
     "type": "string: closed | cancelled | open",
     "flags": "optional",
     "note": "Default closed."
    }
   ]
  },
  {
   "id": "MatchExceptionWaive",
   "name": "MatchExceptionWaive",
   "group": "finance",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "reason",
     "type": "string",
     "flags": "required",
     "note": "Trimmed; blank is refused with 400."
    }
   ]
  },
  {
   "id": "MatchExceptionBulkWaive",
   "name": "MatchExceptionBulkWaive",
   "group": "finance",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "ids",
     "type": "integer[]",
     "flags": "required",
     "note": "1 to 500 match-exception ids; the limit of 500 is counted before duplicates are removed. A valid id sent twice is handled once."
    },
    {
     "name": "reason",
     "type": "string",
     "flags": "required",
     "note": "One reason shared by every exception."
    }
   ]
  },
  {
   "id": "GLAccountCreate",
   "name": "GLAccountCreate",
   "group": "finance",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "code",
     "type": "string",
     "flags": "required",
     "note": "Trimmed. A code that already exists is refused with 409."
    },
    {
     "name": "name",
     "type": "string",
     "flags": "optional",
     "note": "Defaults to the code."
    },
    {
     "name": "type",
     "type": "string",
     "flags": "optional",
     "note": "The account type, stored as sent."
    }
   ]
  },
  {
   "id": "PeriodClose",
   "name": "PeriodClose",
   "group": "finance",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "force",
     "type": "boolean",
     "flags": "optional",
     "note": "true locks the period although it still has open items."
    },
    {
     "name": "reason",
     "type": "string",
     "flags": "required with force",
     "note": "Trimmed; blank with force is refused with 400."
    }
   ]
  },
  {
   "id": "GLConnectorWrite",
   "name": "GLConnectorWrite",
   "group": "finance",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "connector",
     "type": "string: manual | quickbooks | netsuite | xero",
     "flags": "required",
     "note": "Trimmed and lower-cased. Any other value is refused with 400."
    }
   ]
  },
  {
   "id": "BudgetReject",
   "name": "BudgetReject",
   "group": "finance",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "reason",
     "type": "string",
     "flags": "required",
     "note": "Trimmed; blank is refused with 400."
    }
   ]
  },
  {
   "id": "BudgetLock",
   "name": "BudgetLock",
   "group": "finance",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "locked",
     "type": "boolean",
     "flags": "optional",
     "note": "Default true. false unlocks, which needs manage_finance_policy."
    }
   ]
  },
  {
   "id": "BudgetFreeze",
   "name": "BudgetFreeze",
   "group": "finance",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "frozen",
     "type": "boolean",
     "flags": "optional",
     "note": "Default true. false lifts the freeze."
    },
    {
     "name": "reason",
     "type": "string",
     "flags": "optional",
     "note": "Stored with a freeze; not kept when the freeze is lifted."
    }
   ]
  },
  {
   "id": "BudgetCopy",
   "name": "BudgetCopy",
   "group": "finance",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "period",
     "type": "string",
     "flags": "required",
     "note": "The period of the copy: YYYY-MM or YYYY."
    }
   ]
  },
  {
   "id": "BudgetCommentCreate",
   "name": "BudgetCommentCreate",
   "group": "finance",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "comment",
     "type": "string",
     "flags": "required",
     "note": "Blank is refused with 400."
    }
   ]
  },
  {
   "id": "TransferReject",
   "name": "TransferReject",
   "group": "finance",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "reason",
     "type": "string",
     "flags": "optional",
     "note": "Trimmed; stored as the rejection reason."
    }
   ]
  },
  {
   "id": "SpendRequestDecision",
   "name": "SpendRequestDecision",
   "group": "finance",
   "kind": "request",
   "note": "Read for the reject and cancel actions only; approve and fulfill read no body.",
   "extends": null,
   "fields": [
    {
     "name": "reason",
     "type": "string",
     "flags": "optional",
     "note": ""
    }
   ]
  },
  {
   "id": "AgentPackage",
   "name": "AgentPackage",
   "group": "devices",
   "kind": "response",
   "note": "One hosted agent installer, as the agent-pkg read answers it and as each entry of versions[] in the agent-pkgs read. The file itself is not part of it.",
   "extends": null,
   "fields": [
    {
     "name": "version",
     "type": "string",
     "flags": "nullable",
     "note": "Null for an installer uploaded without a version."
    },
    {
     "name": "filename",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "sha256",
     "type": "string",
     "flags": "",
     "note": "SHA-256 of the hosted file, 64 hex characters."
    },
    {
     "name": "sizeBytes",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": "When it was uploaded."
    }
   ]
  },
  {
   "id": "AgentPackageUpload",
   "name": "AgentPackageUpload",
   "group": "devices",
   "kind": "response",
   "note": "The reply to an installer upload.",
   "extends": null,
   "fields": [
    {
     "name": "platform",
     "type": "string",
     "flags": "",
     "note": "macos | linux | windows"
    },
    {
     "name": "version",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "filename",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "sha256",
     "type": "string",
     "flags": "",
     "note": "SHA-256 of the stored file, 64 hex characters."
    },
    {
     "name": "sizeBytes",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "sig",
     "type": "string",
     "flags": "nullable",
     "note": "The vendor release signature carried by a signed bundle."
    },
    {
     "name": "kid",
     "type": "string",
     "flags": "nullable",
     "note": "The id of the key that signature was verified with."
    },
    {
     "name": "signed",
     "type": "boolean",
     "flags": "",
     "note": "True when the upload was a signed bundle."
    }
   ]
  },
  {
   "id": "DeviceSetupState",
   "name": "DeviceSetupState",
   "group": "devices",
   "kind": "response",
   "note": "Which platforms are in use. Answered by the setup-state read.",
   "extends": null,
   "fields": [
    {
     "name": "platforms",
     "type": "object",
     "flags": "",
     "note": "Keyed by macos, windows and linux. Each { active: boolean, agentPublished: boolean, deviceCount: integer, pendingCount: integer }: active is true when an installer is hosted or any device of that platform has enrolled; deviceCount counts approved devices that are not retired; pendingCount counts enrolments not yet approved."
    },
    {
     "name": "anyActive",
     "type": "boolean",
     "flags": "",
     "note": "True when any platform is active."
    }
   ]
  },
  {
   "id": "EnrollmentToken",
   "name": "EnrollmentToken",
   "group": "devices",
   "kind": "response",
   "note": "One enrolment token record, without its value. The token list is a bare array of the 200 newest, newest first; it takes no paging parameter.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "label",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "expiresAt",
     "type": "string (date-time)",
     "flags": "",
     "note": ""
    },
    {
     "name": "maxUses",
     "type": "integer",
     "flags": "",
     "note": "0 means unlimited."
    },
    {
     "name": "useCount",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "usedAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "revokedAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "status",
     "type": "string",
     "flags": "read-only",
     "note": "revoked | expired | used (every use spent) | active. Checked in that order."
    }
   ]
  },
  {
   "id": "EnrollmentTokenCreate",
   "name": "EnrollmentTokenCreate",
   "group": "devices",
   "kind": "request",
   "note": "Issues an enrolment token. The reply is { id, token, label, expiresAt, maxUses }; token is the value, shown only in that reply.",
   "extends": null,
   "fields": [
    {
     "name": "label",
     "type": "string",
     "flags": "",
     "note": "Cut to 120 characters."
    },
    {
     "name": "ttlHours",
     "type": "integer",
     "flags": "",
     "note": "Default 24. Held to 1 to 720."
    },
    {
     "name": "maxUses",
     "type": "integer",
     "flags": "",
     "note": "Default 1. Held to 1 to 500."
    }
   ]
  },
  {
   "id": "DeviceCommandType",
   "name": "DeviceCommandType",
   "group": "devices",
   "kind": "response",
   "note": "One entry of the command catalogue: { types: DeviceCommandType[] }. A fixed list.",
   "extends": null,
   "fields": [
    {
     "name": "type",
     "type": "string",
     "flags": "",
     "note": "lock | uninstall | restart | update_os | rename"
    },
    {
     "name": "label",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "platforms",
     "type": "string[]",
     "flags": "",
     "note": "macos | linux | windows"
    },
    {
     "name": "destructive",
     "type": "boolean",
     "flags": "",
     "note": "True for uninstall only."
    },
    {
     "name": "args",
     "type": "object[]",
     "flags": "",
     "note": "The arguments the command takes. Empty except for rename: one { name: \"name\", label, type: \"string\", required: true, maxLength: 63, maxLengthByPlatform: { windows: 15 }, pattern }."
    }
   ]
  },
  {
   "id": "DeviceCommandCreate",
   "name": "DeviceCommandCreate",
   "group": "devices",
   "kind": "request",
   "note": "Queues one command for one device. The batch call takes the same fields plus ids (1 to 500 device ids).",
   "extends": null,
   "fields": [
    {
     "name": "type",
     "type": "string",
     "flags": "required",
     "note": "lock | uninstall | restart | update_os | rename"
    },
    {
     "name": "args",
     "type": "object",
     "flags": "",
     "note": "Required for rename: { name } of letters, digits and hyphens, with no hyphen first or last, at most 63 characters and at most 15 on Windows."
    },
    {
     "name": "reason",
     "type": "string",
     "flags": "",
     "note": "Required for uninstall."
    }
   ]
  },
  {
   "id": "DeviceCommand",
   "name": "DeviceCommand",
   "group": "devices",
   "kind": "response",
   "note": "One command in a device’s history. The history is a bare array, newest first; ?limit takes 1 to 200 and defaults to 50, and there is no offset.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "deviceId",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "type",
     "type": "string",
     "flags": "",
     "note": "lock | uninstall | restart | update_os | rename"
    },
    {
     "name": "args",
     "type": "object",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "status",
     "type": "string",
     "flags": "read-only",
     "note": "queued, then delivered once the agent has collected it, then the result the agent reports: succeeded | failed | unsupported. A queued command can instead end as cancelled or expired."
    },
    {
     "name": "requestedBy",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "reason",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "batchId",
     "type": "string",
     "flags": "nullable, read-only",
     "note": "Shared by the commands of one batch call."
    },
    {
     "name": "result",
     "type": "object",
     "flags": "nullable, read-only",
     "note": "{ status, detail } as the agent reported it, or { detail } for a cancelled or expired command."
    },
    {
     "name": "deliveredAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "completedAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "updatedAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    }
   ]
  },
  {
   "id": "DeviceCommandResult",
   "name": "DeviceCommandResult",
   "group": "devices",
   "kind": "response",
   "note": "The reply to queueing commands. Each device is tried on its own, so read failed even on a 200. The single-device call answers ok and failed only, and on a refusal adds error and pendingCommand at the top level.",
   "extends": null,
   "fields": [
    {
     "name": "ok",
     "type": "object[]",
     "flags": "",
     "note": "Each { deviceId, commandId, noop }: noop is present, as true, only when nothing new was queued."
    },
    {
     "name": "failed",
     "type": "object[]",
     "flags": "",
     "note": "Batch call: each { deviceId, error } and, where one applies, pendingCommand. Single-device call: each { deviceId, error, status } and, where one applies, code and pendingCommand."
    },
    {
     "name": "batchId",
     "type": "string",
     "flags": "nullable",
     "note": "Batch call only. Null when the call named a single device."
    }
   ]
  },
  {
   "id": "DeviceGroup",
   "name": "DeviceGroup",
   "group": "devices",
   "kind": "response",
   "note": "One device group, as an entry of groups in the group list. That list answers { groups, fields, ops, fieldOps, complianceValues }: every group in one reply, ordered by name.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "name",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "description",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "kind",
     "type": "string",
     "flags": "",
     "note": "static | smart"
    },
    {
     "name": "platform",
     "type": "string",
     "flags": "nullable",
     "note": "macos | linux | windows. Null for a group that is not bound to one platform."
    },
    {
     "name": "criteria",
     "type": "object",
     "flags": "nullable",
     "note": "The rules of a smart group."
    },
    {
     "name": "createdBy",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "memberCount",
     "type": "integer",
     "flags": "read-only",
     "note": "Every member."
    },
    {
     "name": "enforceableCount",
     "type": "integer",
     "flags": "read-only",
     "note": "The members that profiles and rollouts actually target."
    }
   ]
  },
  {
   "id": "DeviceLite",
   "name": "DeviceLite",
   "group": "devices",
   "kind": "response",
   "note": "One entry of the device picker, which answers { items: DeviceLite[], total }. Retired devices and devices still waiting for enrolment approval are never included. Ordered by hostname.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "hostname",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "name",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "serialNumber",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "platform",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "ownerName",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "loggedInUser",
     "type": "string",
     "flags": "nullable",
     "note": ""
    }
   ]
  },
  {
   "id": "DeviceActivityEntry",
   "name": "DeviceActivityEntry",
   "group": "devices",
   "kind": "response",
   "note": "One audit-log entry about a device. The activity read is a bare array, newest first; ?limit defaults to 50 with a maximum of 200, and there is no offset.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "action",
     "type": "string",
     "flags": "",
     "note": "The audit action name."
    },
    {
     "name": "level",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "userEmail",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "",
     "note": ""
    },
    {
     "name": "details",
     "type": "string",
     "flags": "nullable",
     "note": ""
    }
   ]
  },
  {
   "id": "ProfileRollout",
   "name": "ProfileRollout",
   "group": "devices",
   "kind": "response",
   "note": "A staged rollout of one configuration profile, as stored. The rollout calls answer it as rollout, and a reply can carry computed fields beyond these.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "profileId",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "fromVersion",
     "type": "integer",
     "flags": "nullable, read-only",
     "note": "The profile version in force before the rollout."
    },
    {
     "name": "toVersion",
     "type": "integer",
     "flags": "read-only",
     "note": "The version being rolled out."
    },
    {
     "name": "rings",
     "type": "object[]",
     "flags": "",
     "note": "2 to 5 rings, each { type: \"groups\", groupIds, label }, { type: \"percent\", percent, label } or, for the last ring, { type: \"all\", label }."
    },
    {
     "name": "currentRing",
     "type": "integer",
     "flags": "read-only",
     "note": "The index of the ring now receiving the version, from 0."
    },
    {
     "name": "state",
     "type": "string",
     "flags": "read-only",
     "note": "running | paused | complete | rolled_back | canceled"
    },
    {
     "name": "autoPromote",
     "type": "boolean",
     "flags": "",
     "note": "Default true."
    },
    {
     "name": "promoteAfterHours",
     "type": "integer",
     "flags": "",
     "note": "Default 24. 1 to 720."
    },
    {
     "name": "ringStartedAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "startedBy",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "endedAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "endedBy",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "holdReason",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    }
   ]
  },
  {
   "id": "AgentRollout",
   "name": "AgentRollout",
   "group": "devices",
   "kind": "response",
   "note": "One agent version rollout. The rollout read answers { platform, rollouts: AgentRollout[] }: every rollout of the platform in one reply, newest first.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "platform",
     "type": "string",
     "flags": "",
     "note": "macos | linux | windows"
    },
    {
     "name": "version",
     "type": "string",
     "flags": "",
     "note": "The agent version targeted."
    },
    {
     "name": "enabled",
     "type": "boolean",
     "flags": "",
     "note": "False while the rollout is on hold."
    },
    {
     "name": "label",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "scope",
     "type": "object",
     "flags": "",
     "note": "{ type: \"all\" } or a scope naming groups or devices."
    },
    {
     "name": "scopeLabel",
     "type": "string",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "deviceCount",
     "type": "integer",
     "flags": "read-only",
     "note": "Devices in scope."
    },
    {
     "name": "onTarget",
     "type": "integer",
     "flags": "read-only",
     "note": "Devices in scope already on that version or a newer one."
    },
    {
     "name": "createdBy",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    }
   ]
  },
  {
   "id": "MdmStatus",
   "name": "MdmStatus",
   "group": "devices",
   "kind": "response",
   "note": "Whether Apple MDM is set up and reachable. { configured: false } alone when it is not set up.",
   "extends": null,
   "fields": [
    {
     "name": "configured",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "connected",
     "type": "boolean",
     "flags": "",
     "note": "Whether the MDM control plane answered."
    },
    {
     "name": "tenant",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "deviceCount",
     "type": "integer",
     "flags": "nullable",
     "note": "Null when the control plane did not answer."
    },
    {
     "name": "enrollmentUrl",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "enrollmentReady",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "hasCert",
     "type": "boolean",
     "flags": "",
     "note": "Whether a push certificate is installed."
    },
    {
     "name": "certNotAfter",
     "type": "string",
     "flags": "nullable",
     "note": "When the push certificate expires."
    }
   ]
  },
  {
   "id": "SoftwarePage",
   "name": "SoftwarePage",
   "group": "devices",
   "kind": "response",
   "note": "One page of the software inventory. Paged by ?page and ?pageSize (default 50, 1 to 500).",
   "extends": null,
   "fields": [
    {
     "name": "items",
     "type": "object[]",
     "flags": "",
     "note": "Each { name, publisher, deviceCount, platforms, versions: [{ version, count }] }."
    },
    {
     "name": "total",
     "type": "integer",
     "flags": "",
     "note": "Every title matching the filters."
    },
    {
     "name": "page",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "pageSize",
     "type": "integer",
     "flags": "",
     "note": ""
    }
   ]
  },
  {
   "id": "DeviceCreate",
   "name": "DeviceCreate",
   "group": "devices",
   "kind": "request",
   "note": "Adds a device record by hand. Only these keys are read; any other key is ignored.",
   "extends": null,
   "fields": [
    {
     "name": "name",
     "type": "string",
     "flags": "required",
     "note": ""
    },
    {
     "name": "type",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "serialNumber",
     "type": "string",
     "flags": "optional",
     "note": "Unique across devices."
    },
    {
     "name": "status",
     "type": "string",
     "flags": "optional",
     "note": "Default available."
    },
    {
     "name": "manufacturer",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "userId",
     "type": "integer",
     "flags": "optional",
     "note": "The owner, a user id."
    },
    {
     "name": "assetTag",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "department",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "location",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "costCenter",
     "type": "string",
     "flags": "optional",
     "note": ""
    }
   ]
  },
  {
   "id": "DeviceAlertSettingsUpdate",
   "name": "DeviceAlertSettingsUpdate",
   "group": "devices",
   "kind": "request",
   "note": "Only the keys you send are changed. A value of the wrong type or out of range answers 400 and nothing is saved.",
   "extends": null,
   "fields": [
    {
     "name": "pendingEnrollment",
     "type": "boolean",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "noncompliant",
     "type": "boolean",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "encryptionOff",
     "type": "boolean",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "silentDays",
     "type": "integer",
     "flags": "optional, nullable",
     "note": "1 to 365. Null turns the alert off."
    },
    {
     "name": "enrollTokenDays",
     "type": "integer",
     "flags": "optional, nullable",
     "note": "1 to 90. Null turns the alert off."
    },
    {
     "name": "pushCertDays",
     "type": "integer[]",
     "flags": "optional",
     "note": "At most 6 whole numbers, each 1 to 365. Stored without repeats, largest first."
    },
    {
     "name": "recipients",
     "type": "string",
     "flags": "optional",
     "note": "Only the value manage_devices is accepted; anything else answers 400."
    }
   ]
  },
  {
   "id": "DeviceStaleSettingUpdate",
   "name": "DeviceStaleSettingUpdate",
   "group": "devices",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "staleDays",
     "type": "integer",
     "flags": "required",
     "note": "A whole number from 1 to 365. deviceStaleDays is read when staleDays is not sent."
    }
   ]
  },
  {
   "id": "DeviceCustomFieldsWrite",
   "name": "DeviceCustomFieldsWrite",
   "group": "devices",
   "kind": "request",
   "note": "Replaces the whole list of custom device fields.",
   "extends": null,
   "fields": [
    {
     "name": "fields",
     "type": "object[]",
     "flags": "optional",
     "note": "Left out, or not an array, the list is emptied (200, not 400). At most 20 are kept. Each { key, label (cut to 60 characters), type: text | select | number | date (anything else is stored as text), options (select only: up to 50, each cut to 60 characters) }. An entry that is not an object, has no key or label, or repeats a key is skipped, not refused."
    }
   ]
  },
  {
   "id": "InstallInvitesSend",
   "name": "InstallInvitesSend",
   "group": "devices",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "userIds",
     "type": "integer[]",
     "flags": "required",
     "note": "The users to email. At least one whole number."
    }
   ]
  },
  {
   "id": "ReservationLink",
   "name": "ReservationLink",
   "group": "devices",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "reservationId",
     "type": "integer",
     "flags": "required",
     "note": "The id of the reservation record to link the device to."
    }
   ]
  },
  {
   "id": "EnrollmentReject",
   "name": "EnrollmentReject",
   "group": "devices",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "reason",
     "type": "string",
     "flags": "optional",
     "note": "At most 234 characters after trimming, or the answer is 400."
    }
   ]
  },
  {
   "id": "DeviceIdsRequest",
   "name": "DeviceIdsRequest",
   "group": "devices",
   "kind": "request",
   "note": "The body of the batch approve and unretire calls.",
   "extends": null,
   "fields": [
    {
     "name": "ids",
     "type": "integer[]",
     "flags": "required",
     "note": "1 to 500 device ids. Repeated ids are counted once; an entry that is not a positive whole number is dropped."
    }
   ]
  },
  {
   "id": "DevicePolicyUpdate",
   "name": "DevicePolicyUpdate",
   "group": "devices",
   "kind": "request",
   "note": "Only the keys you send are changed. The hardening and software-update switches named on the 409 are read by this route too.",
   "extends": null,
   "fields": [
    {
     "name": "platform",
     "type": "string: macos | linux | windows",
     "flags": "optional",
     "note": "Default macos."
    },
    {
     "name": "checkInIntervalMinutes",
     "type": "integer",
     "flags": "optional",
     "note": "Held to 5 to 1440."
    },
    {
     "name": "allowUserQuit",
     "type": "boolean",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "minOsVersion",
     "type": "string",
     "flags": "optional, null clears it",
     "note": "A dotted number such as 14.5, 24.04 or 10.0.22631. An empty string also clears it."
    },
    {
     "name": "minAgentVersion",
     "type": "string",
     "flags": "optional, null clears it",
     "note": "A version such as 1.1.0, not above the adopted agent version. An empty string also clears it."
    }
   ]
  },
  {
   "id": "DeviceAssign",
   "name": "DeviceAssign",
   "group": "devices",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "userId",
     "type": "integer",
     "flags": "optional, null clears it",
     "note": "The owner, a user id. Null, or leaving the key out, unassigns the owner."
    },
    {
     "name": "assetTag",
     "type": "string",
     "flags": "optional",
     "note": "Cut to 255 characters. An empty string clears it; a key left out keeps its value. The same for department, location and costCenter."
    },
    {
     "name": "department",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "location",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "costCenter",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "customFields",
     "type": "object",
     "flags": "optional",
     "note": "Values keyed by custom field key. Keys that are not in the custom field list are dropped."
    }
   ]
  },
  {
   "id": "DeviceBatchAssign",
   "name": "DeviceBatchAssign",
   "group": "devices",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "ids",
     "type": "integer[]",
     "flags": "required",
     "note": "1 to 500 device ids. Repeated ids are counted once; an entry that is not a positive whole number is dropped."
    },
    {
     "name": "userId",
     "type": "integer",
     "flags": "required, nullable",
     "note": "A user id, or null to unassign. Leaving the key out answers 400."
    }
   ]
  },
  {
   "id": "DeviceBatchCommandCreate",
   "name": "DeviceBatchCommandCreate",
   "group": "devices",
   "kind": "request",
   "note": "Queues one command for many devices.",
   "extends": "DeviceCommandCreate",
   "fields": [
    {
     "name": "ids",
     "type": "integer[]",
     "flags": "required",
     "note": "1 to 500 device ids. Repeated ids are counted once; an entry that is not a positive whole number is dropped."
    }
   ]
  },
  {
   "id": "DeviceRetire",
   "name": "DeviceRetire",
   "group": "devices",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "ids",
     "type": "integer[]",
     "flags": "required",
     "note": "1 to 500 device ids. Repeated ids are counted once; an entry that is not a positive whole number is dropped."
    },
    {
     "name": "reason",
     "type": "string",
     "flags": "required",
     "note": "3 to 255 characters after trimming."
    }
   ]
  },
  {
   "id": "DeviceGroupCreate",
   "name": "DeviceGroupCreate",
   "group": "devices",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "name",
     "type": "string",
     "flags": "required",
     "note": ""
    },
    {
     "name": "description",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "kind",
     "type": "string: static | smart",
     "flags": "optional",
     "note": "Default static: any value other than smart is read as static."
    },
    {
     "name": "platform",
     "type": "string: macos | linux | windows",
     "flags": "optional",
     "note": "Binds the group to one platform. Any other value leaves it unbound."
    },
    {
     "name": "criteria",
     "type": "object",
     "flags": "optional",
     "note": "Smart groups only, where it is required and checked."
    },
    {
     "name": "deviceIds",
     "type": "integer[]",
     "flags": "optional",
     "note": "Static groups only: the first members. In a platform-bound group, devices of another platform are left out."
    }
   ]
  },
  {
   "id": "DeviceGroupUpdate",
   "name": "DeviceGroupUpdate",
   "group": "devices",
   "kind": "request",
   "note": "Only the keys you send are changed.",
   "extends": null,
   "fields": [
    {
     "name": "name",
     "type": "string",
     "flags": "optional",
     "note": "An empty name is ignored."
    },
    {
     "name": "description",
     "type": "string",
     "flags": "optional",
     "note": "An empty string clears it."
    },
    {
     "name": "criteria",
     "type": "object",
     "flags": "optional",
     "note": "Smart groups only; ignored on a static group."
    }
   ]
  },
  {
   "id": "DeviceGroupPreview",
   "name": "DeviceGroupPreview",
   "group": "devices",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "criteria",
     "type": "object",
     "flags": "required",
     "note": "The rules to try, checked as for a smart group."
    },
    {
     "name": "platform",
     "type": "string: macos | linux | windows",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "kind",
     "type": "string",
     "flags": "optional",
     "note": "When sent it must be smart."
    }
   ]
  },
  {
   "id": "DeviceGroupMembers",
   "name": "DeviceGroupMembers",
   "group": "devices",
   "kind": "request",
   "note": "The body of the add-members and bulk-remove calls.",
   "extends": null,
   "fields": [
    {
     "name": "deviceIds",
     "type": "integer[]",
     "flags": "required",
     "note": "At least one device id."
    }
   ]
  },
  {
   "id": "DeviceProfileCreate",
   "name": "DeviceProfileCreate",
   "group": "devices",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "name",
     "type": "string",
     "flags": "required",
     "note": ""
    },
    {
     "name": "platform",
     "type": "string: macos | linux | windows",
     "flags": "required",
     "note": "Cannot be changed later."
    },
    {
     "name": "payloads",
     "type": "object[]",
     "flags": "optional",
     "note": "Each { key, settings }. A key the platform does not support, or one that is not available, answers 400."
    },
    {
     "name": "scope",
     "type": "object",
     "flags": "optional",
     "note": "{ type: all | groups | devices, groupIds, deviceIds, excludeGroupIds, excludeDeviceIds }. Default type all."
    },
    {
     "name": "description",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "enabled",
     "type": "boolean",
     "flags": "optional",
     "note": "Default false."
    }
   ]
  },
  {
   "id": "DeviceProfileUpdate",
   "name": "DeviceProfileUpdate",
   "group": "devices",
   "kind": "request",
   "note": "Only the keys you send are changed. The profile version goes up by one when something changed.",
   "extends": null,
   "fields": [
    {
     "name": "name",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "description",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "payloads",
     "type": "object[]",
     "flags": "optional",
     "note": "As on create."
    },
    {
     "name": "scope",
     "type": "object",
     "flags": "optional",
     "note": "As on create."
    },
    {
     "name": "enabled",
     "type": "boolean",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "platform",
     "type": "string: macos | linux | windows",
     "flags": "optional",
     "note": "A profile that has a platform keeps it: a different value answers 400."
    },
    {
     "name": "rollout",
     "type": "ProfileRolloutStart",
     "flags": "optional",
     "note": "Saves the change and starts a staged rollout of it in one call. The reply then also carries rollout and mdm. 400 when nothing changed on a profile that is already on."
    }
   ]
  },
  {
   "id": "ProfileIdsRequest",
   "name": "ProfileIdsRequest",
   "group": "devices",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "ids",
     "type": "integer[]",
     "flags": "required",
     "note": "1 to 100 profile ids. A repeated id is deployed once."
    }
   ]
  },
  {
   "id": "ProfileRolloutStart",
   "name": "ProfileRolloutStart",
   "group": "devices",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "rings",
     "type": "object[]",
     "flags": "required",
     "note": "2 to 5 rings. Each is { type: \"groups\", groupIds } or { type: \"percent\", percent } with 1 to 99 and each share larger than the one before, plus an optional label of up to 40 characters. The last ring must be { type: \"all\" }."
    },
    {
     "name": "autoPromote",
     "type": "boolean",
     "flags": "optional",
     "note": "Default true."
    },
    {
     "name": "promoteAfterHours",
     "type": "integer",
     "flags": "optional",
     "note": "1 to 720. Default 24."
    }
   ]
  },
  {
   "id": "ProfileRolloutSettings",
   "name": "ProfileRolloutSettings",
   "group": "devices",
   "kind": "request",
   "note": "Either key or both.",
   "extends": null,
   "fields": [
    {
     "name": "autoPromote",
     "type": "boolean",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "promoteAfterHours",
     "type": "integer",
     "flags": "optional",
     "note": "1 to 720."
    }
   ]
  },
  {
   "id": "MdmEnrollCodeCreate",
   "name": "MdmEnrollCodeCreate",
   "group": "devices",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "reusable",
     "type": "boolean",
     "flags": "optional",
     "note": "True for a reusable code. Anything else gives a single-use code."
    }
   ]
  },
  {
   "id": "MdmPushCertUpload",
   "name": "MdmPushCertUpload",
   "group": "devices",
   "kind": "request",
   "note": "Send certPem (with keyPem), or p12 (with passphrase). 413 when the certificate and key together are over 200,000 characters.",
   "extends": null,
   "fields": [
    {
     "name": "certPem",
     "type": "string",
     "flags": "optional",
     "note": "The Apple push certificate in PEM form. Required unless p12 is sent."
    },
    {
     "name": "keyPem",
     "type": "string",
     "flags": "optional",
     "note": "Its private key in PEM form."
    },
    {
     "name": "p12",
     "type": "string",
     "flags": "optional",
     "note": "A .p12 file holding both. When it is sent, certPem and keyPem are not read. 400 when it cannot be opened."
    },
    {
     "name": "passphrase",
     "type": "string",
     "flags": "optional",
     "note": "The export password of the .p12."
    }
   ]
  },
  {
   "id": "MdmLockRequest",
   "name": "MdmLockRequest",
   "group": "devices",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "message",
     "type": "string",
     "flags": "optional",
     "note": "Passed to the MDM control plane with the lock."
    }
   ]
  },
  {
   "id": "DestructiveReason",
   "name": "DestructiveReason",
   "group": "devices",
   "kind": "request",
   "note": "The body of the remote wipe, recovery-key reveal and recovery-key rotation calls.",
   "extends": null,
   "fields": [
    {
     "name": "reason",
     "type": "string",
     "flags": "required",
     "note": "At least 3 characters after trimming. The wipe and rotation calls cut it to 500 characters."
    }
   ]
  },
  {
   "id": "AgentRolloutHold",
   "name": "AgentRolloutHold",
   "group": "devices",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "enabled",
     "type": "boolean",
     "flags": "optional",
     "note": "True resumes the rollout. False, or leaving the key out, holds it."
    }
   ]
  },
  {
   "id": "EnrollmentTokenIssued",
   "name": "EnrollmentTokenIssued",
   "group": "devices",
   "kind": "response",
   "note": "The reply to issuing an enrolment token. The only reply that carries the token value.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "token",
     "type": "string",
     "flags": "read-only",
     "note": "The token value. Only its hash is stored."
    },
    {
     "name": "label",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "expiresAt",
     "type": "string (date-time)",
     "flags": "",
     "note": ""
    },
    {
     "name": "maxUses",
     "type": "integer",
     "flags": "",
     "note": ""
    }
   ]
  },
  {
   "id": "DeviceBatchOutcome",
   "name": "DeviceBatchOutcome",
   "group": "devices",
   "kind": "response",
   "note": "The reply to the batch approve and batch assign calls. Each device is tried on its own, so read failed even on a 200.",
   "extends": null,
   "fields": [
    {
     "name": "ok",
     "type": "object[]",
     "flags": "",
     "note": "Batch approve: each { deviceId, enrollmentApproved: true }. Batch assign: each { deviceId, userId }, userId null when unassigned."
    },
    {
     "name": "failed",
     "type": "object[]",
     "flags": "",
     "note": "Each { deviceId, error }."
    }
   ]
  },
  {
   "id": "DeviceLifecycleOutcome",
   "name": "DeviceLifecycleOutcome",
   "group": "devices",
   "kind": "response",
   "note": "The reply to the retire and unretire calls. Each device is tried on its own, so read failed even on a 200.",
   "extends": null,
   "fields": [
    {
     "name": "ok",
     "type": "integer[]",
     "flags": "",
     "note": "The ids done. A device already in the state asked for is listed here and nothing about it is changed."
    },
    {
     "name": "failed",
     "type": "object[]",
     "flags": "",
     "note": "Each { id, error }; \"Device not found\" for an unknown id."
    }
   ]
  },
  {
   "id": "AgentEnrollRequest",
   "name": "AgentEnrollRequest",
   "group": "devices",
   "kind": "request",
   "note": "The first call an agent makes. A device is one record per serial number.",
   "extends": null,
   "fields": [
    {
     "name": "enrollmentToken",
     "type": "string",
     "flags": "required",
     "note": ""
    },
    {
     "name": "serialNumber",
     "type": "string",
     "flags": "required",
     "note": "At most 255 characters. Spaces at either end are removed."
    },
    {
     "name": "publicKey",
     "type": "string",
     "flags": "required",
     "note": "The device's P-256 public key. A key on another curve is refused."
    },
    {
     "name": "hostname",
     "type": "string",
     "flags": "optional",
     "note": "A string longer than 255 characters is cut to 255. A value that is not text or a number is ignored."
    },
    {
     "name": "osVersion",
     "type": "string",
     "flags": "optional",
     "note": "A string longer than 255 characters is cut to 255. A value that is not text or a number is ignored."
    },
    {
     "name": "osBuild",
     "type": "string",
     "flags": "optional",
     "note": "A string longer than 255 characters is cut to 255. A value that is not text or a number is ignored."
    },
    {
     "name": "architecture",
     "type": "string",
     "flags": "optional",
     "note": "A string longer than 255 characters is cut to 255. A value that is not text or a number is ignored."
    },
    {
     "name": "chip",
     "type": "string",
     "flags": "optional",
     "note": "A string longer than 255 characters is cut to 255. A value that is not text or a number is ignored."
    },
    {
     "name": "modelName",
     "type": "string",
     "flags": "optional",
     "note": "A string longer than 255 characters is cut to 255. A value that is not text or a number is ignored."
    },
    {
     "name": "agentVersion",
     "type": "string",
     "flags": "optional",
     "note": "The version of the agent that is running. The check-in offers an update only when a newer version is the target."
    },
    {
     "name": "loggedInUser",
     "type": "string",
     "flags": "optional",
     "note": "Stored for display. It never sets the owner of the device. The Windows agent sends the name of the account its own service runs as."
    },
    {
     "name": "platform",
     "type": "string",
     "flags": "optional",
     "note": "macos, linux or windows, in any letter case. At check-in any other value gets the macOS policy."
    },
    {
     "name": "cpuCores",
     "type": "integer",
     "flags": "optional",
     "note": "A whole number, zero or more, or the same written as digits. Any other value is ignored."
    },
    {
     "name": "memoryBytes",
     "type": "integer",
     "flags": "optional",
     "note": "A whole number, zero or more, or the same written as digits. Any other value is ignored."
    },
    {
     "name": "diskTotalBytes",
     "type": "integer",
     "flags": "optional",
     "note": "A whole number, zero or more, or the same written as digits. Any other value is ignored."
    },
    {
     "name": "diskFreeBytes",
     "type": "integer",
     "flags": "optional",
     "note": "A whole number, zero or more, or the same written as digits. Any other value is ignored."
    },
    {
     "name": "osUpdateAvailable",
     "type": "boolean",
     "flags": "optional",
     "note": "Also read: the text true or false, 1 or 0."
    },
    {
     "name": "inventory",
     "type": "object",
     "flags": "optional",
     "note": "Free-form: any keys, stored as sent. An installedSoftware list of { name, version, publisher, bundleId } entries is kept as the software inventory, at most 2,000 entries."
    }
   ]
  },
  {
   "id": "AgentEnrollReply",
   "name": "AgentEnrollReply",
   "group": "devices",
   "kind": "response",
   "note": "The reply to an enrolment.",
   "extends": null,
   "fields": [
    {
     "name": "deviceId",
     "type": "integer",
     "flags": "",
     "note": "The id to send in the x-zx-device-id header of every signed call."
    }
   ]
  },
  {
   "id": "AgentHeartbeatRequest",
   "name": "AgentHeartbeatRequest",
   "group": "devices",
   "kind": "request",
   "note": "The signed check-in. No key is required: the device is named by the signed headers, and the signature is over the exact bytes sent.",
   "extends": null,
   "fields": [
    {
     "name": "hostname",
     "type": "string",
     "flags": "optional",
     "note": "A string longer than 255 characters is cut to 255. A value that is not text or a number is ignored."
    },
    {
     "name": "osVersion",
     "type": "string",
     "flags": "optional",
     "note": "A string longer than 255 characters is cut to 255. A value that is not text or a number is ignored."
    },
    {
     "name": "osBuild",
     "type": "string",
     "flags": "optional",
     "note": "A string longer than 255 characters is cut to 255. A value that is not text or a number is ignored."
    },
    {
     "name": "architecture",
     "type": "string",
     "flags": "optional",
     "note": "A string longer than 255 characters is cut to 255. A value that is not text or a number is ignored."
    },
    {
     "name": "chip",
     "type": "string",
     "flags": "optional",
     "note": "A string longer than 255 characters is cut to 255. A value that is not text or a number is ignored."
    },
    {
     "name": "modelName",
     "type": "string",
     "flags": "optional",
     "note": "A string longer than 255 characters is cut to 255. A value that is not text or a number is ignored."
    },
    {
     "name": "agentVersion",
     "type": "string",
     "flags": "optional",
     "note": "The version of the agent that is running. The check-in offers an update only when a newer version is the target."
    },
    {
     "name": "loggedInUser",
     "type": "string",
     "flags": "optional",
     "note": "Stored for display. It never sets the owner of the device. The Windows agent sends the name of the account its own service runs as."
    },
    {
     "name": "platform",
     "type": "string",
     "flags": "optional",
     "note": "macos, linux or windows, in any letter case. At check-in any other value gets the macOS policy."
    },
    {
     "name": "cpuCores",
     "type": "integer",
     "flags": "optional",
     "note": "A whole number, zero or more, or the same written as digits. Any other value is ignored."
    },
    {
     "name": "memoryBytes",
     "type": "integer",
     "flags": "optional",
     "note": "A whole number, zero or more, or the same written as digits. Any other value is ignored."
    },
    {
     "name": "diskTotalBytes",
     "type": "integer",
     "flags": "optional",
     "note": "A whole number, zero or more, or the same written as digits. Any other value is ignored."
    },
    {
     "name": "diskFreeBytes",
     "type": "integer",
     "flags": "optional",
     "note": "A whole number, zero or more, or the same written as digits. Any other value is ignored."
    },
    {
     "name": "osUpdateAvailable",
     "type": "boolean",
     "flags": "optional",
     "note": "Also read: the text true or false, 1 or 0."
    },
    {
     "name": "inventory",
     "type": "object",
     "flags": "optional",
     "note": "Free-form: any keys, stored as sent. An installedSoftware list of { name, version, publisher, bundleId } entries is kept as the software inventory, at most 2,000 entries."
    },
    {
     "name": "capabilities",
     "type": "string[]",
     "flags": "optional",
     "note": "What the agent supports, for example commands_v2 or enforcement_v1. At most 32 are read; each is lower-cased and cut to 64 characters."
    },
    {
     "name": "commandResults",
     "type": "AgentCommandResult[]",
     "flags": "optional",
     "note": "The outcome of commands delivered on an earlier check-in. The first 50 entries are read."
    },
    {
     "name": "enforcement",
     "type": "AgentEnforcementReport",
     "flags": "optional",
     "note": "Which policy the agent applied."
    }
   ]
  },
  {
   "id": "AgentCommandResult",
   "name": "AgentCommandResult",
   "group": "devices",
   "kind": "request",
   "note": "One entry of commandResults. An entry with an unknown status, or an id that is not a positive whole number, is skipped; a repeated id is read once.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "required",
     "note": "The id of the command, as the check-in delivered it."
    },
    {
     "name": "status",
     "type": "string: succeeded | failed | unsupported",
     "flags": "required",
     "note": ""
    },
    {
     "name": "detail",
     "type": "string",
     "flags": "optional",
     "note": "Cut to 500 characters."
    }
   ]
  },
  {
   "id": "AgentEnforcementReport",
   "name": "AgentEnforcementReport",
   "group": "devices",
   "kind": "request",
   "note": "The enforcement key of a check-in.",
   "extends": null,
   "fields": [
    {
     "name": "policyHash",
     "type": "string",
     "flags": "optional",
     "note": "The policyHash of the policy the agent applied, from an earlier reply."
    },
    {
     "name": "applied",
     "type": "string[]",
     "flags": "optional",
     "note": "The policy fields applied. At most 200 are read, each cut to 64 characters."
    },
    {
     "name": "failed",
     "type": "object[]",
     "flags": "optional",
     "note": "Each { field, error }: a policy field that could not be applied and why. At most 200 are read; field is cut to 64 characters and error to 200."
    }
   ]
  },
  {
   "id": "AgentHeartbeatReply",
   "name": "AgentHeartbeatReply",
   "group": "devices",
   "kind": "response",
   "note": "The reply to a check-in: the policy to apply, the commands to run and the update to install. It can carry further keys.",
   "extends": null,
   "fields": [
    {
     "name": "ok",
     "type": "boolean",
     "flags": "",
     "note": "Always true."
    },
    {
     "name": "deviceId",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "command",
     "type": "string",
     "flags": "nullable",
     "note": "One command for an agent that does not declare commands_v2: lock or uninstall. Null when nothing is waiting, and null when the same command is in commands."
    },
    {
     "name": "trustBundle",
     "type": "object",
     "flags": "nullable",
     "note": "The current list of release-signing keys, or null when the instance holds none."
    },
    {
     "name": "policy",
     "type": "AgentPolicy",
     "flags": "",
     "note": ""
    },
    {
     "name": "update",
     "type": "AgentUpdate",
     "flags": "nullable",
     "note": "Null unless a version newer than the agentVersion sent is the target for this device."
    },
    {
     "name": "policyHash",
     "type": "string",
     "flags": "",
     "note": "SHA-256 of the policy object, 64 hex characters. Send it back in enforcement.policyHash."
    },
    {
     "name": "commands",
     "type": "AgentCommand[]",
     "flags": "",
     "note": "Only for an agent that declares commands_v2. At most 20 per check-in, oldest first. Each is delivered once."
    },
    {
     "name": "commandResultsAccepted",
     "type": "boolean",
     "flags": "",
     "note": "Only for an agent that declares commands_v2, and only when the request carried commandResults. False means they were not stored: send them again."
    }
   ]
  },
  {
   "id": "AgentCommand",
   "name": "AgentCommand",
   "group": "devices",
   "kind": "response",
   "note": "One entry of commands in the check-in reply.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "",
     "note": "Send it back as the id of the AgentCommandResult."
    },
    {
     "name": "type",
     "type": "string",
     "flags": "",
     "note": "The command, for example lock, uninstall, restart, update_os or rename."
    },
    {
     "name": "args",
     "type": "object",
     "flags": "nullable",
     "note": "The arguments of the command, for example { name } for rename."
    }
   ]
  },
  {
   "id": "AgentUpdate",
   "name": "AgentUpdate",
   "group": "devices",
   "kind": "response",
   "note": "The agent version to install, in the update key of the check-in reply.",
   "extends": null,
   "fields": [
    {
     "name": "version",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "url",
     "type": "string",
     "flags": "",
     "note": "Where to download exactly this version."
    },
    {
     "name": "sha256",
     "type": "string",
     "flags": "",
     "note": "SHA-256 of the file, 64 hex characters."
    },
    {
     "name": "sig",
     "type": "string",
     "flags": "nullable",
     "note": "The release signature of this version."
    },
    {
     "name": "kid",
     "type": "string",
     "flags": "nullable",
     "note": "The id of the key that signature was made with."
    }
   ]
  },
  {
   "id": "AgentPolicy",
   "name": "AgentPolicy",
   "group": "devices",
   "kind": "response",
   "note": "The fleet policy of the agent's platform, in the policy key of the check-in reply. A null field means: leave that setting as it is. The object carries further hardening keys of the same kind that are not listed here.",
   "extends": null,
   "fields": [
    {
     "name": "allowUserQuit",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "checkInIntervalMinutes",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "manageUpdates",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "updateAutoCheck",
     "type": "boolean",
     "flags": "nullable",
     "note": "Null unless manageUpdates is true."
    },
    {
     "name": "updateAutoDownload",
     "type": "boolean",
     "flags": "nullable",
     "note": "Null unless manageUpdates is true."
    },
    {
     "name": "updateInstallSecurity",
     "type": "boolean",
     "flags": "nullable",
     "note": "Null unless manageUpdates is true."
    },
    {
     "name": "updateInstallMacOS",
     "type": "boolean",
     "flags": "nullable",
     "note": "Null unless manageUpdates is true."
    },
    {
     "name": "updateInstallApps",
     "type": "boolean",
     "flags": "nullable",
     "note": "Null unless manageUpdates is true."
    },
    {
     "name": "manageCompliance",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "complianceFirewall",
     "type": "boolean",
     "flags": "nullable",
     "note": "Null unless manageCompliance is true."
    },
    {
     "name": "complianceDisableSSH",
     "type": "boolean",
     "flags": "nullable",
     "note": "Null unless manageCompliance is true."
    },
    {
     "name": "complianceDisableScreenSharing",
     "type": "boolean",
     "flags": "nullable",
     "note": "Null unless manageCompliance is true."
    },
    {
     "name": "complianceDisableGuest",
     "type": "boolean",
     "flags": "nullable",
     "note": "Null unless manageCompliance is true."
    },
    {
     "name": "complianceDisableAutoLogin",
     "type": "boolean",
     "flags": "nullable",
     "note": "Null unless manageCompliance is true."
    },
    {
     "name": "complianceGatekeeper",
     "type": "boolean",
     "flags": "nullable",
     "note": "Null unless manageCompliance is true."
    },
    {
     "name": "complianceRequireScreenLock",
     "type": "boolean",
     "flags": "nullable",
     "note": "Null unless manageCompliance is true."
    },
    {
     "name": "complianceScreenLockMinutes",
     "type": "integer",
     "flags": "nullable",
     "note": "Null unless manageCompliance is true."
    },
    {
     "name": "complianceAuditd",
     "type": "boolean",
     "flags": "nullable",
     "note": "Null unless manageCompliance is true."
    },
    {
     "name": "complianceTimeSync",
     "type": "boolean",
     "flags": "nullable",
     "note": "Null unless manageCompliance is true."
    },
    {
     "name": "complianceFail2ban",
     "type": "boolean",
     "flags": "nullable",
     "note": "Null unless manageCompliance is true."
    },
    {
     "name": "complianceDefender",
     "type": "boolean",
     "flags": "nullable",
     "note": "Null unless manageCompliance is true."
    },
    {
     "name": "complianceBitlocker",
     "type": "boolean",
     "flags": "nullable",
     "note": "Null unless manageCompliance is true."
    }
   ]
  },
  {
   "id": "AgentRecoveryKeyEscrow",
   "name": "AgentRecoveryKeyEscrow",
   "group": "devices",
   "kind": "request",
   "note": "A signed call. One key is kept per device and key type; a later call replaces it.",
   "extends": null,
   "fields": [
    {
     "name": "keyType",
     "type": "string: bitlocker | filevault",
     "flags": "required",
     "note": "Read in any letter case."
    },
    {
     "name": "recoveryKey",
     "type": "string",
     "flags": "required",
     "note": ""
    },
    {
     "name": "volumeId",
     "type": "string",
     "flags": "optional",
     "note": "Cut to 255 characters."
    }
   ]
  },
  {
   "id": "AgentMdmEnrollRedeem",
   "name": "AgentMdmEnrollRedeem",
   "group": "devices",
   "kind": "request",
   "note": "Exchanges an enrolment code for the profile to install. code is the only key read.",
   "extends": null,
   "fields": [
    {
     "name": "code",
     "type": "string",
     "flags": "required",
     "note": "The enrolment code: at least 6 letters or digits."
    }
   ]
  },
  {
   "id": "LoginRequest",
   "name": "LoginRequest",
   "group": "signin",
   "kind": "request",
   "note": "The body of the password sign-in.",
   "extends": null,
   "fields": [
    {
     "name": "username",
     "type": "string",
     "flags": "required",
     "note": "The account email. At most 255 characters."
    },
    {
     "name": "password",
     "type": "string",
     "flags": "required",
     "note": "At most 1024 characters."
    },
    {
     "name": "consentAccepted",
     "type": "boolean",
     "flags": "",
     "note": "true accepts the current terms in the same call, when the first answer was a LoginTermsChallenge."
    }
   ]
  },
  {
   "id": "SessionGrant",
   "name": "SessionGrant",
   "group": "signin",
   "kind": "response",
   "note": "A session was opened. Answered by the password sign-in, the session refresh, the step-up, the magic-link redemption, the terms acceptance, both passkey sign-ins and the passkey step-up. The gateway also sets the same token as the session cookie.",
   "extends": null,
   "fields": [
    {
     "name": "auth",
     "type": "boolean",
     "flags": "",
     "note": "Always true."
    },
    {
     "name": "token",
     "type": "string",
     "flags": "",
     "note": "The session token (a JWT). Send it as Authorization: Bearer, or rely on the cookie."
    }
   ]
  },
  {
   "id": "LoginTermsChallenge",
   "name": "LoginTermsChallenge",
   "group": "signin",
   "kind": "response",
   "note": "Answered with status 200 instead of a SessionGrant when the current terms are not yet accepted. No session exists yet.",
   "extends": null,
   "fields": [
    {
     "name": "termsAcceptanceRequired",
     "type": "boolean",
     "flags": "",
     "note": "Always true. This field tells the three challenge objects apart."
    },
    {
     "name": "userId",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "termsVersion",
     "type": "string",
     "flags": "",
     "note": "The version of the terms to show."
    },
    {
     "name": "termsToken",
     "type": "string",
     "flags": "",
     "note": "Present on the magic-link, passkey and single sign-on paths only: the single-use token the terms acceptance call takes, valid for 10 minutes. The password sign-in sends no token; repeat the sign-in with consentAccepted: true."
    }
   ]
  },
  {
   "id": "LoginPasswordChangeChallenge",
   "name": "LoginPasswordChangeChallenge",
   "group": "signin",
   "kind": "response",
   "note": "Answered with status 200 instead of a SessionGrant when the account must choose a new password first.",
   "extends": null,
   "fields": [
    {
     "name": "passwordChangeRequired",
     "type": "boolean",
     "flags": "",
     "note": "Always true."
    },
    {
     "name": "userId",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "requirements",
     "type": "object",
     "flags": "",
     "note": "{ minLength: integer, requireUppercase: boolean, requireNumbers: boolean, requireSymbols: boolean }: the strictest value of every policy that applies to the account."
    },
    {
     "name": "passwordChangeToken",
     "type": "string",
     "flags": "",
     "note": "The single-use token the password change call takes as its Bearer token."
    }
   ]
  },
  {
   "id": "LoginMfaChallenge",
   "name": "LoginMfaChallenge",
   "group": "signin",
   "kind": "response",
   "note": "Answered with status 200 instead of a SessionGrant when a second factor is still owed.",
   "extends": null,
   "fields": [
    {
     "name": "mfaRequired",
     "type": "boolean",
     "flags": "",
     "note": "Always true."
    },
    {
     "name": "mfaStatus",
     "type": "string",
     "flags": "",
     "note": "setup_pending (no factor enrolled yet: enrol one with the token) | challenge_pending (answer with an enrolled factor)."
    },
    {
     "name": "userId",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "mfaToken",
     "type": "string",
     "flags": "",
     "note": "A short-lived token for the enrolment and verification calls. It is not a session."
    },
    {
     "name": "mfaMethods",
     "type": "object",
     "flags": "",
     "note": "Present with challenge_pending only: { passkey: boolean, totp: boolean }, which factors the account has enrolled."
    }
   ]
  },
  {
   "id": "CurrentUser",
   "name": "CurrentUser",
   "group": "signin",
   "kind": "response",
   "note": "The user object inside the reply of the signed-in user read: { auth: true, user }.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "username",
     "type": "string",
     "flags": "",
     "note": "The account email."
    },
    {
     "name": "firstName",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "lastName",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "roles",
     "type": "string[]",
     "flags": "",
     "note": "Role names, held directly or through a group."
    },
    {
     "name": "groups",
     "type": "string[]",
     "flags": "",
     "note": "Group names."
    },
    {
     "name": "permissions",
     "type": "string[]",
     "flags": "",
     "note": "Permission names, held directly, through a role or through a group."
    },
    {
     "name": "mfaEnabled",
     "type": "boolean",
     "flags": "",
     "note": "The MFA flag stored on the account."
    },
    {
     "name": "mfaVerified",
     "type": "boolean",
     "flags": "",
     "note": "Whether this session proved a second factor."
    },
    {
     "name": "mfaRequired",
     "type": "boolean",
     "flags": "",
     "note": "Whether the account is required to use one."
    },
    {
     "name": "supportSession",
     "type": "SupportSessionInfo",
     "flags": "",
     "note": "Present only in a support-access session."
    }
   ]
  },
  {
   "id": "SupportSessionInfo",
   "name": "SupportSessionInfo",
   "group": "signin",
   "kind": "response",
   "note": "Marks a session as a time-boxed support-access session.",
   "extends": null,
   "fields": [
    {
     "name": "grantId",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "scope",
     "type": "string",
     "flags": "",
     "note": "read_only | role"
    },
    {
     "name": "roleName",
     "type": "string",
     "flags": "nullable",
     "note": "The role granted. Null for read_only."
    },
    {
     "name": "expiresAt",
     "type": "string (date-time)",
     "flags": "",
     "note": ""
    }
   ]
  },
  {
   "id": "PasswordChangeResult",
   "name": "PasswordChangeResult",
   "group": "signin",
   "kind": "response",
   "note": "The reply to a forced password change. It opens no session: sign in again afterwards.",
   "extends": null,
   "fields": [
    {
     "name": "success",
     "type": "boolean",
     "flags": "",
     "note": "Always true."
    },
    {
     "name": "message",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "devicesRevoked",
     "type": "integer",
     "flags": "",
     "note": "How many trusted devices of the account were revoked by the change."
    }
   ]
  },
  {
   "id": "InviteInspection",
   "name": "InviteInspection",
   "group": "signin",
   "kind": "response",
   "note": "Who a set-password invitation is for. Status 410 with { error } when the token is unknown, used or expired.",
   "extends": null,
   "fields": [
    {
     "name": "valid",
     "type": "boolean",
     "flags": "",
     "note": "Always true."
    },
    {
     "name": "email",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "firstName",
     "type": "string",
     "flags": "",
     "note": "Empty string when the account has none."
    },
    {
     "name": "expiresAt",
     "type": "string (date-time)",
     "flags": "",
     "note": ""
    },
    {
     "name": "policy",
     "type": "object",
     "flags": "",
     "note": "{ minLength: integer, requireUppercase: boolean, requireNumbers: boolean, requireSymbols: boolean }: the strictest value of every policy that applies to the account."
    }
   ]
  },
  {
   "id": "InviteAcceptRequest",
   "name": "InviteAcceptRequest",
   "group": "signin",
   "kind": "request",
   "note": "Sets the password of the invited account. The reply is { success: true, email } and opens no session.",
   "extends": null,
   "fields": [
    {
     "name": "token",
     "type": "string",
     "flags": "required",
     "note": "The invitation token. At most 128 characters."
    },
    {
     "name": "password",
     "type": "string",
     "flags": "required",
     "note": "At most 1024 characters, and it must meet the policy the inspection returned."
    }
   ]
  },
  {
   "id": "TotpSetup",
   "name": "TotpSetup",
   "group": "signin",
   "kind": "response",
   "note": "The reply to starting authenticator-app enrolment. The secret is pending until it is confirmed with a code.",
   "extends": null,
   "fields": [
    {
     "name": "secret",
     "type": "string",
     "flags": "",
     "note": "The shared secret, base32."
    },
    {
     "name": "otpauth_url",
     "type": "string",
     "flags": "",
     "note": "The same secret as an otpauth:// address, for a QR code."
    }
   ]
  },
  {
   "id": "MfaEnrolmentResult",
   "name": "MfaEnrolmentResult",
   "group": "signin",
   "kind": "response",
   "note": "The reply to confirming an authenticator app. Registering a passkey answers verified: true in place of auth, with recoveryCodes under the same rule.",
   "extends": null,
   "fields": [
    {
     "name": "auth",
     "type": "boolean",
     "flags": "",
     "note": "Always true."
    },
    {
     "name": "token",
     "type": "string",
     "flags": "",
     "note": "A session token that carries the proven second factor."
    },
    {
     "name": "recoveryCodes",
     "type": "string[]",
     "flags": "nullable",
     "note": "The recovery codes in clear, shown this once. Null when the account already had recovery codes."
    }
   ]
  },
  {
   "id": "MfaVerifyRequest",
   "name": "MfaVerifyRequest",
   "group": "signin",
   "kind": "request",
   "note": "Answers a LoginMfaChallenge with an authenticator-app code. The recovery-code call takes recoveryCode (string, required) in place of token.",
   "extends": null,
   "fields": [
    {
     "name": "mfaToken",
     "type": "string",
     "flags": "required",
     "note": "The mfaToken of the challenge."
    },
    {
     "name": "token",
     "type": "string",
     "flags": "required",
     "note": "The current code from the authenticator app. A code is accepted once."
    },
    {
     "name": "rememberDevice",
     "type": "boolean",
     "flags": "",
     "note": "true also registers this browser as a trusted device."
    }
   ]
  },
  {
   "id": "MfaVerifyResult",
   "name": "MfaVerifyResult",
   "group": "signin",
   "kind": "response",
   "note": "The reply to a second factor answered with a code or a recovery code.",
   "extends": null,
   "fields": [
    {
     "name": "auth",
     "type": "boolean",
     "flags": "",
     "note": "Always true."
    },
    {
     "name": "token",
     "type": "string",
     "flags": "",
     "note": "The session token, also set as the session cookie."
    },
    {
     "name": "trustedDeviceToken",
     "type": "string",
     "flags": "nullable",
     "note": "The trusted-device token when rememberDevice was true, otherwise null. When it is set, the gateway also sets it as an httpOnly cookie."
    }
   ]
  },
  {
   "id": "PasskeyRegistrationOptions",
   "name": "PasskeyRegistrationOptions",
   "group": "signin",
   "kind": "response",
   "note": "The WebAuthn creation options to pass to the browser. The server passes no algorithm list and no timeout, so the WebAuthn library the instance uses decides pubKeyCredParams and timeout.",
   "extends": null,
   "fields": [
    {
     "name": "challenge",
     "type": "string",
     "flags": "",
     "note": "base64url. Kept by the server until the registration is verified."
    },
    {
     "name": "rp",
     "type": "object",
     "flags": "",
     "note": "{ name, id }: the instance."
    },
    {
     "name": "user",
     "type": "object",
     "flags": "",
     "note": "{ id, name, displayName }: name and displayName are the account email."
    },
    {
     "name": "pubKeyCredParams",
     "type": "object[]",
     "flags": "",
     "note": "Set by the library."
    },
    {
     "name": "timeout",
     "type": "integer",
     "flags": "",
     "note": "Milliseconds. Set by the library."
    },
    {
     "name": "attestation",
     "type": "string",
     "flags": "",
     "note": "none"
    },
    {
     "name": "excludeCredentials",
     "type": "object[]",
     "flags": "",
     "note": "The passkeys the account already has."
    },
    {
     "name": "authenticatorSelection",
     "type": "object",
     "flags": "",
     "note": "residentKey: preferred, userVerification: required."
    }
   ]
  },
  {
   "id": "PasskeyList",
   "name": "PasskeyList",
   "group": "signin",
   "kind": "response",
   "note": "The signed-in user’s own second factors.",
   "extends": null,
   "fields": [
    {
     "name": "credentials",
     "type": "object[]",
     "flags": "",
     "note": "Oldest first. Each { id: integer, transports: string[], createdAt: string (date-time) }. The public key is not included."
    },
    {
     "name": "totpEnrolled",
     "type": "boolean",
     "flags": "",
     "note": "Whether an authenticator app is enrolled."
    },
    {
     "name": "recoveryCodesRemaining",
     "type": "integer",
     "flags": "",
     "note": "Unused recovery codes."
    }
   ]
  },
  {
   "id": "TrustedDevice",
   "name": "TrustedDevice",
   "group": "signin",
   "kind": "response",
   "note": "One remembered browser. The trusted-device read answers { devices: TrustedDevice[] }, most recently used first, every device in one reply.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "userAgent",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "ipAddress",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "lastUsedAt",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "expiresAt",
     "type": "string (date-time)",
     "flags": "",
     "note": "After this the browser is asked for a second factor again."
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "current",
     "type": "boolean",
     "flags": "",
     "note": "True for the browser the call came from."
    }
   ]
  },
  {
   "id": "FactorResetResult",
   "name": "FactorResetResult",
   "group": "signin",
   "kind": "response",
   "note": "The reply to an administrator resetting another user’s second factors. Revoking only their trusted devices answers { ok, email, revoked: integer }.",
   "extends": null,
   "fields": [
    {
     "name": "ok",
     "type": "boolean",
     "flags": "",
     "note": "Always true."
    },
    {
     "name": "email",
     "type": "string",
     "flags": "",
     "note": "The account that was reset."
    },
    {
     "name": "passkeysRemoved",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "devicesRevoked",
     "type": "integer",
     "flags": "",
     "note": ""
    }
   ]
  },
  {
   "id": "PasswordChangeRequest",
   "name": "PasswordChangeRequest",
   "group": "signin",
   "kind": "request",
   "note": "The account is the one the password-change token names, never one named in the body. The token goes in the Authorization: Bearer header; a call without that header is answered 401.",
   "extends": null,
   "fields": [
    {
     "name": "newPassword",
     "type": "string",
     "flags": "required",
     "note": "Must not be empty, and must meet the password rules that apply to the account."
    }
   ]
  },
  {
   "id": "StepUpRequest",
   "name": "StepUpRequest",
   "group": "signin",
   "kind": "request",
   "note": "The session is the one in the Authorization: Bearer header.",
   "extends": null,
   "fields": [
    {
     "name": "password",
     "type": "string",
     "flags": "required",
     "note": "The password of the signed-in account."
    }
   ]
  },
  {
   "id": "SignInLinkRequest",
   "name": "SignInLinkRequest",
   "group": "signin",
   "kind": "request",
   "note": "Asks for a sign-in link by email.",
   "extends": null,
   "fields": [
    {
     "name": "email",
     "type": "string",
     "flags": "required",
     "note": "At most 255 characters."
    }
   ]
  },
  {
   "id": "MfaSetupStart",
   "name": "MfaSetupStart",
   "group": "signin",
   "kind": "request",
   "note": "Every key is optional.",
   "extends": null,
   "fields": [
    {
     "name": "mfaToken",
     "type": "string",
     "flags": "optional",
     "note": "The token a sign-in answered when it asked for a second factor. Left out, the token in the Authorization: Bearer header is read."
    }
   ]
  },
  {
   "id": "MfaSetupConfirm",
   "name": "MfaSetupConfirm",
   "group": "signin",
   "kind": "request",
   "note": "Confirms an authenticator app with its first code.",
   "extends": null,
   "fields": [
    {
     "name": "token",
     "type": "string",
     "flags": "required",
     "note": "The code the authenticator app shows."
    },
    {
     "name": "mfaToken",
     "type": "string",
     "flags": "optional",
     "note": "The token a sign-in answered when it asked for a second factor. Left out, the token in the Authorization: Bearer header is read."
    }
   ]
  },
  {
   "id": "MfaRecoveryVerify",
   "name": "MfaRecoveryVerify",
   "group": "signin",
   "kind": "request",
   "note": "Completes a sign-in with a recovery code.",
   "extends": null,
   "fields": [
    {
     "name": "mfaToken",
     "type": "string",
     "flags": "required",
     "note": "The token the sign-in answered when it asked for a second factor."
    },
    {
     "name": "recoveryCode",
     "type": "string",
     "flags": "required",
     "note": ""
    },
    {
     "name": "rememberDevice",
     "type": "boolean",
     "flags": "optional",
     "note": ""
    }
   ]
  },
  {
   "id": "SsoStatus",
   "name": "SsoStatus",
   "group": "access",
   "kind": "response",
   "note": "What the sign-in page reads before anyone is signed in. { enabled: false } alone when the identity service cannot be reached.",
   "extends": null,
   "fields": [
    {
     "name": "enabled",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "protocol",
     "type": "string",
     "flags": "",
     "note": "oidc | saml"
    },
    {
     "name": "preset",
     "type": "string",
     "flags": "",
     "note": "okta | entra | google | other. Present only while single sign-on is on."
    },
    {
     "name": "smtpConfigured",
     "type": "boolean",
     "flags": "",
     "note": "Whether the instance can send email, and so offer the emailed sign-in link."
    }
   ]
  },
  {
   "id": "SsoConfig",
   "name": "SsoConfig",
   "group": "access",
   "kind": "response",
   "note": "The single sign-on configuration as an administrator reads it and as a save answers it. The client secret is never returned.",
   "extends": null,
   "fields": [
    {
     "name": "enabled",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "protocol",
     "type": "string",
     "flags": "",
     "note": "oidc | saml. oidc when none is stored."
    },
    {
     "name": "preset",
     "type": "string",
     "flags": "",
     "note": "okta | entra | google | other. other when none is stored."
    },
    {
     "name": "issuer",
     "type": "string",
     "flags": "",
     "note": "Empty string when unset."
    },
    {
     "name": "clientId",
     "type": "string",
     "flags": "",
     "note": "Empty string when unset."
    },
    {
     "name": "clientSecretSet",
     "type": "boolean",
     "flags": "read-only",
     "note": "Whether a client secret is stored."
    },
    {
     "name": "saml",
     "type": "object",
     "flags": "",
     "note": "{ idpEntityId: string, idpSsoUrl: string, certs: SsoCertificate[] }"
    },
    {
     "name": "jit",
     "type": "object",
     "flags": "",
     "note": "{ enabled, allowedDomains, defaultRoleId, defaultGroupId }: creating an account on first sign-in."
    },
    {
     "name": "mfaAcrValues",
     "type": "string[]",
     "flags": "",
     "note": "Empty array when unset."
    }
   ]
  },
  {
   "id": "SsoCertificate",
   "name": "SsoCertificate",
   "group": "access",
   "kind": "response",
   "note": "A summary of one identity-provider signing certificate. The certificate itself is not returned by the configuration read.",
   "extends": null,
   "fields": [
    {
     "name": "subject",
     "type": "string",
     "flags": "",
     "note": "unparseable when the stored certificate cannot be read."
    },
    {
     "name": "notAfter",
     "type": "string",
     "flags": "nullable",
     "note": "The expiry as the certificate states it."
    },
    {
     "name": "fingerprint256",
     "type": "string",
     "flags": "nullable",
     "note": "SHA-256 fingerprint."
    },
    {
     "name": "expired",
     "type": "boolean",
     "flags": "",
     "note": ""
    }
   ]
  },
  {
   "id": "SsoPendingStep",
   "name": "SsoPendingStep",
   "group": "access",
   "kind": "response",
   "note": "The step a single sign-on still owes. Only the fields of that step are present: the fields of a LoginMfaChallenge, or of a LoginTermsChallenge with its termsToken.",
   "extends": null,
   "fields": [
    {
     "name": "mfaRequired",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "mfaStatus",
     "type": "string",
     "flags": "",
     "note": "setup_pending | challenge_pending"
    },
    {
     "name": "userId",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "mfaToken",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "mfaMethods",
     "type": "object",
     "flags": "",
     "note": "{ passkey: boolean, totp: boolean }"
    },
    {
     "name": "termsAcceptanceRequired",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "termsToken",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "termsVersion",
     "type": "string",
     "flags": "",
     "note": ""
    }
   ]
  },
  {
   "id": "ScimUser",
   "name": "ScimUser",
   "group": "access",
   "kind": "response",
   "note": "A SCIM 2.0 User as the instance returns it. A field marked \"when set\" is left out, not sent as null.",
   "extends": null,
   "fields": [
    {
     "name": "schemas",
     "type": "string[]",
     "flags": "",
     "note": "The core User schema and the enterprise User extension."
    },
    {
     "name": "id",
     "type": "string",
     "flags": "read-only",
     "note": "The Zonitix user id, as a string."
    },
    {
     "name": "userName",
     "type": "string",
     "flags": "",
     "note": "The userName the identity provider sent, otherwise the account email."
    },
    {
     "name": "externalId",
     "type": "string",
     "flags": "",
     "note": "When set."
    },
    {
     "name": "name",
     "type": "object",
     "flags": "",
     "note": "{ givenName, familyName, formatted }: empty strings when the account has no name."
    },
    {
     "name": "displayName",
     "type": "string",
     "flags": "",
     "note": "First and last name. Left out when both are empty."
    },
    {
     "name": "emails",
     "type": "object[]",
     "flags": "",
     "note": "One entry: { value, type: \"work\", primary: true }."
    },
    {
     "name": "active",
     "type": "boolean",
     "flags": "",
     "note": "False for an account that is offboarded, disabled, inactive, terminated, archived, deleted or suspended. True for every other status."
    },
    {
     "name": "title",
     "type": "string",
     "flags": "",
     "note": "When set."
    },
    {
     "name": "phoneNumbers",
     "type": "object[]",
     "flags": "",
     "note": "When set: one entry { value, type: \"work\" }."
    },
    {
     "name": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User",
     "type": "object",
     "flags": "",
     "note": "Any of department, employeeNumber and manager: { value }, each when set. An empty object otherwise."
    },
    {
     "name": "meta",
     "type": "object",
     "flags": "read-only",
     "note": "{ resourceType: \"User\", created, lastModified, location }"
    }
   ]
  },
  {
   "id": "ScimUserWrite",
   "name": "ScimUserWrite",
   "group": "access",
   "kind": "request",
   "note": "What the instance reads from a SCIM User on create, replace and patch. Other attributes are accepted and ignored. Only the attributes present are changed.",
   "extends": null,
   "fields": [
    {
     "name": "userName",
     "type": "string",
     "flags": "",
     "note": "At most 320 characters. On create the sign-in email is the value of the chosen emails entry or, when there is none, a userName that is an email address; with neither the call answers 400. It is stored in lower case."
    },
    {
     "name": "externalId",
     "type": "string",
     "flags": "",
     "note": "At most 255 characters. null clears it."
    },
    {
     "name": "emails",
     "type": "object[]",
     "flags": "",
     "note": "The entry marked primary is used; otherwise the entry of type work; otherwise the first entry with a value."
    },
    {
     "name": "name",
     "type": "object",
     "flags": "",
     "note": "{ givenName, familyName }"
    },
    {
     "name": "displayName",
     "type": "string",
     "flags": "",
     "note": "Split into first and last name only when no name object is sent."
    },
    {
     "name": "title",
     "type": "string",
     "flags": "",
     "note": "Stored as the position."
    },
    {
     "name": "active",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "phoneNumbers",
     "type": "object[]",
     "flags": "",
     "note": "The entry marked primary is used; otherwise the entry of type work, then mobile; otherwise the first entry with a value. At most 64 characters."
    },
    {
     "name": "urn:ietf:params:scim:schemas:extension:enterprise:2.0:User",
     "type": "object",
     "flags": "",
     "note": "department, employeeNumber (at most 64 characters) and manager (a user id, or { value })."
    }
   ]
  },
  {
   "id": "ScimGroup",
   "name": "ScimGroup",
   "group": "access",
   "kind": "response",
   "note": "A SCIM 2.0 Group as the instance returns it.",
   "extends": null,
   "fields": [
    {
     "name": "schemas",
     "type": "string[]",
     "flags": "",
     "note": "The core Group schema."
    },
    {
     "name": "id",
     "type": "string",
     "flags": "read-only",
     "note": "The Zonitix group id, as a string."
    },
    {
     "name": "displayName",
     "type": "string",
     "flags": "",
     "note": "The group name."
    },
    {
     "name": "externalId",
     "type": "string",
     "flags": "",
     "note": "Only for a group that SCIM created and that has one."
    },
    {
     "name": "members",
     "type": "object[]",
     "flags": "",
     "note": "Each { value: the user id as a string, display, $ref }. Left out when the request excludes members."
    },
    {
     "name": "meta",
     "type": "object",
     "flags": "read-only",
     "note": "{ resourceType: \"Group\", created, lastModified, location }"
    }
   ]
  },
  {
   "id": "ScimListResponse",
   "name": "ScimListResponse",
   "group": "access",
   "kind": "response",
   "note": "The envelope of the SCIM user and group lists. Paging is by startIndex (1-based, default 1) and count (default 100, at most 200).",
   "extends": null,
   "fields": [
    {
     "name": "schemas",
     "type": "string[]",
     "flags": "",
     "note": "The SCIM ListResponse schema."
    },
    {
     "name": "totalResults",
     "type": "integer",
     "flags": "",
     "note": "Every match, not only this page."
    },
    {
     "name": "startIndex",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "itemsPerPage",
     "type": "integer",
     "flags": "",
     "note": "The number of resources in this reply."
    },
    {
     "name": "Resources",
     "type": "object[]",
     "flags": "",
     "note": "ScimUser or ScimGroup objects."
    }
   ]
  },
  {
   "id": "ScimError",
   "name": "ScimError",
   "group": "access",
   "kind": "response",
   "note": "The body of every refused SCIM call.",
   "extends": null,
   "fields": [
    {
     "name": "schemas",
     "type": "string[]",
     "flags": "",
     "note": "The SCIM Error schema."
    },
    {
     "name": "status",
     "type": "string",
     "flags": "",
     "note": "The HTTP status, as a string."
    },
    {
     "name": "detail",
     "type": "string",
     "flags": "",
     "note": "The reason."
    },
    {
     "name": "scimType",
     "type": "string",
     "flags": "",
     "note": "Present on some refusals, for example invalidFilter, invalidValue, uniqueness or mutability."
    }
   ]
  },
  {
   "id": "ScimToken",
   "name": "ScimToken",
   "group": "access",
   "kind": "response",
   "note": "One SCIM bearer token record. The token value is returned once, as token, by the call that creates it.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "name",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "tokenPrefix",
     "type": "string",
     "flags": "read-only",
     "note": "The first characters of the value, to recognise it by."
    },
    {
     "name": "createdBy",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "lastUsedAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "revokedAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "revokedBy",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "familyId",
     "type": "string",
     "flags": "read-only",
     "note": "The connection the token belongs to. A token made with rotateFromId keeps the family of the one it replaces."
    },
    {
     "name": "active",
     "type": "boolean",
     "flags": "read-only",
     "note": "False once revoked."
    },
    {
     "name": "owns",
     "type": "object",
     "flags": "nullable, read-only",
     "note": "In the token list only: { users: integer, groups: integer } owned by the token’s connection. Null when the counts could not be read."
    }
   ]
  },
  {
   "id": "ScimSettings",
   "name": "ScimSettings",
   "group": "access",
   "kind": "response",
   "note": "The provisioning policy and the groups it can cover. A save answers the policy object alone.",
   "extends": null,
   "fields": [
    {
     "name": "policy",
     "type": "object",
     "flags": "",
     "note": "{ onboardOnCreate: boolean, allowGroupCreate: boolean, managedGroupIds: integer[], privilegedAllowedGroupIds: integer[] }"
    },
    {
     "name": "groups",
     "type": "object[]",
     "flags": "",
     "note": "Each { id, name, source, scimCreated: boolean, eligible: boolean, managed: boolean, privileged: boolean, confers: string[], privilegedAllowed: boolean }."
    }
   ]
  },
  {
   "id": "AccessReviewCampaign",
   "name": "AccessReviewCampaign",
   "group": "access",
   "kind": "response",
   "note": "One access review. The campaign list is a bare array, newest first, every campaign that is not archived in one reply (?archived=1 lists the archived ones instead).",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "name",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "description",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "status",
     "type": "string",
     "flags": "read-only",
     "note": "active | completed | archived"
    },
    {
     "name": "scopeType",
     "type": "string",
     "flags": "",
     "note": "all | department"
    },
    {
     "name": "scopeValue",
     "type": "string",
     "flags": "nullable",
     "note": "The department, for a department scope."
    },
    {
     "name": "createdBy",
     "type": "string",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "dueDate",
     "type": "string (date)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "itemCount",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "decidedCount",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "revokeCount",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "completedAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "scheduleId",
     "type": "integer",
     "flags": "nullable, read-only",
     "note": "The recurring schedule that launched it."
    },
    {
     "name": "lastReminderAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "updatedAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    }
   ]
  },
  {
   "id": "AccessReviewCampaignCreate",
   "name": "AccessReviewCampaignCreate",
   "group": "access",
   "kind": "request",
   "note": "Starts a review and creates its items.",
   "extends": null,
   "fields": [
    {
     "name": "name",
     "type": "string",
     "flags": "required",
     "note": ""
    },
    {
     "name": "description",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "scopeType",
     "type": "string",
     "flags": "",
     "note": "all (default) | department"
    },
    {
     "name": "scopeValue",
     "type": "string",
     "flags": "",
     "note": "The department. Required for a department scope."
    },
    {
     "name": "dueDate",
     "type": "string (date)",
     "flags": "",
     "note": ""
    },
    {
     "name": "externalSource",
     "type": "string",
     "flags": "",
     "note": "none | persona | actual | both. Default persona."
    }
   ]
  },
  {
   "id": "AccessReviewItem",
   "name": "AccessReviewItem",
   "group": "access",
   "kind": "response",
   "note": "One piece of access to decide, as the campaign read and the reviewer queue return it.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "campaignId",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "userId",
     "type": "integer",
     "flags": "read-only",
     "note": "The person whose access it is."
    },
    {
     "name": "userEmail",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "reviewerEmail",
     "type": "string",
     "flags": "",
     "note": "Who is asked to decide."
    },
    {
     "name": "accessType",
     "type": "string",
     "flags": "read-only",
     "note": "role | group | app | license"
    },
    {
     "name": "accessId",
     "type": "integer",
     "flags": "read-only",
     "note": "The role or group id. 0 for an application or licence."
    },
    {
     "name": "accessLabel",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "providerType",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "runId",
     "type": "integer",
     "flags": "nullable, read-only",
     "note": "The workflow run that carries out a revoke."
    },
    {
     "name": "drift",
     "type": "boolean",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "decision",
     "type": "string",
     "flags": "",
     "note": "pending | keep | revoke"
    },
    {
     "name": "decidedBy",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "decidedAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "note",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "revoked",
     "type": "boolean",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "reroutedFrom",
     "type": "string",
     "flags": "nullable, read-only",
     "note": "The reviewer the item was moved away from."
    },
    {
     "name": "reroutedAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "rerouteReason",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "updatedAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "campaignName",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "campaignDueDate",
     "type": "string (date)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "classification",
     "type": "string",
     "flags": "read-only",
     "note": "directory (a role or a group) | blueprint (an application the person’s persona expects) | drift (an application outside it)."
    },
    {
     "name": "permissionCount",
     "type": "integer",
     "flags": "nullable, read-only",
     "note": "How many permissions the role or group carries. Null for an application or licence."
    },
    {
     "name": "privileged",
     "type": "boolean",
     "flags": "read-only",
     "note": "True for the administrator role, for a role or group carrying any manage_ permission, and for one carrying 15 or more permissions."
    }
   ]
  },
  {
   "id": "AccessReviewCampaignDetail",
   "name": "AccessReviewCampaignDetail",
   "group": "access",
   "kind": "response",
   "note": "One campaign with a page of its items, ordered by person then access type. Paged by ?limit (default 200, 1 to 500) and ?offset.",
   "extends": null,
   "fields": [
    {
     "name": "campaign",
     "type": "AccessReviewCampaign",
     "flags": "",
     "note": ""
    },
    {
     "name": "items",
     "type": "AccessReviewItem[]",
     "flags": "",
     "note": ""
    },
    {
     "name": "total",
     "type": "integer",
     "flags": "",
     "note": "Every item of the campaign."
    },
    {
     "name": "limit",
     "type": "integer",
     "flags": "",
     "note": "The limit applied."
    },
    {
     "name": "offset",
     "type": "integer",
     "flags": "",
     "note": ""
    }
   ]
  },
  {
   "id": "AccessReviewQueue",
   "name": "AccessReviewQueue",
   "group": "access",
   "kind": "response",
   "note": "The pending items assigned to the caller in active campaigns. Paged by ?limit (default 50, 1 to 200) and ?offset.",
   "extends": null,
   "fields": [
    {
     "name": "items",
     "type": "AccessReviewItem[]",
     "flags": "",
     "note": ""
    },
    {
     "name": "total",
     "type": "integer",
     "flags": "",
     "note": "Every item matching the filter."
    },
    {
     "name": "limit",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "offset",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "filter",
     "type": "string",
     "flags": "",
     "note": "The filter applied. all when the one sent is not recognised."
    },
    {
     "name": "stats",
     "type": "object",
     "flags": "",
     "note": "{ all, attention, expected, privileged, drift, people }, each an integer."
    }
   ]
  },
  {
   "id": "AccessGrantDetail",
   "name": "AccessGrantDetail",
   "group": "access",
   "kind": "response",
   "note": "The permissions behind one role or group. For any other access type only accessType, accessId, external: true, permissions: null, permissionCount: null and privileged: false are sent.",
   "extends": null,
   "fields": [
    {
     "name": "accessType",
     "type": "string",
     "flags": "nullable",
     "note": "role | group, or the value sent for any other type. Null when none was sent."
    },
    {
     "name": "accessId",
     "type": "integer",
     "flags": "nullable",
     "note": "Null when none was sent."
    },
    {
     "name": "accessLabel",
     "type": "string",
     "flags": "nullable",
     "note": "The role or group name."
    },
    {
     "name": "external",
     "type": "boolean",
     "flags": "",
     "note": "False for a role or group."
    },
    {
     "name": "permissions",
     "type": "string[]",
     "flags": "nullable",
     "note": "Permission names, sorted."
    },
    {
     "name": "permissionCount",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "privileged",
     "type": "boolean",
     "flags": "",
     "note": ""
    }
   ]
  },
  {
   "id": "AccessReviewSchedule",
   "name": "AccessReviewSchedule",
   "group": "access",
   "kind": "response",
   "note": "A recurring review. The schedule list is a bare array, newest first, every schedule in one reply.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "name",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "scopeType",
     "type": "string",
     "flags": "",
     "note": "all | department"
    },
    {
     "name": "scopeValue",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "externalSource",
     "type": "string",
     "flags": "",
     "note": "none | persona | actual | both. Default both."
    },
    {
     "name": "cadence",
     "type": "string",
     "flags": "",
     "note": "monthly | quarterly (default) | semiannual | annual | custom"
    },
    {
     "name": "intervalMonths",
     "type": "integer",
     "flags": "nullable",
     "note": "For a custom cadence."
    },
    {
     "name": "dueDays",
     "type": "integer",
     "flags": "",
     "note": "Default 14."
    },
    {
     "name": "status",
     "type": "string",
     "flags": "",
     "note": "active | paused"
    },
    {
     "name": "remind",
     "type": "boolean",
     "flags": "",
     "note": "Default true."
    },
    {
     "name": "autoClose",
     "type": "boolean",
     "flags": "",
     "note": "Default true."
    },
    {
     "name": "skipIfOpen",
     "type": "boolean",
     "flags": "",
     "note": "Default false."
    },
    {
     "name": "nextRunAt",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "lastRunAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "createdBy",
     "type": "string",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "updatedAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    }
   ]
  },
  {
   "id": "LiveAccessSnapshot",
   "name": "LiveAccessSnapshot",
   "group": "access",
   "kind": "response",
   "note": "Current access across the scope, computed on each call. Not paged: at most 2000 items are returned and capped says when the list was cut.",
   "extends": null,
   "fields": [
    {
     "name": "generatedAt",
     "type": "string (date-time)",
     "flags": "",
     "note": ""
    },
    {
     "name": "scopeType",
     "type": "string",
     "flags": "",
     "note": "all | department"
    },
    {
     "name": "scopeValue",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "externalSource",
     "type": "string",
     "flags": "",
     "note": "none | persona | actual | both"
    },
    {
     "name": "userCount",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "total",
     "type": "integer",
     "flags": "",
     "note": "Items before the cut."
    },
    {
     "name": "driftCount",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "privilegedCount",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "providersActive",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "externalScanned",
     "type": "boolean",
     "flags": "",
     "note": "True when actual usage was read (externalSource actual or both)."
    },
    {
     "name": "capped",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "items",
     "type": "object[]",
     "flags": "",
     "note": "Each { userId, userEmail, department, accessType, accessId, accessLabel, providerType, drift, classification, permissionCount, privileged, liveKey }."
    }
   ]
  },
  {
   "id": "AccessRequest",
   "name": "AccessRequest",
   "group": "access",
   "kind": "response",
   "note": "One request for an application or licence. Both request lists are bare arrays, newest first, every matching request in one reply; a list entry can carry further fields describing the person.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "userId",
     "type": "integer",
     "flags": "",
     "note": "The person the access is for."
    },
    {
     "name": "requestedBy",
     "type": "string",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "itemKind",
     "type": "string",
     "flags": "",
     "note": "Default app."
    },
    {
     "name": "itemLabel",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "providerType",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "justification",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "customFields",
     "type": "object",
     "flags": "nullable",
     "note": "Values keyed by custom-field key."
    },
    {
     "name": "status",
     "type": "string",
     "flags": "read-only",
     "note": "pending | approved | fulfilled | declined | rejected | failed"
    },
    {
     "name": "approverEmail",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "decisionComment",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "decidedAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "runId",
     "type": "integer",
     "flags": "nullable, read-only",
     "note": "The workflow run that provisions an approved request."
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "updatedAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    }
   ]
  },
  {
   "id": "LogForwardDestination",
   "name": "LogForwardDestination",
   "group": "access",
   "kind": "response",
   "note": "One log-forwarding destination. The secret is never returned. The destination read answers { destinations, types, datadogSites, health }, every destination in one reply, id ascending.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "name",
     "type": "string",
     "flags": "",
     "note": "At most 80 characters."
    },
    {
     "name": "type",
     "type": "string",
     "flags": "",
     "note": "webhook | splunk_hec | datadog | syslog_tls. Cannot be changed after creation."
    },
    {
     "name": "typeLabel",
     "type": "string",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "enabled",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "url",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "port",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "config",
     "type": "object",
     "flags": "",
     "note": "Type-specific settings. Empty object when none."
    },
    {
     "name": "secretSet",
     "type": "boolean",
     "flags": "read-only",
     "note": "Whether a secret is stored."
    },
    {
     "name": "cursorSeq",
     "type": "integer",
     "flags": "nullable, read-only",
     "note": "The sequence number of the last audit event the destination acknowledged."
    },
    {
     "name": "lastAckAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "lastAttemptAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "lastError",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "lastErrorAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "consecutiveFailures",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "nextAttemptAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "totalSent",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "skippedCount",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "lastSkip",
     "type": "object",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "gapCount",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "lagEvents",
     "type": "integer",
     "flags": "nullable, read-only",
     "note": "In the list only: audit events not yet acknowledged."
    },
    {
     "name": "lagSeconds",
     "type": "integer",
     "flags": "nullable, read-only",
     "note": "In the list only."
    },
    {
     "name": "createdBy",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "updatedBy",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "updatedAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    }
   ]
  },
  {
   "id": "LogForwardingHealth",
   "name": "LogForwardingHealth",
   "group": "access",
   "kind": "response",
   "note": "The health block of the destination read. { error } alone when the state cannot be read.",
   "extends": null,
   "fields": [
    {
     "name": "configured",
     "type": "integer",
     "flags": "",
     "note": "Destinations that exist."
    },
    {
     "name": "enabled",
     "type": "integer",
     "flags": "",
     "note": "Destinations switched on."
    },
    {
     "name": "failing",
     "type": "integer",
     "flags": "",
     "note": "Enabled destinations whose last attempt failed."
    },
    {
     "name": "stale",
     "type": "integer",
     "flags": "",
     "note": "Enabled destinations with a backlog and no acknowledgement for 15 minutes."
    },
    {
     "name": "maxLagEvents",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "maxLagSeconds",
     "type": "integer",
     "flags": "",
     "note": ""
    },
    {
     "name": "degraded",
     "type": "boolean",
     "flags": "",
     "note": "True when any destination is failing or stale."
    },
    {
     "name": "lastTick",
     "type": "object",
     "flags": "nullable",
     "note": "{ at: string (date-time), status: worked | idle | failed, summary }: the last delivery sweep. Null before the first one."
    },
    {
     "name": "destinations",
     "type": "object[]",
     "flags": "",
     "note": "Each { id, type, enabled, lagEvents, lagSeconds, consecutiveFailures, lastAckAt, skipped, gaps, stale }."
    }
   ]
  },
  {
   "id": "SupportAccessStatus",
   "name": "SupportAccessStatus",
   "group": "access",
   "kind": "response",
   "note": "The state of vendor support access. No credential is ever part of it.",
   "extends": null,
   "fields": [
    {
     "name": "active",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "grant",
     "type": "SupportAccessGrant",
     "flags": "nullable",
     "note": "The grant in force. Null when none is active."
    },
    {
     "name": "account",
     "type": "object",
     "flags": "nullable",
     "note": "{ email, status, mfaEnrolled: boolean, invitePending: boolean, lastActiveAt }. Null when no support account exists."
    },
    {
     "name": "history",
     "type": "object[]",
     "flags": "",
     "note": "Up to 20 ended grants, newest first: each a SupportAccessGrant plus endedAt, endReason (revoked | expired) and endedBy."
    },
    {
     "name": "limits",
     "type": "object",
     "flags": "",
     "note": "{ minHours: 1, maxHours: 72 }"
    },
    {
     "name": "readOnlyPermissions",
     "type": "string[]",
     "flags": "",
     "note": "The permissions a read_only grant carries."
    },
    {
     "name": "supportMailbox",
     "type": "string",
     "flags": "",
     "note": "The address invitations are suggested for. Empty string when none is saved."
    }
   ]
  },
  {
   "id": "SupportAccessGrant",
   "name": "SupportAccessGrant",
   "group": "access",
   "kind": "response",
   "note": "One grant of support access. Granting answers { grant, invite }, where invite is { sent: boolean, expiresAt?, reason?, link? }.",
   "extends": null,
   "fields": [
    {
     "name": "grantId",
     "type": "string",
     "flags": "read-only",
     "note": "A UUID."
    },
    {
     "name": "scope",
     "type": "string",
     "flags": "",
     "note": "read_only | role"
    },
    {
     "name": "roleName",
     "type": "string",
     "flags": "nullable",
     "note": "The role granted. Null for read_only."
    },
    {
     "name": "grantedBy",
     "type": "string",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "grantedAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "expiresAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "hours",
     "type": "integer",
     "flags": "",
     "note": "1 to 72."
    },
    {
     "name": "reason",
     "type": "string",
     "flags": "",
     "note": "5 to 500 characters."
    },
    {
     "name": "email",
     "type": "string",
     "flags": "",
     "note": "The support address the invitation went to."
    },
    {
     "name": "administratorAcknowledged",
     "type": "boolean",
     "flags": "read-only",
     "note": "True when the role granted is administrator."
    }
   ]
  },
  {
   "id": "ApiTokenAdminRevoke",
   "name": "ApiTokenAdminRevoke",
   "group": "access",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "reason",
     "type": "string",
     "flags": "optional",
     "note": "Cut to 200 characters and stored as revokeReason. Without it the record says \"Revoked by an administrator\"."
    }
   ]
  },
  {
   "id": "ScimTokenIssue",
   "name": "ScimTokenIssue",
   "group": "access",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "name",
     "type": "string",
     "flags": "required",
     "note": ""
    },
    {
     "name": "rotateFromId",
     "type": "integer",
     "flags": "optional",
     "note": "The id of the token this one replaces, so it keeps that connection’s users and groups. 404 when no token has that id."
    }
   ]
  },
  {
   "id": "ScimTokenRebind",
   "name": "ScimTokenRebind",
   "group": "access",
   "kind": "request",
   "note": "Send one of the two keys.",
   "extends": null,
   "fields": [
    {
     "name": "fromTokenId",
     "type": "integer",
     "flags": "optional",
     "note": "Move the users and groups of this token’s connection to the token in the path."
    },
    {
     "name": "unowned",
     "type": "boolean",
     "flags": "optional",
     "note": "True moves the users and groups that belong to no connection."
    }
   ]
  },
  {
   "id": "AccessReviewDecision",
   "name": "AccessReviewDecision",
   "group": "access",
   "kind": "request",
   "note": "The bulk call takes the same keys plus ids (1 to 500 item ids).",
   "extends": null,
   "fields": [
    {
     "name": "decision",
     "type": "string: keep | revoke",
     "flags": "required",
     "note": ""
    },
    {
     "name": "note",
     "type": "string",
     "flags": "optional",
     "note": ""
    }
   ]
  },
  {
   "id": "AccessReviewReassign",
   "name": "AccessReviewReassign",
   "group": "access",
   "kind": "request",
   "note": "The bulk call takes the same key plus ids (1 to 500 item ids).",
   "extends": null,
   "fields": [
    {
     "name": "reviewerEmail",
     "type": "string",
     "flags": "required",
     "note": "An active user, and not the person whose access the item is."
    }
   ]
  },
  {
   "id": "AccessReviewScheduleWrite",
   "name": "AccessReviewScheduleWrite",
   "group": "access",
   "kind": "request",
   "note": "The body of the schedule create call. The update call reads the same keys, all optional.",
   "extends": null,
   "fields": [
    {
     "name": "name",
     "type": "string",
     "flags": "required",
     "note": ""
    },
    {
     "name": "cadence",
     "type": "string: monthly | quarterly | semiannual | annual | custom",
     "flags": "optional",
     "note": "Default quarterly; an unknown value is read as quarterly."
    },
    {
     "name": "intervalMonths",
     "type": "integer",
     "flags": "optional",
     "note": "Custom cadence only. At least 1."
    },
    {
     "name": "scopeType",
     "type": "string: all | department",
     "flags": "optional",
     "note": "Default all."
    },
    {
     "name": "scopeValue",
     "type": "string",
     "flags": "optional",
     "note": "The department, for a department scope."
    },
    {
     "name": "externalSource",
     "type": "string: none | persona | actual | both",
     "flags": "optional",
     "note": "Default both."
    },
    {
     "name": "dueDays",
     "type": "integer",
     "flags": "optional",
     "note": "Held to 1 to 365. Default 14."
    },
    {
     "name": "remind",
     "type": "boolean",
     "flags": "optional",
     "note": "Default true."
    },
    {
     "name": "autoClose",
     "type": "boolean",
     "flags": "optional",
     "note": "Default true."
    },
    {
     "name": "skipIfOpen",
     "type": "boolean",
     "flags": "optional",
     "note": "Default false."
    },
    {
     "name": "firstRunAt",
     "type": "string (date-time)",
     "flags": "optional",
     "note": "Default now."
    }
   ]
  },
  {
   "id": "LiveAccessRevoke",
   "name": "LiveAccessRevoke",
   "group": "access",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "userId",
     "type": "integer",
     "flags": "required",
     "note": ""
    },
    {
     "name": "accessType",
     "type": "string",
     "flags": "required",
     "note": ""
    },
    {
     "name": "accessLabel",
     "type": "string",
     "flags": "required",
     "note": ""
    },
    {
     "name": "accessId",
     "type": "integer",
     "flags": "optional",
     "note": "Required to revoke a role or a group."
    },
    {
     "name": "providerType",
     "type": "string",
     "flags": "optional",
     "note": ""
    }
   ]
  },
  {
   "id": "AccessRequestCreate",
   "name": "AccessRequestCreate",
   "group": "access",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "itemLabel",
     "type": "string",
     "flags": "required",
     "note": "What is being asked for."
    },
    {
     "name": "itemKind",
     "type": "string",
     "flags": "optional",
     "note": "Default app."
    },
    {
     "name": "providerType",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "justification",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "customFields",
     "type": "object",
     "flags": "optional",
     "note": "Values keyed by custom field key. Only keys in the custom field list are kept, 500 characters each."
    },
    {
     "name": "userId",
     "type": "integer",
     "flags": "optional",
     "note": "Who the request is for. Honoured only for an approver or a holder of manage_users; otherwise the request is for the caller."
    }
   ]
  },
  {
   "id": "AccessRequestDecision",
   "name": "AccessRequestDecision",
   "group": "access",
   "kind": "request",
   "note": "The bulk call takes the same keys plus ids (1 to 500 request ids).",
   "extends": null,
   "fields": [
    {
     "name": "approve",
     "type": "boolean",
     "flags": "optional",
     "note": "True approves. Anything else, or leaving it out, rejects."
    },
    {
     "name": "comment",
     "type": "string",
     "flags": "optional",
     "note": ""
    }
   ]
  },
  {
   "id": "AccessRequestFieldsWrite",
   "name": "AccessRequestFieldsWrite",
   "group": "access",
   "kind": "request",
   "note": "Replaces the whole list of custom fields on the request form.",
   "extends": null,
   "fields": [
    {
     "name": "fields",
     "type": "object[]",
     "flags": "optional",
     "note": "Left out, or not an array, the list is emptied (200, not 400). At most 20 are kept. Each { key, label (cut to 60 characters), type: text | select | number | date, options (select only: up to 50) }. A malformed entry is skipped, not refused."
    }
   ]
  },
  {
   "id": "DepartmentCreate",
   "name": "DepartmentCreate",
   "group": "access",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "name",
     "type": "string",
     "flags": "required",
     "note": "At most 100 characters."
    }
   ]
  },
  {
   "id": "UserOffboard",
   "name": "UserOffboard",
   "group": "access",
   "kind": "request",
   "note": "All keys are optional.",
   "extends": null,
   "fields": [
    {
     "name": "lastDay",
     "type": "string",
     "flags": "optional",
     "note": "The person’s last day."
    },
    {
     "name": "immediate",
     "type": "boolean",
     "flags": "optional",
     "note": "True runs the revoke steps at once, and then reason is required."
    },
    {
     "name": "reason",
     "type": "string",
     "flags": "optional",
     "note": "At least 5 characters when immediate is true."
    }
   ]
  },
  {
   "id": "UserOnboard",
   "name": "UserOnboard",
   "group": "access",
   "kind": "request",
   "note": "All keys are optional.",
   "extends": null,
   "fields": [
    {
     "name": "personaId",
     "type": "integer",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "startDate",
     "type": "string",
     "flags": "optional",
     "note": ""
    },
    {
     "name": "managerId",
     "type": "integer",
     "flags": "optional",
     "note": ""
    }
   ]
  },
  {
   "id": "DottedReportsUpdate",
   "name": "DottedReportsUpdate",
   "group": "access",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "managerIds",
     "type": "integer[]",
     "flags": "optional",
     "note": "Replaces the whole set. Repeats and the person’s own id are dropped; at most 10; each must be an active user. Left out, or not an array, the set is emptied."
    }
   ]
  },
  {
   "id": "RolePermissionsReplace",
   "name": "RolePermissionsReplace",
   "group": "access",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "permissionIds",
     "type": "integer[]",
     "flags": "required",
     "note": "Replaces the role’s permission set."
    }
   ]
  },
  {
   "id": "OrgSnapshotCreate",
   "name": "OrgSnapshotCreate",
   "group": "access",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "name",
     "type": "string",
     "flags": "required",
     "note": ""
    }
   ]
  },
  {
   "id": "PasswordExpiryBatch",
   "name": "PasswordExpiryBatch",
   "group": "access",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "userIds",
     "type": "integer[]",
     "flags": "required",
     "note": "1 to 500 user ids. Repeated ids are counted once."
    }
   ]
  },
  {
   "id": "SupportAccessGrantRequest",
   "name": "SupportAccessGrantRequest",
   "group": "access",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "hours",
     "type": "integer",
     "flags": "required",
     "note": "A whole number from 1 to 72."
    },
    {
     "name": "email",
     "type": "string (email)",
     "flags": "required",
     "note": "The support address the invitation goes to. At most 254 characters."
    },
    {
     "name": "reason",
     "type": "string",
     "flags": "required",
     "note": "5 to 500 characters after trimming. Recorded in the audit log."
    },
    {
     "name": "scope",
     "type": "string: read_only | role",
     "flags": "optional",
     "note": "Default read_only."
    },
    {
     "name": "roleName",
     "type": "string",
     "flags": "optional",
     "note": "Required for scope role."
    },
    {
     "name": "acknowledgeAdministrator",
     "type": "boolean",
     "flags": "optional",
     "note": "Must be true when roleName is administrator."
    }
   ]
  },
  {
   "id": "SupportMailboxUpdate",
   "name": "SupportMailboxUpdate",
   "group": "access",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "email",
     "type": "string",
     "flags": "optional",
     "note": "A valid address of at most 254 characters. Empty, null or left out clears the stored address."
    }
   ]
  },
  {
   "id": "PasswordPolicy",
   "name": "PasswordPolicy",
   "group": "platform",
   "kind": "response",
   "note": "One password policy, as an entry of passwordPolicies in the security settings. The same shape is sent back on a save, where the array replaces the stored one.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "string",
     "flags": "",
     "note": "default is the organisation-wide policy. Any other id is a policy scoped to groups."
    },
    {
     "name": "name",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "minLength",
     "type": "integer",
     "flags": "",
     "note": "8 to 128."
    },
    {
     "name": "requireUppercase",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "requireNumbers",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "requireSymbols",
     "type": "boolean",
     "flags": "",
     "note": ""
    },
    {
     "name": "groupIds",
     "type": "integer[]",
     "flags": "",
     "note": "The groups it applies to. Empty on default."
    }
   ]
  },
  {
   "id": "SecuritySettings",
   "name": "SecuritySettings",
   "group": "platform",
   "kind": "response",
   "note": "The security settings read. The eight fields marked \"restricted\" are sent only to a caller with manage_security or the administrator role. The SMTP password is never returned. A reply carries further fields beyond these.",
   "extends": null,
   "fields": [
    {
     "name": "mfaEnabled",
     "type": "boolean",
     "flags": "",
     "note": "The organisation-wide default that groups set to inherit follow."
    },
    {
     "name": "passwordPolicies",
     "type": "PasswordPolicy[]",
     "flags": "",
     "note": ""
    },
    {
     "name": "passwordHistoryCount",
     "type": "integer",
     "flags": "",
     "note": "0 to 24."
    },
    {
     "name": "namingConvention",
     "type": "string",
     "flags": "",
     "note": "[first_name].[last_name] when none is stored."
    },
    {
     "name": "emailDomain",
     "type": "string",
     "flags": "",
     "note": "Empty string when unset."
    },
    {
     "name": "conflictStrategy",
     "type": "string",
     "flags": "",
     "note": "numeric when none is stored."
    },
    {
     "name": "sessionTimeout",
     "type": "string",
     "flags": "",
     "note": "A duration such as 1h, which is the value when none is stored. 5m to 24h."
    },
    {
     "name": "auditLogRetention",
     "type": "object",
     "flags": "",
     "note": "{ days, source, minDays, maxDays }: the retention in force and its limits."
    },
    {
     "name": "auditLogRetentionDays",
     "type": "integer",
     "flags": "",
     "note": "90 to 3650."
    },
    {
     "name": "workflowRequireSeparateApprover",
     "type": "boolean",
     "flags": "",
     "note": "True unless switched off."
    },
    {
     "name": "workflowRequireSeparateRunApprover",
     "type": "boolean",
     "flags": "",
     "note": "True unless switched off."
    },
    {
     "name": "reportRecipientPolicy",
     "type": "string",
     "flags": "",
     "note": "open when none is stored."
    },
    {
     "name": "reportRecipientDomains",
     "type": "string",
     "flags": "",
     "note": "Empty string when unset."
    },
    {
     "name": "awsMappingEnabled",
     "type": "boolean",
     "flags": "",
     "note": "A module switch. False when never set."
    },
    {
     "name": "azureMappingEnabled",
     "type": "boolean",
     "flags": "",
     "note": "A module switch."
    },
    {
     "name": "gcpMappingEnabled",
     "type": "boolean",
     "flags": "",
     "note": "A module switch."
    },
    {
     "name": "securityLabEnabled",
     "type": "boolean",
     "flags": "",
     "note": "A module switch."
    },
    {
     "name": "complianceEnabled",
     "type": "boolean",
     "flags": "",
     "note": "A module switch."
    },
    {
     "name": "reportingEnabled",
     "type": "boolean",
     "flags": "",
     "note": "A module switch."
    },
    {
     "name": "financeEnabled",
     "type": "boolean",
     "flags": "",
     "note": "A module switch."
    },
    {
     "name": "activeAiModel",
     "type": "string",
     "flags": "",
     "note": "The model the local AI uses. Always one on the allow-list."
    },
    {
     "name": "aiModelRouting",
     "type": "object",
     "flags": "",
     "note": "Empty object when unset."
    },
    {
     "name": "aiEnabled",
     "type": "boolean",
     "flags": "",
     "note": "True unless switched off."
    },
    {
     "name": "aiRequireUsePermission",
     "type": "boolean",
     "flags": "",
     "note": "False unless switched on."
    },
    {
     "name": "smtpConfigured",
     "type": "boolean",
     "flags": "",
     "note": "True when a mail host, user and password are all stored."
    },
    {
     "name": "branding",
     "type": "object",
     "flags": "",
     "note": "{ companyName: string, logoUrl: string or null, primaryBrandColor: string }"
    },
    {
     "name": "itNotificationRecipients",
     "type": "string",
     "flags": "",
     "note": "Restricted."
    },
    {
     "name": "hrNotificationRecipients",
     "type": "string",
     "flags": "",
     "note": "Restricted."
    },
    {
     "name": "smtpHost",
     "type": "string",
     "flags": "",
     "note": "Restricted."
    },
    {
     "name": "smtpPort",
     "type": "integer",
     "flags": "",
     "note": "Restricted."
    },
    {
     "name": "smtpUser",
     "type": "string",
     "flags": "",
     "note": "Restricted."
    },
    {
     "name": "smtpFrom",
     "type": "string",
     "flags": "",
     "note": "Restricted."
    },
    {
     "name": "smtpSecure",
     "type": "boolean",
     "flags": "",
     "note": "Restricted."
    },
    {
     "name": "smtpPassSet",
     "type": "boolean",
     "flags": "",
     "note": "Restricted. Whether a password is stored."
    }
   ]
  },
  {
   "id": "AccessControlRules",
   "name": "AccessControlRules",
   "group": "platform",
   "kind": "response",
   "note": "The network access rules: what is stored, laid over the defaults. A reply carries further fields beyond these.",
   "extends": null,
   "fields": [
    {
     "name": "enabled",
     "type": "boolean",
     "flags": "",
     "note": "Default false."
    },
    {
     "name": "mode",
     "type": "string",
     "flags": "",
     "note": "allowlist | blocklist. Default blocklist."
    },
    {
     "name": "countries",
     "type": "string[]",
     "flags": "",
     "note": "Default empty."
    },
    {
     "name": "blockedIps",
     "type": "string[]",
     "flags": "",
     "note": "Default empty."
    },
    {
     "name": "allowedIps",
     "type": "string[]",
     "flags": "",
     "note": "Default empty."
    },
    {
     "name": "blockUnknownGeo",
     "type": "boolean",
     "flags": "",
     "note": "Default false."
    },
    {
     "name": "dryRun",
     "type": "boolean",
     "flags": "",
     "note": "Default false."
    },
    {
     "name": "autoBlock",
     "type": "object",
     "flags": "",
     "note": "{ enabled (default false), dryRun (default true), threshold (default 6), windowSec (default 600), banSec (default 21600), patterns, tuning }"
    },
    {
     "name": "geoDbLoaded",
     "type": "boolean",
     "flags": "read-only",
     "note": "Added by the gateway: whether its offline country database loaded. Country rules do nothing when false."
    }
   ]
  },
  {
   "id": "HookProvider",
   "name": "HookProvider",
   "group": "platform",
   "kind": "response",
   "note": "One identity hook. Secret values are removed from config and their names listed in configuredCredentials. The hook list is a bare array, every hook in one reply. A reply carries further fields beyond these.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "name",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "description",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "type",
     "type": "string",
     "flags": "",
     "note": "The kind of system, for example OKTA or AWS."
    },
    {
     "name": "config",
     "type": "object",
     "flags": "",
     "note": "The connection settings, without secret values."
    },
    {
     "name": "configuredCredentials",
     "type": "string[]",
     "flags": "read-only",
     "note": "The names of the secrets that are set."
    },
    {
     "name": "isActive",
     "type": "boolean",
     "flags": "",
     "note": "Default true."
    },
    {
     "name": "canReadUsers",
     "type": "boolean",
     "flags": "",
     "note": "Default false."
    },
    {
     "name": "canReadGroups",
     "type": "boolean",
     "flags": "",
     "note": "Default false."
    },
    {
     "name": "canWriteUsers",
     "type": "boolean",
     "flags": "",
     "note": "Default false."
    },
    {
     "name": "canWriteGroups",
     "type": "boolean",
     "flags": "",
     "note": "Default false."
    },
    {
     "name": "canReadAllResources",
     "type": "boolean",
     "flags": "",
     "note": "Default false."
    },
    {
     "name": "capabilitiesSetAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "isVerified",
     "type": "boolean",
     "flags": "read-only",
     "note": "Set by the validation call."
    },
    {
     "name": "lastSync",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "lastValidation",
     "type": "object",
     "flags": "nullable, read-only",
     "note": "The result of the last validation."
    },
    {
     "name": "lastValidatedAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "syncCount",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "syncInterval",
     "type": "integer",
     "flags": "",
     "note": "Default 24."
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "updatedAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    }
   ]
  },
  {
   "id": "ProviderSyncLog",
   "name": "ProviderSyncLog",
   "group": "platform",
   "kind": "response",
   "note": "One sync of one hook. The log read is a bare array of the 10 most recent, newest first.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "providerId",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "syncType",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "status",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "summary",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "details",
     "type": "object[]",
     "flags": "",
     "note": "Empty array when none."
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "updatedAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    }
   ]
  },
  {
   "id": "IdentityAlignmentLog",
   "name": "IdentityAlignmentLog",
   "group": "platform",
   "kind": "response",
   "note": "One alignment action, as an entry of the alignment history. The route adds readable source labels to each entry.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "string",
     "flags": "read-only",
     "note": "A UUID. This is the id the rollback call takes."
    },
    {
     "name": "actionType",
     "type": "string",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "targetEmails",
     "type": "string[]",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "sourceOfTruth",
     "type": "string",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "priorState",
     "type": "object",
     "flags": "read-only",
     "note": "The values before the change."
    },
    {
     "name": "newState",
     "type": "object",
     "flags": "read-only",
     "note": "The values after it."
    },
    {
     "name": "status",
     "type": "string",
     "flags": "read-only",
     "note": "SUCCESS when written without another value."
    },
    {
     "name": "adminUser",
     "type": "string",
     "flags": "read-only",
     "note": "Who ran it."
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "updatedAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    }
   ]
  },
  {
   "id": "AlignmentChangeRequest",
   "name": "AlignmentChangeRequest",
   "group": "platform",
   "kind": "response",
   "note": "A bulk alignment waiting for, or past, approval. The route adds readable source labels to each entry.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "status",
     "type": "string",
     "flags": "read-only",
     "note": "pending when created."
    },
    {
     "name": "requestedBy",
     "type": "string",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "reason",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "sourceOfTruth",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "targets",
     "type": "string[]",
     "flags": "",
     "note": ""
    },
    {
     "name": "emails",
     "type": "string[]",
     "flags": "",
     "note": ""
    },
    {
     "name": "userCount",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "approverEmail",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "decisionComment",
     "type": "string",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "decidedAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "appliedAt",
     "type": "string (date-time)",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "result",
     "type": "object",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "error",
     "type": "string",
     "flags": "nullable, read-only",
     "note": ""
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "updatedAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    }
   ]
  },
  {
   "id": "CloudResourceMap",
   "name": "CloudResourceMap",
   "group": "platform",
   "kind": "response",
   "note": "The stored resource map of one cloud hook. The same shape serves the three clouds.",
   "extends": null,
   "fields": [
    {
     "name": "providerId",
     "type": "integer",
     "flags": "",
     "note": "The hook the map belongs to."
    },
    {
     "name": "nodes",
     "type": "object[]",
     "flags": "",
     "note": "The resources found."
    },
    {
     "name": "edges",
     "type": "object[]",
     "flags": "",
     "note": "The relations between them."
    },
    {
     "name": "billingData",
     "type": "object",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "lastUpdated",
     "type": "string (date-time)",
     "flags": "",
     "note": "When the map was last discovered."
    }
   ]
  },
  {
   "id": "CloudSnapshot",
   "name": "CloudSnapshot",
   "group": "platform",
   "kind": "response",
   "note": "One dated copy of a resource map. The snapshot list sends id, snapshotDate and metadata only; the detail read sends every field.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "string",
     "flags": "read-only",
     "note": "A UUID."
    },
    {
     "name": "providerId",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "snapshotDate",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "metadata",
     "type": "object",
     "flags": "",
     "note": "Empty object when none."
    },
    {
     "name": "nodes",
     "type": "object[]",
     "flags": "",
     "note": "Detail read only."
    },
    {
     "name": "edges",
     "type": "object[]",
     "flags": "",
     "note": "Detail read only."
    },
    {
     "name": "billingData",
     "type": "object",
     "flags": "nullable",
     "note": "Detail read only."
    }
   ]
  },
  {
   "id": "AiModelStatus",
   "name": "AiModelStatus",
   "group": "platform",
   "kind": "response",
   "note": "Whether one local AI model is downloaded and loaded.",
   "extends": null,
   "fields": [
    {
     "name": "model",
     "type": "string",
     "flags": "",
     "note": "The model asked about."
    },
    {
     "name": "pulled",
     "type": "boolean",
     "flags": "",
     "note": "Whether it is downloaded."
    },
    {
     "name": "ready",
     "type": "boolean",
     "flags": "",
     "note": "Whether it is loaded and can answer."
    },
    {
     "name": "state",
     "type": "string",
     "flags": "",
     "note": "ready | loading | pulling | engine_unreachable"
    },
    {
     "name": "reachable",
     "type": "boolean",
     "flags": "",
     "note": "Whether the model engine answered."
    },
    {
     "name": "previousResident",
     "type": "boolean",
     "flags": "nullable",
     "note": "Whether the model named by ?previous is still in memory. Null when the engine is unreachable."
    },
    {
     "name": "consumers",
     "type": "object[]",
     "flags": "",
     "note": "The features that use the local AI, each { key, label, detail, permission }."
    }
   ]
  },
  {
   "id": "AiModelCapability",
   "name": "AiModelCapability",
   "group": "platform",
   "kind": "response",
   "note": "Which models this server can run.",
   "extends": null,
   "fields": [
    {
     "name": "box",
     "type": "object",
     "flags": "",
     "note": "{ cores, ramGB, gpu, cpuParamCap }: the server as the AI service sees it."
    },
    {
     "name": "models",
     "type": "object[]",
     "flags": "",
     "note": "Each { id, label, sizeB, ramGB, fits: boolean, reason }."
    },
    {
     "name": "defaultModel",
     "type": "string",
     "flags": "",
     "note": ""
    }
   ]
  },
  {
   "id": "Notification",
   "name": "Notification",
   "group": "platform",
   "kind": "response",
   "note": "One entry of the signed-in user’s own notification feed. The feed is a bare array of at most 50: unread entries first, newest first; then read entries in the order they were read, earliest first. It takes no paging parameter.",
   "extends": null,
   "fields": [
    {
     "name": "id",
     "type": "integer",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "userId",
     "type": "integer",
     "flags": "read-only",
     "note": "Always the caller."
    },
    {
     "name": "type",
     "type": "string",
     "flags": "",
     "note": "info when none was set."
    },
    {
     "name": "message",
     "type": "string",
     "flags": "",
     "note": ""
    },
    {
     "name": "link",
     "type": "string",
     "flags": "nullable",
     "note": "Where in the app the notification points."
    },
    {
     "name": "readAt",
     "type": "string (date-time)",
     "flags": "nullable",
     "note": "Null while unread."
    },
    {
     "name": "candidateId",
     "type": "integer",
     "flags": "nullable",
     "note": ""
    },
    {
     "name": "createdAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    },
    {
     "name": "updatedAt",
     "type": "string (date-time)",
     "flags": "read-only",
     "note": ""
    }
   ]
  },
  {
   "id": "WorkflowReject",
   "name": "WorkflowReject",
   "group": "platform",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "reason",
     "type": "string",
     "flags": "required",
     "note": "Not empty."
    }
   ]
  },
  {
   "id": "WorkflowBulkDecision",
   "name": "WorkflowBulkDecision",
   "group": "platform",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "ids",
     "type": "integer[]",
     "flags": "required",
     "note": "1 to 200 workflow ids. A repeated id is processed once."
    },
    {
     "name": "action",
     "type": "string: promote | reject",
     "flags": "required",
     "note": ""
    },
    {
     "name": "reason",
     "type": "string",
     "flags": "optional",
     "note": "Required, and not empty, when action is reject."
    }
   ]
  },
  {
   "id": "WorkflowClone",
   "name": "WorkflowClone",
   "group": "platform",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "name",
     "type": "string",
     "flags": "optional",
     "note": "The name of the copy, trimmed to 120 characters. Left out, the copy is named after its source with \" (copy)\" added."
    }
   ]
  },
  {
   "id": "WorkflowRetire",
   "name": "WorkflowRetire",
   "group": "platform",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "retired",
     "type": "boolean",
     "flags": "optional",
     "note": "True retires the pipeline. Anything else, or leaving it out, reinstates it."
    }
   ]
  },
  {
   "id": "WorkflowRunRecall",
   "name": "WorkflowRunRecall",
   "group": "platform",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "targetNodeId",
     "type": "string",
     "flags": "required",
     "note": "The id of a human step the run has already passed."
    },
    {
     "name": "reason",
     "type": "string",
     "flags": "optional",
     "note": ""
    }
   ]
  },
  {
   "id": "WorkflowNodeExecute",
   "name": "WorkflowNodeExecute",
   "group": "platform",
   "kind": "request",
   "note": "Only these two keys are read. The action, provider and target come from the run’s stored steps.",
   "extends": null,
   "fields": [
    {
     "name": "nodeId",
     "type": "string",
     "flags": "required",
     "note": "The id of the step to run."
    },
    {
     "name": "taskData",
     "type": "object",
     "flags": "optional",
     "note": ""
    }
   ]
  },
  {
   "id": "WorkflowRunSchedule",
   "name": "WorkflowRunSchedule",
   "group": "platform",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "startDate",
     "type": "string (date)",
     "flags": "optional, nullable",
     "note": "A real YYYY-MM-DD date. Null, or leaving the key out, clears it."
    }
   ]
  },
  {
   "id": "CredentialReveal",
   "name": "CredentialReveal",
   "group": "platform",
   "kind": "request",
   "note": null,
   "extends": null,
   "fields": [
    {
     "name": "nodeId",
     "type": "string",
     "flags": "required",
     "note": "The id of the step that created the account."
    }
   ]
  }
 ]
}
